Skip to content

English · Español

01 — Tensor como nodo del autograd

La estructura del Tensor: cinco campos, todos isomorfos a los del Value de la fase 7, pero con data y grad ahora como ndarrays. Lo único realmente nuevo es la bandera requires_grad, que controla si el nodo participa o no en el grafo.


La forma de la clase

class Tensor:
    data: np.ndarray              # the actual values, dtype float32 or float64
    grad: np.ndarray | None       # same shape as data; None until backward populates it
    _prev: tuple[Tensor, ...]     # parents
    _op: str                      # op tag for debugging / visualization
    _backward: Callable[[], None] # closure that contributes to parents' grads
    requires_grad: bool           # if False, this node is a constant (no grad tracking)

    def __init__(self, data, requires_grad=False, _prev=(), _op="") -> None: ...
    def backward(self) -> None: ...
    # ... ops as methods and via dunders ...

Idéntica en forma al Value de la fase 7. Las diferencias:

  1. data: np.ndarray en lugar de float. Lleva consigo forma (shape), dtype, strides — toda la maquinaria de la fase 6 aplica.
  2. grad: np.ndarray | None en lugar de float. El valor inicial None nos permite detectar barato "nunca tuvo gradiente" frente a "tuvo un gradiente que se puso a cero".
  3. requires_grad: bool — la nueva bandera.

Por qué la forma (shape) de grad siempre iguala a la de data

La actualización del optimizador es elementwise: p.data -= lr * p.grad. Para que esto esté bien definido, p.data y p.grad deben tener la misma forma (shape).

Si forward broadcastea a.shape = (3,) a (4, 3) y produce una salida c.shape = (4, 3), el gradiente upstream en c tiene forma (shape) (4, 3) — pero la contribución a a.grad debe tener forma (shape) (3,), coincidiendo con a.data. La operación de suma-a-lo-largo-de-los-ejes-de-broadcasting reconcilia esto. (Detalles en theory/02.)

Regla: tensor.grad.shape == tensor.data.shape. Siempre. Conviértelo en un unit test.

requires_grad: quién posee el grafo

Algunos tensores no deberían tener gradientes:

  • Datos de entrada. Features alimentados al modelo. No los actualizas.
  • Constantes. Cosas como máscaras, constantes de normalización, etiquetas objetivo.
  • Tensores detached. Un tensor que has "cortado" explícitamente del grafo (p. ej., para evaluar el modelo sin tracking de gradiente).

Algunos tensores deben tener gradientes:

  • Parámetros. Pesos y sesgos. El optimizador los actualiza vía .grad.

La bandera requires_grad controla la participación en el grafo:

x = Tensor(data, requires_grad=False)  # input, won't get a grad
w = Tensor(data, requires_grad=True)   # parameter, will get a grad
y = w @ x                              # y.requires_grad = True (inherited from w)

Regla de propagación

El requires_grad de un tensor resultado es True si alguno de sus padres tiene requires_grad=True. En caso contrario, False.

out_requires_grad = any(p.requires_grad for p in parents)

Si out.requires_grad es False, ni siquiera creamos el closure _backward — no tiene sentido. El forward pasa; el resultado es un Tensor plano sin grafo adjunto. Esta es una pequeña optimización en minitorch; es la misma optimización que hace PyTorch (context manager torch.no_grad()).

¿Por qué no trackear siempre?

Tres razones:

  1. Memoria. Trackear gradientes significa mantener intermedios vivos hasta el backward. Para un forward de inferencia sobre un modelo de 100M parámetros, esto son gigabytes ahorrados.
  2. Velocidad. Construir closures tiene overhead. Sáltalo cuando sea innecesario.
  3. Corrección. Trackear gradientes durante la evaluación puede enmascarar bugs (p. ej., si por accidente retropropagas por tu pipeline de eval). Un requires_grad=False explícito deja clara la intención.

En la fase 18 (loop de entrenamiento), Borja usará esto sistemáticamente: contexto with no_grad(): (o su equivalente en minitorch) para la pasada de evaluación.

El contrato de dtype de data

Tensor.data es un array de NumPy. El dtype es fp32 por defecto para nuestros propósitos — eso coincide con lo que usa la inferencia ML real (FP16 es para producción; mantenemos FP32 por claridad y para tests dtype-uniformes).

Para testing, usamos fp64. Razón: gradcheck necesita precisión FP64 (~16 dígitos) para tener sentido. En FP32, el error de truncamiento+redondeo de las diferencias finitas es ~1e-3 y gradcheck tendría que aceptar tolerancias absurdas.

Regla: - Código de forma (shape) de producción en tensor.py: fp32. - Tests en tests/test_tensor_autograd.py: fp64. - El constructor de Tensor acepta cualquier dtype; no lo fuerza.

Plantilla de construcción del forward

Toda op sigue la misma forma:

def some_op(self, other) -> Tensor:
    # 1. Compute forward data.
    out_data = some_numpy_op(self.data, other.data)

    # 2. Decide if grad tracking is needed.
    out_requires_grad = self.requires_grad or other.requires_grad

    # 3. Construct output tensor.
    out = Tensor(
        out_data,
        requires_grad=out_requires_grad,
        _prev=(self, other) if out_requires_grad else (),
        _op="some_op",
    )

    # 4. If tracking, define the backward closure.
    if out_requires_grad:
        def _backward():
            if self.requires_grad:
                self_grad_contribution = ...  # uses upstream out.grad and local Jacobian
                # Sum along broadcast axes if needed:
                self_grad_contribution = unbroadcast(self_grad_contribution, self.data.shape)
                self.grad = (self.grad if self.grad is not None else 0) + self_grad_contribution
            if other.requires_grad:
                other_grad_contribution = ...
                other_grad_contribution = unbroadcast(other_grad_contribution, other.data.shape)
                other.grad = (other.grad if other.grad is not None else 0) + other_grad_contribution
        out._backward = _backward

    return out

Tres patrones a notar:

  1. _prev está vacío si no hay tracking. Esto ahorra aún más memoria.
  2. if requires_grad por padre. No calcules una contribución de gradiente para un padre que no la quiere.
  3. unbroadcast(grad, target_shape) es un helper que suma grad a lo largo de los ejes en los que fue broadcasteado desde target_shape. Lo implementaremos en 02-tensor-op-derivatives.md.
  4. self.grad if self.grad is not None else 0. La primera contribución reserva memoria de forma perezosa; las contribuciones posteriores se acumulan vía +. El 0 es un int de Python que NumPy broadcasteará trivialmente. (Alternativa: reservar siempre np.zeros_like(self.data) al construir. Coste de memoria = un duplicado de cada parámetro. La fase 8 escoge lazy.)

El método backward()

Algoritmo idéntico al de la fase 7:

def backward(self) -> None:
    if not self.requires_grad:
        raise RuntimeError("backward called on tensor that doesn't require grad")

    # Build topological order.
    topo = []
    visited = set()
    def build(v):
        if v in visited: return
        visited.add(v)
        for p in v._prev:
            build(p)
        topo.append(v)
    build(self)

    # Seed.
    self.grad = np.ones_like(self.data)

    # Reverse walk.
    for v in reversed(topo):
        v._backward()

Diferencias respecto a la fase 7:

  • La semilla es np.ones_like(self.data) en lugar de 1.0. Si self.data es un tensor escalar (loss), np.ones_like devuelve un array 0-D con valor 1. Si self.data es un tensor (p. ej., llamando a backward sobre un no-escalar), np.ones_like devuelve un tensor de unos — que no es lo que quieres a menos que tengas una razón específica. Convención: backward() solo se llama sobre tensores escalares (losses).

PyTorch lo impone: tensor.backward() requiere un tensor escalar o un argumento gradient= explícito. Nosotros hacemos lo mismo: assert self.data.shape == () (o .ndim == 0).

  • Defensivo: requires_grad debe ser True en el objetivo de la llamada.

Implicaciones de memoria

La fase 7 tenía grafos de ~100 nodos (XOR MLP). La fase 8 tendrá grafos con miles de nodos (un bloque transformer son ~50 ops; multiplica por longitud de secuencia y tamaño de batch).

Cada Tensor en memoria mantiene: el array data (grande), el array grad (mismo tamaño), _prev (unos pocos punteros), _op (un string pequeño), _backward (un closure con arrays capturados).

Para un forward de MLP con hidden size 256 y batch size 64: - Cada activación oculta: 64 × 256 × 4 bytes = 64 KiB. - MLP de 10 capas: 10 activaciones × 64 KiB = 640 KiB. - Con autograd: 640 KiB para data + 640 KiB para grad = 1,3 MiB.

Trivial. La fase 8 no tiene preocupaciones de memoria. La fase 18 empezará a tenerlas.

Revisitando la pregunta __hash__ / __eq__

El Value de la fase 7 usaba el hash por defecto del objeto (basado en identidad) y no sobreescribía __eq__. Igual aquí para Tensor. No queremos que Tensor(a) == Tensor(a) devuelva un bool — queremos que devuelva un Tensor de booleanos elementwise (como NumPy), y que hash siga funcionando para pertenencia a set.

Convención: - __eq__ no sobreescrito → igualdad por identidad por defecto. - Para comparación elementwise, usa Tensor.equal(other) o np.array_equal(a.data, b.data).

PyTorch lo gestiona de forma diferente — sobrecarga == para elementwise. Nosotros no, porque nos impediría usar Tensor en un set (el visited del topo sort se rompería).

Escollos (te morderán en el lab)

  1. out.grad.shape no coincide con out.data.shape. Añade un assert al inicio de cada _backward: assert self.grad is None or self.grad.shape == self.data.shape. (Cuesta runtime — gatea tras una debug flag si hace falta.)
  2. requires_grad=True no propagado a través de una op. Test: (Tensor(x, requires_grad=False) + Tensor(y, requires_grad=True)).requires_grad == True.
  3. backward() sobre un no-escalar. Debe lanzar excepción. Testéalo.
  4. backward() sobre un tensor constante (requires_grad=False). Debe lanzar excepción. Testéalo.
  5. Olvidar np.ones_like en la semilla. Usar 1.0 hace self.grad = 1.0 (un float de Python), y el primer _backward fallará porque espera un ndarray.

Recapitulación en un párrafo

Tensor es isomorfo al Value de la fase 7 pero con data: ndarray y grad: ndarray | None. El contrato de forma (shape) — grad.shape == data.shape — es invariante. requires_grad controla la participación en el grafo; se propaga como el any() de los padres. La plantilla del forward de una op tiene cuatro pasos (computar, decidir, construir, definir _backward), y el método backward() es el mismo topo-sort + traversal inverso de antes, con np.ones_like como semilla y un assert de solo-escalar. Todo lo estructural en la fase 8 es la fase 7 con shapes; todo lo matemático (cubierto en 02-04) es genuinamente nuevo.


Siguiente: 02-tensor-op-derivatives.md