English · Español
01 — Grafos de cómputo¶
🇪🇸 Un grafo de cómputo es un DAG: nodos = valores, aristas = operaciones que produjeron cada valor a partir de sus padres. Se construye implícitamente durante el forward pass cuando Python evalúa expresiones. Es el esqueleto sobre el que después corre el backward.
Qué es un grafo de cómputo¶
Un grafo de cómputo es un grafo dirigido acíclico (DAG) en el que:
- Los nodos son valores intermedios.
- Las aristas apuntan de padres a hijos: si
c = a * b, hay aristasa → cyb → c. - Cada nodo no-hoja está asociado a una operación (la etiqueta
_open nuestra claseValue).
Por convención en autograd, a veces invertimos la dirección de la arista para la visualización, dibujando flechas de hijos a padres — porque esa es la dirección que tomará el recorrido backward. Usaremos ambas según el contexto; la estructura de datos es la misma de una u otra forma.
Un pequeño ejemplo¶
Considera d = a * b + c.
Cinco nodos (a, b, c, ab, d), cuatro aristas, dos operaciones (*, +). El DAG es acíclico (sin ciclos), dirigido (las aristas tienen dirección) y enraizado en la salida (d).
Cómo se construye¶
Python evalúa d = a * b + c de izquierda a derecha con la precedencia normal:
a * binvocaValue.__mul__(a, b). Este método:- Calcula el dato:
a.data * b.data = 6.0(digamosa=2, b=3). - Crea un nuevo
Valuecon ese dato. - Fija
_prev = (a, b)y_op = '*'en el nuevo nodo. - Adjunta un closure
_backwardque, al ser llamado, añadiráb.data * out.gradaa.gradya.data * out.gradab.grad. - Devuelve el nuevo
Value. Llamémosloab. ab + cinvocaValue.__add__(ab, c). Historia similar: nuevoValuecon datoab.data + c.data = 6 + 10 = 16,_prev = (ab, c),_op = '+',_backwardque añadeout.grada los gradientes de ambos padres. Devuelved.
Cuando la asignación d = ... termina, el DAG entero existe, con los punteros a padres en su sitio y los closures _backward armados. El forward pass es el pase de construcción del grafo. No hay un paso separado de "compile" o "trace" — eso es modo eager, y así funcionan PyTorch (y micrograd, y nuestro minigrad).
Por qué un DAG (no un árbol, no un grafo general)¶
Por qué acíclico: los Values se crean estrictamente después que sus padres. Un nuevo Value solo puede apuntar a padres ya existentes. No hay forma de crear un ciclo (necesitarías un nodo que sea su propio ancestro, pero en el momento de la creación ninguno de sus ancestros existe todavía).
Por qué dirigido: la relación padre-hijo es intrínsecamente direccional. a * b hace a a y b padres del producto; no hay simetría.
Por qué no un árbol: un único Value puede ser padre de varios hijos. Ejemplo: d = a * b + a tiene a apareciendo dos veces — una como multiplicando, otra como sumando. a tiene dos aristas salientes (una a ab, otra a d). Este es el patrón en diamante, y es donde las implementaciones ingenuas de retropropagación se rompen (lo veremos en 03-worked-backprop.md).
(Dibujé a dos veces en ASCII pero literalmente es el mismo objeto en memoria.)
Hojas vs nodos internos¶
Una hoja es un Value cuyo _prev está vacío — es decir, no fue producido por ninguna operación, fue construido directamente con Value(some_float). Las hojas son típicamente:
- Parámetros — pesos y sesgos aprendibles.
- Entradas — features alimentadas al modelo.
- Constantes — valores fijados a mano que no cambian.
Un nodo interno tiene _prev conteniendo al menos un padre. Fue producido por una operación.
Esto importa porque:
- Durante el backward, el
_backwardde las hojas es un no-op (nada que propagar más — no tienen padres). - Durante el entrenamiento, solo las hojas-parámetro son actualizadas por el optimizador; las hojas de entrada/constantes mantienen sus valores. (En la Fase 9,
Parameterserá el marcador explícito.)
En la Fase 7 no distinguimos — cada Value(...) es una hoja, y el bucle de entrenamiento XOR aplica manualmente actualizaciones a los pesos. La Fase 9 introduce Parameter como una subclase de Tensor que el optimizador conoce.
Qué es _backward, exactamente¶
_backward es una función sin argumentos que, al ser llamada, contribuye el gradiente de este nodo de vuelta a los gradientes de sus padres. Es un closure — captura a los padres por referencia en el momento de creación de la operación.
Concretamente, para c = a * b:
def _backward():
a.grad += b.data * c.grad # ∂c/∂a · ∂L/∂c = b · ∂L/∂c
b.grad += a.data * c.grad # ∂c/∂b · ∂L/∂c = a · ∂L/∂c
c._backward = _backward
Tres cosas que notar:
+=, no=. Porque un padre puede tener varios hijos, y la contribución de cada hijo debe sumarse. (Caso del diamante.)- Lee
c.grad. Por esto el backward debe llamarse en orden topológico —c.graddebe estar completamente acumulado desde los hijos descendientes decantes de quec._backwardse ejecute, para que el gradiente que propaga sea correcto. - El closure captura
aybpor referencia. Cuando se llama a_backward(potencialmente mucho después, tras añadir muchas más operaciones al grafo), todavía se refiere a los mismos objetosayb. La semántica de closures de Python hace que esto funcione; solo hay que tener cuidado de no usar una variable de bucle descuidadamente.
El _backward de la hoja es el no-op por defecto: lambda: None. Los nodos internos reciben un closure con sentido cuando se ejecuta el constructor de su operación.
Cómo funciona el recorrido backward (topo-sort + recorrido inverso)¶
El algoritmo de backward:
def backward(self):
# 1. Construir el orden topológico: padres antes que hijos.
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)
# 2. Semilla: ∂L/∂L = 1.
self.grad = 1.0
# 3. Recorrer en reverso: hijos antes que padres.
for v in reversed(topo):
v._backward()
Vamos a trazarlo sobre d = a*b + c:
build(d): visita primero a los padres ded(ab, c).build(ab): visita primero a los padres deab(a, b).build(a): sin padres, añadea.topo = [a].build(b): sin padres, añadeb.topo = [a, b].- Después de que los padres de
abterminen, añadeab.topo = [a, b, ab]. build(c): sin padres, añadec.topo = [a, b, ab, c].- Añade
d.topo = [a, b, ab, c, d].
Reverso: [d, c, ab, b, a]. Semilla d.grad = 1. Recorrido:
d._backward():ab.grad += 1 * d.grad = 1;c.grad += 1 * d.grad = 1. (La derivada local de la suma es 1 para ambos padres.)c._backward(): no-op (hoja).ab._backward():a.grad += b.data * ab.grad = 3 * 1 = 3;b.grad += a.data * ab.grad = 2 * 1 = 2.b._backward(): no-op.a._backward(): no-op.
Gradientes finales: a.grad = 3, b.grad = 2, c.grad = 1. Verifica a mano: d = ab + c = a*b + c. ∂d/∂a = b = 3 ✓. ∂d/∂b = a = 2 ✓. ∂d/∂c = 1 ✓.
El patrón en diamante, a cámara lenta¶
d = a*b + a (a usado dos veces):
ab = a * b.ab._prev = (a, b).d = ab + a.d._prev = (ab, a).aaparece tanto en(a, b)como en(ab, a)— el mismo objeto ambas veces.
Topo-sort (un orden válido): [b, a, ab, d]. Reverso: [d, ab, a, b]. Semilla d.grad = 1.
d._backward():ab.grad += 1;a.grad += 1. Ahoraa.grad = 1.ab._backward():a.grad += b.data * ab.grad = b·1 = b;b.grad += a.data * ab.grad = a·1 = a.
Después: a.grad = 1 + b. Verifica: d = a·b + a. ∂d/∂a = b + 1 ✓.
Las dos contribuciones a a.grad llegaron en dos llamadas _backward separadas. Se sumaron porque usamos +=. Si hubiéramos usado =, la segunda llamada habría sobrescrito la primera y tendríamos a.grad = b, que está mal.
Esta es la razón individual más importante por la que _backward usa +=. Tételo explícitamente.
Por qué esto es "autodiferenciación en modo inverso"¶
Dos sabores de diferenciación automática:
- Forward-mode: propaga la información de derivada hacia adelante junto con los datos. Para computar
∂L/∂apara algún parámetroa, siembrasacon derivada 1, fijas la derivada de los demás parámetros a 0, y propagas hacia adelante. Coste: un forward pass por parámetro. Malo cuando tienes millones de parámetros y una pérdida. - Reverse-mode (esta fase): propaga la información de derivada hacia atrás desde la pérdida. Un backward pass te da ∂L/∂cada-parámetro. Coste: un forward + un backward, independientemente del número de parámetros.
Para LLMs: 1 pérdida, miles de millones de parámetros → reverse-mode gana. Para simulaciones físicas con miles de entradas y una sola salida → reverse-mode gana. Para finanzas con pocas entradas y muchas salidas → forward-mode podría ganar.
La regla práctica de Karpathy: reverse-mode cuando n_outputs << n_inputs. Forward-mode cuando n_outputs >> n_inputs. La Fase 7 solo construye reverse-mode; eso es todo lo que ML usa.
Escollos para fijar (vista previa de bugs comunes)¶
Estos morderán en el lab. Aviso:
- Olvidar
+=— usar=en_backwardsilenciosamente descarta contribuciones para nodos usados varias veces. El test de diamante lo captura. - Re-ejecutar
backward()sin poner a cero — los gradientes acumulan; la segunda llamada los duplica. El optimizador de la Fase 9 introducirázero_grad(). - Mutar
dataentre forward y backward — el closure de backward capturóa.data(como valor, ya que float es inmutable) en el momento de creación de la operación… espera, en realidad, el closure capturaacomo objeto, y leea.dataen el momento de la llamada. Si mutasa.dataentre forward y backward, el closure leerá el nuevo valor. Sutil. No mutes en vuelo. - Topo-sort con ciclos — no puede pasar por construcción en nuestro framework, pero un
assertdefensivo es barato. set()conteniendo instanciasValue—Valuenecesita__hash__para pertenencia aset. El hash por defecto deobject(por identidad) funciona bien. No sobrescribas__eq__salvo que también sobrescribas__hash__.
Recapitulación en un párrafo¶
Un grafo de cómputo es un DAG donde cada nodo no-hoja está asociado a una operación y a un closure que contribuye el gradiente del nodo de vuelta a sus padres. El grafo se construye implícitamente durante el forward pass sobrecargando los operadores aritméticos de Python; el backward pass es un topo-sort seguido de un recorrido inverso, llamando al closure _backward de cada nodo para acumular gradientes vía la regla de la cadena. La acumulación con += maneja el caso del diamante donde un nodo tiene varios hijos. Esto es diferenciación automática en modo inverso — la elección correcta para ML porque tenemos muchos parámetros y una pérdida escalar.
Siguiente: 02-op-derivatives.md