Skip to content

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 aristas a → c y b → c.
  • Cada nodo no-hoja está asociado a una operación (la etiqueta _op en nuestra clase Value).

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.

a ──┐
    ╳ (*)──> ab ──┐
b ──┘             ╳ (+)──> d
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:

  1. a * b invoca Value.__mul__(a, b). Este método:
  2. Calcula el dato: a.data * b.data = 6.0 (digamos a=2, b=3).
  3. Crea un nuevo Value con ese dato.
  4. Fija _prev = (a, b) y _op = '*' en el nuevo nodo.
  5. Adjunta un closure _backward que, al ser llamado, añadirá b.data * out.grad a a.grad y a.data * out.grad a b.grad.
  6. Devuelve el nuevo Value. Llamémoslo ab.
  7. ab + c invoca Value.__add__(ab, c). Historia similar: nuevo Value con dato ab.data + c.data = 6 + 10 = 16, _prev = (ab, c), _op = '+', _backward que añade out.grad a los gradientes de ambos padres. Devuelve d.

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).

a ──┬──> ab ──┐
    │         ╳ (+)──> d
b ──┘         │
    ┌─────────┘
a (mismo nodo)

(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 _backward de 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, Parameter será 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:

  1. +=, no =. Porque un padre puede tener varios hijos, y la contribución de cada hijo debe sumarse. (Caso del diamante.)
  2. Lee c.grad. Por esto el backward debe llamarse en orden topológico — c.grad debe estar completamente acumulado desde los hijos descendientes de c antes de que c._backward se ejecute, para que el gradiente que propaga sea correcto.
  3. El closure captura a y b por 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 objetos a y b. 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 de d (ab, c).
  • build(ab): visita primero a los padres de ab (a, b).
  • build(a): sin padres, añade a. topo = [a].
  • build(b): sin padres, añade b. topo = [a, b].
  • Después de que los padres de ab terminen, añade ab. topo = [a, b, ab].
  • build(c): sin padres, añade c. 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).
  • a aparece 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. Ahora a.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/∂a para algún parámetro a, siembras a con 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:

  1. Olvidar += — usar = en _backward silenciosamente descarta contribuciones para nodos usados varias veces. El test de diamante lo captura.
  2. Re-ejecutar backward() sin poner a cero — los gradientes acumulan; la segunda llamada los duplica. El optimizador de la Fase 9 introducirá zero_grad().
  3. Mutar data entre 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 captura a como objeto, y lee a.data en el momento de la llamada. Si mutas a.data entre forward y backward, el closure leerá el nuevo valor. Sutil. No mutes en vuelo.
  4. Topo-sort con ciclos — no puede pasar por construcción en nuestro framework, pero un assert defensivo es barato.
  5. set() conteniendo instancias ValueValue necesita __hash__ para pertenencia a set. El hash por defecto de object (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