Skip to content

English · Español

03 — Retropropagación trabajada, a mano

🇪🇸 Una expresión, dos páginas. Calculamos L = (a·b + c) · (a - c) con a=2, b=3, c=4, primero por forward, luego derivamos ∂L/∂a, ∂L/∂b, ∂L/∂c por dos métodos: (i) regla de la cadena simbólica, (ii) recorrido inverso del grafo. Los dos resultados deben coincidir. Y luego verificamos con diferencias finitas.


La expresión

\[ L = (a \cdot b + c) \cdot (a - c) \]

con a = 2.0, b = 3.0, c = 4.0.

Esta expresión es lo bastante pequeña para hacerla a mano, lo bastante grande para presentar una variable compartida (a aparece en dos sitios — el patrón en diamante de 01-computation-graphs.md).

Paso 1: Forward pass, a mano

Calcula valores intermedios:

  • ab = a · b = 2 · 3 = 6
  • e = ab + c = 6 + 4 = 10 (factor izquierdo)
  • f = a - c = 2 - 4 = -2 (factor derecho)
  • L = e · f = 10 · (-2) = -20

Paso 2: Backward por regla de la cadena simbólica

Calcula cada parcial vía regla de la cadena directamente:

∂L/∂a:

L depende de a a través de dos caminos: - vía e: e = ab + c = a·b + c, luego ∂e/∂a = b. Entonces ∂L/∂a (vía e) = ∂L/∂e · ∂e/∂a = f · b = -2 · 3 = -6. - vía f: f = a - c, luego ∂f/∂a = 1. Entonces ∂L/∂a (vía f) = ∂L/∂f · ∂f/∂a = e · 1 = 10.

Total: ∂L/∂a = -6 + 10 = 4.

∂L/∂b:

b aparece solo en ab = a · b → e. ∂e/∂b = a. Entonces ∂L/∂b = ∂L/∂e · ∂e/∂b = f · a = -2 · 2 = -4.

∂L/∂c:

c aparece en e (como ab + c) y en f (como a - c). - vía e: ∂e/∂c = 1. Contribución: ∂L/∂e · 1 = f = -2. - vía f: ∂f/∂c = -1. Contribución: ∂L/∂f · (-1) = -e = -10.

Total: ∂L/∂c = -2 + (-10) = -12.

Gradientes (simbólicos): ∂L/∂a = 4, ∂L/∂b = -4, ∂L/∂c = -12.

Paso 3: Backward por recorrido del grafo

Ahora hazlo como lo hará minigrad. Primero, dibuja el grafo (padre → hijo):

a ─┬─> ab ─┐
   │       v
   │      (+) ──> e ──┐
   │       ^          v
b ─┘       │         (·) ──> L
c ─┬───────┘          ^
   │                  │
   └──> f ───────────┘
        ^
a ──────┘  (misma a que arriba)
        ^
c ──(-)─┘  (misma c que arriba; f = a - c)

Nodos (en orden del grafo): a, b, c, ab, e, f, L — 7 nodos. Operaciones: ab=a·b, e=ab+c, f=a-c, L=e·f.

Orden topológico (padres antes que hijos, un orden válido): [a, b, c, ab, e, f, L]. (Cualquier orden donde cada nodo venga después de todos sus padres es válido.)

Reverso: [L, f, e, ab, c, b, a].

Semilla: L.grad = 1 (porque ∂L/∂L = 1). Todos los demás .grad = 0 inicialmente.

Recorre en reverso:

1. L._backward(): L = e · f. Backward de mul: - e.grad += f.data · L.grad = -2 · 1 = -2. Ahora e.grad = -2. - f.grad += e.data · L.grad = 10 · 1 = 10. Ahora f.grad = 10.

2. f._backward(): f = a - c. Backward de resta: - a.grad += 1 · f.grad = 10. Ahora a.grad = 10. - c.grad += -1 · f.grad = -10. Ahora c.grad = -10.

3. e._backward(): e = ab + c. Backward de suma: - ab.grad += 1 · e.grad = -2. Ahora ab.grad = -2. - c.grad += 1 · e.grad = -2. Ahora c.grad = -10 + (-2) = -12. (¡Acumulación de diamante!)

4. ab._backward(): ab = a · b. Backward de mul: - a.grad += b.data · ab.grad = 3 · (-2) = -6. Ahora a.grad = 10 + (-6) = 4. (¡Acumulación de diamante!) - b.grad += a.data · ab.grad = 2 · (-2) = -4. Ahora b.grad = -4.

5. c._backward(): hoja, no-op. 6. b._backward(): hoja, no-op. 7. a._backward(): hoja, no-op.

Gradientes finales: a.grad = 4, b.grad = -4, c.grad = -12.

Estos coinciden con las respuestas simbólicas exactamente. ✓

Paso 4: Verificar con diferencias finitas

Elige ε = 1e-5. El gradiente por diferencia central para, digamos, ∂L/∂a es:

\[ \frac{\partial L}{\partial a} \approx \frac{L(a + \epsilon) - L(a - \epsilon)}{2\epsilon} \]

Calcula: - L(2.0 + 1e-5) = (2.00001 · 3 + 4) · (2.00001 - 4) = (6.00003 + 4) · (-1.99999) = 10.00003 · -1.99999 ≈ -20.00003 - 0.00006 ≈ -20.000060...

Rehagamos esto con más cuidado: - ab = 2.00001 · 3 = 6.00003 - e = 6.00003 + 4 = 10.00003 - f = 2.00001 - 4 = -1.99999 - L⁺ = 10.00003 · (-1.99999) = -20.00006 + términos diminutos

Y L⁻: - ab = 1.99999 · 3 = 5.99997 - e = 9.99997 - f = -2.00001 - L⁻ = 9.99997 · -2.00001 ≈ -20.00 + 0.00006... ≈ -19.99994

Diferencia: L⁺ - L⁻ ≈ -20.00006 - (-19.99994) = -0.00012. Divide por 2ε = 2e-5: -0.00012 / 2e-5 = -6.

Espera — eso da -6, no 4. ¿Me equivoqué?

Rehagamos con más cuidado con números explícitos. a = 2 + ε donde ε = 1e-5:

  • ab = (2+ε) · 3 = 6 + 3ε
  • e = 6 + 3ε + 4 = 10 + 3ε
  • f = (2 + ε) - 4 = -2 + ε
  • L⁺ = (10 + 3ε)(-2 + ε) = -20 + 10ε - 6ε + 3ε² = -20 + 4ε + O(ε²)

Para a = 2 - ε: - ab = 6 - 3ε - e = 10 - 3ε - f = -2 - ε - L⁻ = (10 - 3ε)(-2 - ε) = -20 - 10ε + 6ε + 3ε² = -20 - 4ε + O(ε²)

L⁺ - L⁻ = 8ε + O(ε³). Divide por : 4. ✓

(Mi aritmética en la primera pasada fue descuidada. La derivación simbólica limpia da 4, coincidiendo con el recorrido del grafo y con la respuesta simbólica por regla de la cadena.)

Aplica la misma comprobación por diferencias finitas a ∂L/∂b y ∂L/∂c — mismo ejercicio. Deberías obtener -4 y -12.

Tres lecciones de este ejemplo trabajado

Lección 1: recorrido del grafo == regla de la cadena simbólica == diferencias finitas

Los tres dan la misma respuesta. Más les vale — son tres vistas de la misma identidad.

  • Regla de la cadena simbólica: las matemáticas, sobre papel.
  • Recorrido del grafo: el algoritmo, mecanizado.
  • Diferencias finitas: la comprobación empírica.

Si tu minigrad produce un gradiente que discrepa con las diferencias finitas, tu _backward está mal. Si produce un gradiente que discrepa con PyTorch, tu _backward está mal (PyTorch es el oráculo). Los tests de la Fase 7 usan PyTorch como oráculo primario y las diferencias finitas como respaldo de cordura (la Fase 8 usará gradcheck de forma más central).

Lección 2: la acumulación de diamante es el sentido completo de +=

a.grad recibió contribuciones de dos lugares: -6 vía ab y +10 vía f. Sumaron a 4. Si hubiéramos usado = en vez de += en _backward, solo la última contribución habría quedado — 4 (porque a._backward corrió al final en nuestro recorrido). Habría parecido correcto por suerte. Para c.grad, la última contribución fue -2, pero la respuesta real es -12. Con =, obtendríamos silenciosamente -2. Testéalo.

Lección 3: el orden topológico importa

El recorrido inverso debe visitar un nodo solo después de que todos sus hijos descendientes hayan ejecutado su _backward. De lo contrario, el .grad del nodo está parcial cuando su propio _backward lo lee, y los padres descendientes reciben la contribución equivocada.

En nuestro ejemplo, cuando se ejecuta ab._backward(), lee ab.grad. Ese .grad fue fijado por e._backward(), que corrió en el paso 3. Si de algún modo hubiéramos procesado ab antes de e, ab.grad aún sería 0 y a.grad += 3·0 = 0 sería la contribución — mal.

El paso de topo-sort en backward() garantiza esto. No lo escatimes.

Vista previa del lab

El Lab 02 te pedirá: 1. Computar L = (a·b + c)·(a - c) con estos valores exactos usando minigrad.scalar. 2. Llamar L.backward(). 3. Comprobar con assert a.grad == 4, b.grad == -4, c.grad == -12 (con tolerancia de coma flotante).

Si esas tres aserciones pasan, tu autograd funciona en el patrón de diamante. Ese es el test individual más importante en la fase.

Recapitulación en un párrafo

Trazar a mano L = (a·b + c)·(a - c) con el diamante de a y el diamante de c da ∂L/∂a = 4, ∂L/∂b = -4, ∂L/∂c = -12, verificable tanto por regla de la cadena simbólica como por recorrido del grafo con acumulación += en orden topológico inverso. Las diferencias finitas confirman los mismos números. Las dos acumulaciones de diamante — a.grad = 10 - 6 = 4 y c.grad = -10 - 2 = -12 — son exactamente lo que fallaría silenciosamente si _backward usara = en vez de +=. Este es el caso de test pedagógicamente más importante en la Fase 7.


Siguiente: 04-reverse-mode-vs-forward-mode.md