15 min

Der Tensor

Alles in ML ist Rechnen auf Zahlen-Arrays.

Meilenstein a+b, a*b und a@b rechnen korrekt.

PyTorch heißt PyTorch wegen des Tensors. Alles, was durch ein neuronales Netz läuft, ist einer: die Eingabedaten, die Gewichte, jedes Zwischenergebnis und später auch jeder Gradient. Und ein Tensor ist nichts Geheimnisvolles, sondern ein Feld aus Zahlen mit einer Form. Ein Graustufenbild ist ein Tensor der Form (28, 28), ein Stapel aus 32 solchen Bildern einer der Form (32, 28, 28).

Dieser erste Schritt baut genau diese Hülle. Sie kann noch nichts von Gradienten wissen, das kommt im nächsten Kapitel. Sie kann drei Dinge: addieren, elementweise multiplizieren und das Matrixprodukt bilden. Mehr braucht ein Netz für den Vorwärtslauf nicht.

Das Ziel

Alles in ML ist Rechnen auf Zahlen-Arrays. Wer das einmal ausgesprochen hat, verliert einen guten Teil der Ehrfurcht vor der Sache. Ein Netz „versteht“ nichts. Es multipliziert eine Matrix mit einer anderen, addiert einen Vektor dazu und schneidet negative Werte ab. Die drei Operationen dieses Kapitels sind schon fast alles, was in einem Vorwärtslauf passiert.

Wichtig ist der Unterschied zwischen den beiden Multiplikationen, weil er später die häufigste Fehlerquelle ist:

  • a * b ist elementweise: gleiche Form rein, gleiche Form raus. Jede Zahl trifft ihre Partnerin an derselben Stelle.
  • a @ b ist das Matrixprodukt: (n, k) mal (k, m) ergibt (n, m). Hier fallen Dimensionen zusammen, und genau das macht eine Schicht aus.

Zero Magic in diesem Schritt

Die Regel des Kurses gilt ab der ersten Zeile: NumPy ist erlaubt, torch, jax, tinygrad und autograd sind verboten. NumPy nimmt dir das Rechnen auf Zahlenfeldern ab - np.asarray, +, *, @ -, und das ist in Ordnung, weil schnelle Array-Arithmetik nicht der Punkt dieses Kurses ist. Der Punkt ist die Hülle darum, und die schreibst du selbst.

Das Gerüst

Leg dein Repo an. Zwei Ebenen, weil meintorch gleichzeitig das Projekt und das Paket darin ist - das ist das übliche Layout für Python-Pakete:

meintorch/                  ← dein Repo
├── meintorch/              ← das Paket, dein Framework
│   ├── __init__.py         (leer)
│   └── tensor.py
└── tests/
    └── test_01_tensor.py

Die Tests startest du immer aus dem Repo-Wurzelverzeichnis heraus mit python -m pytest. Das -m ist nicht Geschmackssache: Es legt das aktuelle Verzeichnis auf den Importpfad, und nur deshalb findet from meintorch.tensor import Tensor überhaupt etwas.

Die Aufgabe

Das ist der Coach-Modus: Du bekommst das Gerüst und die Lücken, den Kern schreibst du. meintorch/tensor.py:

import numpy as np


def as_tensor(x):
    """Zahlen und Listen werden zu Tensoren, Tensoren bleiben, was sie sind."""
    return x if isinstance(x, Tensor) else Tensor(x)


class Tensor:
    """Ein Feld aus Zahlen mit einer Form. Mehr ist es an dieser Stelle nicht."""

    def __init__(self, data):
        self.data = np.asarray(data, dtype=np.float64)

    @property
    def shape(self):
        return self.data.shape

    def __repr__(self):
        return f"Tensor({self.data})"

    def __add__(self, other):
        # TODO(du): elementweise Addition, Ergebnis wieder als Tensor
        raise NotImplementedError

    def __mul__(self, other):
        # TODO(du): elementweise Multiplikation - kein Matrixprodukt!
        raise NotImplementedError

    def __matmul__(self, other):
        # TODO(du): das Matrixprodukt
        raise NotImplementedError

Hinweis 1 - die Richtung

Jede der drei Methoden ist ein Dreizeiler nach demselben Muster: die andere Seite zu einem Tensor machen, NumPy rechnen lassen, das Ergebnis wieder in einen Tensor packen. Nichts davon braucht eine Schleife.

Hinweis 2 - konkreter

self.data und other.data sind NumPy-Arrays. NumPy kennt für alle drei Fälle schon den passenden Operator; du gibst ihn nur weiter. Und as_tensor steht nicht zufällig oben - ohne den Aufruf scheitert a * 2.0, weil eine Zahl kein .data hat.

Lösung anzeigen
    def __add__(self, other):
        other = as_tensor(other)
        return Tensor(self.data + other.data)

    def __mul__(self, other):
        other = as_tensor(other)
        return Tensor(self.data * other.data)

    def __matmul__(self, other):
        other = as_tensor(other)
        return Tensor(self.data @ other.data)

Im Goal-Modus

Derselbe Schritt, autonom: Der Agent legt Repo und Paket an, implementiert die drei Operatoren, schreibt den Meilenstein-Test mit und führt ihn aus, bis er grün ist. Er fragt nicht nach, er liefert ein fertiges Kapitel ab. Der Auftrag dafür lautet:

Bau-Prompt
Lege ein Python-Repo `meintorch/` mit dem Paket `meintorch/meintorch/` an.
Implementiere in `meintorch/tensor.py` eine Klasse `Tensor`, die ein NumPy-Array
(dtype float64) kapselt und `__add__`, `__mul__` und `__matmul__` unterstützt -
elementweise Addition, elementweise Multiplikation, Matrixprodukt. Skalare und
Listen auf der rechten Seite müssen ebenfalls funktionieren.
Zwingend: NumPy ist erlaubt, torch/jax/tinygrad/autograd sind verboten.
Schreibe dazu `tests/test_01_tensor.py` und führe `python -m pytest` aus,
bis alle Tests grün sind. Zeige mir am Ende die Testausgabe.

Der Meilenstein-Test

tests/test_01_tensor.py. Grün heißt: Kapitel fertig.

import numpy as np

from meintorch.tensor import Tensor


def test_addition_ist_elementweise():
    a = Tensor([1.0, 2.0, 3.0])
    b = Tensor([10.0, 20.0, 30.0])
    assert np.allclose((a + b).data, [11.0, 22.0, 33.0])


def test_multiplikation_ist_elementweise():
    a = Tensor([1.0, 2.0, 3.0])
    b = Tensor([10.0, 20.0, 30.0])
    assert np.allclose((a * b).data, [10.0, 40.0, 90.0])


def test_matmul_ist_das_matrixprodukt():
    a = Tensor([[1.0, 2.0], [3.0, 4.0]])
    b = Tensor([[5.0, 6.0], [7.0, 8.0]])
    assert np.allclose((a @ b).data, [[19.0, 22.0], [43.0, 50.0]])


def test_form_und_skalare():
    a = Tensor([[1.0, 2.0, 3.0]])
    assert a.shape == (1, 3)
    assert np.allclose((a * 2.0).data, [[2.0, 4.0, 6.0]])