English · Español
01 — Tensor como nodo del autograd¶
La estructura del
Tensor: cinco campos, todos isomorfos a los delValuede la fase 7, pero condataygradahora comondarrays. Lo único realmente nuevo es la banderarequires_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:
data: np.ndarrayen lugar defloat. Lleva consigo forma (shape), dtype, strides — toda la maquinaria de la fase 6 aplica.grad: np.ndarray | Noneen lugar defloat. El valor inicialNonenos permite detectar barato "nunca tuvo gradiente" frente a "tuvo un gradiente que se puso a cero".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.
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:
- 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.
- Velocidad. Construir closures tiene overhead. Sáltalo cuando sea innecesario.
- 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=Falseexplí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:
_prevestá vacío si no hay tracking. Esto ahorra aún más memoria.if requires_gradpor padre. No calcules una contribución de gradiente para un padre que no la quiere.unbroadcast(grad, target_shape)es un helper que sumagrada lo largo de los ejes en los que fue broadcasteado desdetarget_shape. Lo implementaremos en02-tensor-op-derivatives.md.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+. El0es un int de Python que NumPy broadcasteará trivialmente. (Alternativa: reservar siemprenp.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 de1.0. Siself.dataes un tensor escalar (loss),np.ones_likedevuelve un array 0-D con valor 1. Siself.dataes un tensor (p. ej., llamando a backward sobre un no-escalar),np.ones_likedevuelve 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_graddebe 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)¶
out.grad.shapeno coincide conout.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.)requires_grad=Trueno propagado a través de una op. Test:(Tensor(x, requires_grad=False) + Tensor(y, requires_grad=True)).requires_grad == True.backward()sobre un no-escalar. Debe lanzar excepción. Testéalo.backward()sobre un tensor constante (requires_grad=False). Debe lanzar excepción. Testéalo.- Olvidar
np.ones_likeen la semilla. Usar1.0haceself.grad = 1.0(un float de Python), y el primer_backwardfallará 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