English · Español
00 — Motivación: del escalar al tensor¶
La fase 7 te dejó con la mecánica del autograd. Aquí no inventamos nada nuevo — el algoritmo es idéntico — pero cambiamos el
floatpor unndarrayde NumPy y, con eso, aparecen dos fuentes nuevas de complejidad: el broadcasting reverso (sumar gradientes a lo largo de ejes expandidos) y las derivadas tensoriales de ops como matmul y softmax. Esta fase te enseña a no temerles.
La forma de la nueva complejidad¶
El Value de la fase 7 contenía un único float de Python. El Tensor de la fase 8 contiene un ndarray de NumPy. El algoritmo de autograd es idéntico:
- Un
Tensornace de una op. - La op registra
_prev,_opy un closure_backward. backward()hace un orden topológico (topo sort) y un traversal inverso._backwardcontribuye al.gradde los padres.
Lo nuevo:
- Formas (shapes). Todo
Tensortiene undata.shape. Su.gradtiene la misma forma (shape) que.data(para que el paso del optimizadorp.data -= lr · p.gradtenga sentido elementwise). - Broadcasting en forward.
a + bcona.shape = (3,)yb.shape = (4, 3)produce una salida(4, 3). NumPy gestiona el forward. - Broadcasting en backward. Cuando corre el backward,
adebe recibir un gradiente(3,)ybuno(4, 3). NumPy no hace esto por nosotros. Tenemos que sumar el gradiente upstream(4, 3)a lo largo del eje de broadcasting para obtener la forma (shape) correcta paraa. Esta es la nueva fuente de bugs. - Derivadas por op que no son triviales. Matmul, softmax, cross-entropy. Estas requieren una derivación cuidadosa (
theory/02ytheory/03). - Ops de reducción.
sum,mean,softmaxreducen a lo largo de ejes; su backward debe hacerbroadcast_topara devolver el gradiente upstream a la forma (shape) de entrada. requires_grad. No todos los tensores necesitan gradientes — los datos de entrada no, los pesos sí. Una bandera controla la construcción del grafo.- La estrategia de testing escala. El cross-check op por op contra PyTorch no basta. También usamos
gradcheck(verificación por diferencias finitas) y tests de propiedades conhypothesis(fuzzing de formas (shapes) aleatorias).
Por qué "construirlo una vez a grano tensorial"¶
Mismo argumento que en la fase 7 con "construirlo a grano escalar": la complejidad de un framework tiene tres fuentes, y la fase 7 aisló la #1 (el algoritmo). La fase 8 añade ahora las #2 (mecánica de NumPy, para la que la fase 6 nos preparó) y #3 (derivadas tensoriales por op, que derivaremos con cuidado).
Al terminar la fase 8, cuando Borja importe PyTorch en la fase 25, cada línea del motor de autograd de PyTorch es algo que Borja ya ha implementado a menor escala. La librería deja de ser magia.
Anclaje temático (§A13)¶
Todas las formas (shapes) trabajadas en esta fase salen de la rejilla gramatical de verbos en inglés:
- Un tensor
(3, 5)= logits (persona × tiempo) para un verbo. Las reducciones sobreaxis=0dan puntuaciones por tiempo; sobreaxis=1dan puntuaciones por persona. - Un tensor
(20, 3, 5)= la rejilla gramatical completa (20 verbos × 3 personas × 5 tiempos). Un matmul contra una proyección(5, H)produce representaciones ocultas. - Un tensor objetivo entero
(B,)con valores en{0..4}= el índice de tiempo correcto para cada ejemplo en un batch de tamañoB.
El código del autograd es agnóstico de la gramática — Tensor.matmul no sabe qué significan sus ejes. Pero todos los ejemplos elegidos para teoría y lab usan estas formas (shapes), de modo que al final de la fase 8 el modelo mental de shapes de Borja y el corpus §A13 son la misma cosa.
Lo que produce esta fase¶
Un src/minitorch/tensor.py de ~400 LOC por Borja, implementando:
- La clase
Tensorcondata,grad,_prev,_op,_backward,requires_grad. - 20+ ops en tres familias:
- Elementwise:
add sub mul div neg exp log relu gelu tanh. - Reducción/forma:
sum mean reshape transpose broadcast_to getitem cat stack. - Alto riesgo:
matmul softmax cross_entropy. backward()haciendo topo + traversal inverso (estructura idéntica a la fase 7).- Cross-checks contra PyTorch FP64.
- Infraestructura de
gradcheck(diferencias finitas). - Tests de propiedades basados en
hypothesisfuzzeando combinaciones aleatorias de shape/op.
Y un experimento de ML toy: un MLP tensorial de 2 capas entrenado sobre un dataset gramatical — entrada one-hot(verb) ⊕ one-hot(person) (23-dim), salida logits sobre los 5 tiempos, objetivos enteros, ~60 ejemplos de train / 30 de val tomados de la rejilla de conjugación §A13. No será impresionante — esa es la idea. La idea es que gradcheck pase para cada op y que el entrenamiento funcione de punta a punta con el autograd que escribiste tú.
Los dos nuevos bugs a temer¶
La fase 7 tenía dos bugs a temer: (1) olvidar el += en _backward, (2) orden topológico incorrecto. La fase 8 hereda ambos y añade dos más:
Bug 3: olvidar la suma a lo largo de los ejes de broadcasting¶
Si c = a + b fue un broadcast (digamos a.shape = (3,), b.shape = (4, 3), c.shape = (4, 3)), el backward recibe un gradiente upstream de forma (shape) (4, 3). La contribución a a.grad debe ser el upstream sumado a lo largo del eje 0:
a_grad_contribution = upstream.sum(axis=0) # shape (3,) — coincide con a.shape ✓
b_grad_contribution = upstream # shape (4, 3) — coincide con b.shape ✓
Olvida el .sum(axis=0) y a.grad acabará con forma (shape) (4, 3) en lugar de (3,). La siguiente op falla por desajuste de forma (shape). O, peor, vuelve a hacer broadcast silenciosamente.
Este es el bug más común en autograd tensorial. El lab 02 monta la maquinaria para tratarlo genéricamente.
Bug 4: axis/keepdims incorrectos en el backward de reducción¶
y = sum(x, axis=0) con x.shape = (B, N) produce y.shape = (N,). El backward recibe un gradiente upstream de forma (shape) (N,). La contribución a x.grad debe ser el upstream broadcasteado de vuelta a (B, N):
El contrato de forma (shape): el gradiente de x debe igualar a x.shape. Siempre. Si en algún momento produces un gradiente con una forma (shape) distinta, algo va mal upstream.
El nuevo nivel de testing: gradcheck¶
Los tests por op contra PyTorch son necesarios pero no suficientes. PyTorch podría a su vez estar mal (no lo está, pero por paranoia), y el cross-check solo verifica con las formas (shapes) específicas para las que escribiste tests.
gradcheck es la alternativa empírica: dada una función f: Tensor → Tensor y una entrada x, calcula el gradiente de dos formas:
- Autograd:
y = f(x); y.sum().backward(); return x.grad. - Diferencias finitas: para cada elemento
xᵢ, perturba axᵢ ± ε, calcula(f(xᵢ + ε) - f(xᵢ - ε)) / 2ε. Ensambla en un vector. Este es el gradiente numérico.
Los dos deben coincidir en FP64 a ~1e-5 con ε = 1e-7. Si no coinciden, el autograd está mal.
Gradcheck es lento (O(n) evaluaciones de la función por elemento) pero definitivo. Detecta bugs que el cross-check con PyTorch pasaría por alto — p. ej., un backward que resulta correcto para los inputs del test pero erróneo en general.
La fase 8 hace que gradcheck forme parte del toolkit estándar de tests y lo ejecuta sobre cada op para al menos una forma (shape).
El otro nuevo nivel de testing: tests de propiedades con hypothesis¶
hypothesis genera inputs aleatorios. Le decimos: "dame formas (shapes) tensoriales aleatorias (rank 1–4, dims 1–8), ops aleatorias de esta lista, y para toda combinación aleatoria, gradcheck debe pasar". Hypothesis busca automáticamente contraejemplos mínimos cuando algo falla.
En la práctica, hypothesis encuentra casos límite de forma (shape) que los tests escritos a mano se pierden:
- Tensores rank-0 (escalares).
- Dimensiones de tamaño 1.
- Tensores todo-ceros.
- Tensores con forma (shape)
(1, 0, 3)(dim de tamaño cero).
La fase 8 configura hypothesis una vez; las fases futuras reutilizan el mismo arnés.
Por qué nada de ops in-place¶
PyTorch soporta x.relu_() (in-place). minitorch.tensor no lo hará.
La razón: las ops in-place rompen el DAG. Si _backward para c = relu(a) captura a.data para calcular la máscara, y luego alguien hace a.data[mask] = 0 entre forward y backward, el closure ve los datos nuevos (cero) y calcula el gradiente equivocado.
PyTorch lo gestiona con contadores de versión y avisos. Nosotros lo gestionamos no soportando in-place en absoluto. Más limpio pedagógicamente. Algo menos eficiente en memoria. El trade-off es el correcto para nuestra escala.
(La fase 25, internals de PyTorch, explicará cómo PyTorch hace que in-place sea seguro. Por ahora: no.)
Cómo se ve "Borja escribe el cuerpo" en la fase 8¶
La fase 7 fueron ~150 LOC. La fase 8 son ~400 LOC. El mayor tamaño no cambia el contrato:
- Claude escribe BLUEPRINT, teoría, enunciados de lab, stubs de tests.
- Borja escribe
tensor.py. - Las soluciones aparecen al abrir la fase, después de que las decisiones previas de Borja sean visibles.
La fase 8 es una de las más largas del currículo. Planifica ~25–30 horas de estudio. Resiste la tentación de saltarte ops; la cobertura de 20 ops es lo que hace que la librería resultante sea fiable para las fases 9–22.
Recapitulación en un párrafo¶
El autograd tensorial es el mismo algoritmo que el autograd escalar, con dos nuevas fuentes de complejidad atornilladas: broadcasting (que debe revertirse en backward sumando a lo largo de los ejes expandidos) y derivadas por op no triviales (matmul, softmax, cross-entropy). El nivel de testing escala en consecuencia: cross-checks contra PyTorch por op, gradcheck para verificación empírica, hypothesis para fuzzing de formas (shapes) aleatorias. Al final de la fase 8, Borja posee ~400 LOC de código Tensor que hace lo que hace PyTorch a menor escala, con cada gradiente verificado de dos formas.
Siguiente: 01-tensor-as-node.md