Diferenciación Automática: cómo una computadora calcula derivadas solas
Introducción
Cuando entrenamos un modelo de Machine Learning, necesitamos calcular derivadas. Todo el tiempo. En cada paso de entrenamiento, para cada parámetro del modelo.
Si alguna vez te preguntaste:
- ¿Cómo hace PyTorch para calcular automáticamente los gradientes?
- ¿Qué significa que un framework tenga “autograd”?
- ¿Qué es eso del “backward pass” y por qué existe?
Este post es para vos.
La implementación que vamos a ver está basada en micrograd, el motor de diferenciación automática que construye Andrej Karpathy desde cero en su video The spelled-out intro to neural networks and backpropagation: building micrograd. Muy recomendado verlo después de leer este post.
El problema: necesitamos derivadas de funciones muy complejas
En gradiente descendente, la regla de actualización de cualquier parámetro es:
Donde es la función de pérdida (loss). Si no estás familiarizado con el error cuadrático medio como función de pérdida, es un buen punto de partida. El problema es que no es una función simple. Es una composición de decenas, cientos o miles de operaciones anidadas.
Derivar eso a mano es imposible. Necesitamos que la computadora lo haga sola, de forma exacta y eficiente.
Las tres formas de calcular derivadas
Existen tres enfoques distintos:
1. Diferenciación simbólica
Es lo que hacés en papel o con una librería como sympy. Toma la expresión matemática y aplica las reglas de derivación para producir otra expresión.
from sympy import symbols, diff
x = symbols('x')
f = x**2 + 3*x
print(diff(f, x)) # 2*x + 3
Problema: Para funciones con miles de operaciones, la expresión resultante puede ser enormemente compleja y lenta de evaluar.
2. Diferenciación numérica
Usa la definición de derivada con una pequeña perturbación :
def derivada_numerica(f, x, h=1e-5):
return (f(x + h) - f(x)) / h
Problema: Introduce error de redondeo. Además, si tenemos parámetros, necesitamos hacer evaluaciones de la función, lo que es muy lento para modelos grandes.
3. Diferenciación automática
Combina lo mejor de los dos mundos: es exacta (como la simbólica) y eficiente (una sola pasada hacia atrás calcula todos los gradientes a la vez).
No deriva expresiones, sino que recorre un grafo de operaciones que fue construido durante la ejecución del programa.
| Método | Exacta | Eficiente | Escalable |
|---|---|---|---|
| Simbólica | ✅ | ❌ expresiones enormes | ❌ |
| Numérica | ❌ error de redondeo | ❌ n evaluaciones | ❌ |
| Automática | ✅ | ✅ | ✅ |
La idea central: el grafo computacional
El secreto de la diferenciación automática es que registra cada operación que se realiza sobre los datos, formando un grafo dirigido.
Pensalo así: cuando ejecutás c = a + b, no solo calculás el resultado, sino que también guardás:
- que
cdepende deay deb - que la operación fue una suma
- cómo calcular los gradientes de esa suma cuando llegue el momento
Nodos y aristas
El grafo tiene dos tipos de elementos:
- Nodos (variables): cada número intermedio del cálculo. Cada nodo guarda su valor (
data) y su gradiente acumulado (grad). - Aristas (dependencias): indican que un nodo es resultado de una operación sobre otros nodos.
Ejemplo: para la expresión L = (a + b) * c:
a ──┐
├──[+]── d ──┐
b ──┘ ├──[*]── L
c ───────────────┘
Cada nodo del grafo sabe de quién depende. Esa información es lo que permite calcular las derivadas hacia atrás.
El corazón: la regla de la cadena
La diferenciación automática se basa completamente en la regla de la cadena. Si querés ver cómo se aplica en detalle sobre el error cuadrático medio, lo desarrollamos paso a paso en este post.
Si depende de , y depende de , entonces:
En palabras: para saber cómo afecta a , basta con saber cómo afecta al nodo inmediato siguiente (), y cómo ese nodo afecta al final ().
Esto es lo que hace posible calcular todos los gradientes en una sola pasada hacia atrás: cada nodo solo necesita conocer el gradiente del nodo siguiente, no de toda la red.
Los dos pasos: forward y backward
Forward pass (hacia adelante)
Se ejecuta el cálculo normal, de izquierda a derecha. Se computan los valores y se construye el grafo.
a = 2, b = 3, c = 4
d = a + b = 5 → d._prev = {a, b}, d._op = '+'
L = d * c = 20 → L._prev = {d, c}, L._op = '*'
En este momento NO se calcula ningún gradiente. Solo se guarda la estructura del grafo y una función _backward en cada nodo que sabe cómo propagar el gradiente cuando llegue el momento.
Backward pass (hacia atrás)
Se recorre el grafo en orden inverso (de la salida hacia las entradas), aplicando la regla de la cadena en cada nodo.
Usamos el mismo ejemplo: a=2, b=3, c=4, con d = a+b = 5 y L = d*c = 20.
Paso 0 — Estado inicial: todos los gradientes en 0
a (data=2, grad=0) ──┐
├──[+]── d (data=5, grad=0) ──┐
b (data=3, grad=0) ──┘ ├──[*]── L (data=20, grad=0)
c (data=4, grad=0) ─────────────────────────────────┘
Paso 1 — Inicializar L.grad = 1.0
¿Por qué 1? Porque estamos preguntando “¿cuánto cambia L respecto de L misma?”. La respuesta siempre es 1: si L sube 1, L sube 1.
∂L/∂L = 1.0 → L.grad = 1.0
a (grad=0) ──┐
├──[+]── d (grad=0) ──┐
b (grad=0) ──┘ ├──[*]── L (grad=1.0) ← arrancamos acá
c (grad=0) ────────────────────────┘
Paso 2 — Propagar desde L hacia d y c (operación *)
L = d × c. Queremos saber cuánto afecta d a L y cuánto afecta c a L.
Derivadas de la multiplicación:
- Si
dsube 1 → L subec = 4→ por esod.grad = c * L.grad = 4 × 1 = 4 - Si
csube 1 → L subed = 5→ por esoc.grad = d * L.grad = 5 × 1 = 5
a (grad=0) ──┐
├──[+]── d (grad=4) ──┐
b (grad=0) ──┘ ├──[*]── L (grad=1.0)
c (grad=5) ────────────────────────┘
Paso 3 — Propagar desde d hacia a y b (operación +)
d = a + b. Ya sabemos que d.grad = 4 (calculado en el paso anterior).
- Si
asube 1 → d sube 1 → L sube 4 →a.grad = 1 × d.grad = 1 × 4 = 4 - Si
bsube 1 → d sube 1 → L sube 4 →b.grad = 1 × d.grad = 1 × 4 = 4
a (grad=4) ──┐
├──[+]── d (grad=4) ──┐
b (grad=4) ──┘ ├──[*]── L (grad=1.0)
c (grad=5) ────────────────────────┘
Resultado final: a.grad=4, b.grad=4, c.grad=5.
Esto significa: si aumento a en 1 → L aumenta en 4. Si aumento c en 1 → L aumenta en 5.
¿Por qué 1.0 en la suma?
Cuando ves en el código self.grad += 1.0 * out.grad, el 1.0 puede parecer arbitrario. No lo es.
Primero: la intuición
Imaginá que tenés d = a + b con a=2, b=3, entonces d=5.
Ahora aumentá a en 1: a=3, b=3 → d=6. d aumentó en 1.
Aumentá a en 2: a=4, b=3 → d=7. d aumentó en 2.
Cada vez que a sube 1, d sube exactamente 1. La tasa de cambio es 1. Eso es la derivada:
Segundo: aplicar la regla de la cadena
El gradiente que nos interesa no es ∂d/∂a sino ∂L/∂a (cómo afecta a al resultado final L).
Aquí entra la regla de la cadena: el cambio de a en L pasa a través de d:
En código, d.grad ya fue calculado en el paso anterior. Entonces:
def _backward(): # dentro de __add__
self.grad += 1.0 * out.grad # 1.0 = ∂(a+b)/∂a
other.grad += 1.0 * out.grad # 1.0 = ∂(a+b)/∂b
¿Y en la multiplicación?
Con out = a * b, la intuición cambia:
- Si aumentás
aen 1 conb=3→ out sube en 3. La tasa de cambio esb. - Si aumentás
ben 1 cona=2→ out sube en 2. La tasa de cambio esa.
Por eso en el código:
def _backward(): # dentro de __mul__
self.grad += other.data * out.grad # b · ∂L/∂out
other.grad += self.data * out.grad # a · ∂L/∂out
La regla general
En todos los casos la estructura es siempre la misma:
nodo.grad += (derivada local de la operación) × out.grad
No estás “derivando” en tiempo de ejecución. Ya sabés de antemano cuál es la derivada de cada operación básica (suma, producto, potencia…). Solo la aplicás multiplicada por el gradiente que llegó desde arriba.
Las derivadas de las operaciones más comunes
| Operación | Forward | Backward (∂L/∂self) |
|---|---|---|
out = a + b | a + b | self.grad += 1.0 * out.grad |
out = a * b | a * b | self.grad += b.data * out.grad |
out = a ** n | a^n | self.grad += n * a^(n-1) * out.grad |
out = sin(a) | sin(a) | self.grad += cos(a) * out.grad |
out = exp(a) | e^a | self.grad += e^a * out.grad |
out = log(a) | ln(a) | self.grad += (1/a) * out.grad |
En todos los casos la estructura es idéntica: self.grad += (derivada local) * out.grad.
Implementación mínima en Python
Esta implementación está basada en el código de micrograd de Andrej Karpathy. Es la versión más reducida posible para entender la idea central, sin nada extra:
import math
class Value:
def __init__(self, data, _children=(), _op='', label=''):
self.data = data # el valor numérico
self.grad = 0.0 # el gradiente acumulado (empieza en 0)
self._backward = lambda: None # función que propaga el gradiente
self._prev = set(_children) # nodos de los que depende
self._op = _op # operación que generó este nodo
self.label = label
def __repr__(self):
return f"Value(data={self.data})"
def __add__(self, other):
out = Value(self.data + other.data, (self, other), '+')
def _backward():
# Derivada de la suma: ∂(a+b)/∂a = 1, ∂(a+b)/∂b = 1
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward
return out
def __mul__(self, other):
out = Value(self.data * other.data, (self, other), '*')
def _backward():
# Derivada del producto: ∂(a*b)/∂a = b, ∂(a*b)/∂b = a
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def backward(self):
# 1. Ordenar el grafo topológicamente (de hojas a raíz)
topo, visited = [], set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
# 2. El gradiente de la salida respecto de sí misma es siempre 1
self.grad = 1.0
# 3. Recorrer en orden inverso y propagar
for node in reversed(topo):
node._backward()
Ejemplo paso a paso
# Crear las variables (hojas del grafo)
a = Value(2.0, label='a')
b = Value(3.0, label='b')
c = Value(4.0, label='c')
# FORWARD PASS: calcular y construir el grafo
d = a + b # d = 5
d.label = 'd'
L = d * c # L = 20
L.label = 'L'
# BACKWARD PASS: calcular todos los gradientes
L.backward()
print(f"∂L/∂a = {a.grad}") # 4.0 ← Si a sube 1, L sube 4
print(f"∂L/∂b = {b.grad}") # 4.0 ← Si b sube 1, L sube 4
print(f"∂L/∂c = {c.grad}") # 5.0 ← Si c sube 1, L sube 5
print(f"∂L/∂d = {d.grad}") # 4.0
Verificación manual:
Ejemplo 2: tres variables independientes (L = a * b + c)
Ahora agregamos una variable que entra directamente al resultado final, sin pasar por un nodo intermedio.
a = Value(3.0, label='a')
b = Value(4.0, label='b')
c = Value(2.0, label='c')
d = a * b; d.label = 'd' # d = 12
L = d + c; L.label = 'L' # L = 14
L.backward()
print(f"∂L/∂L = {L.grad}") # 1.0 (siempre)
print(f"∂L/∂d = {d.grad}") # 1.0 (L = d + c → ∂(d+c)/∂d = 1)
print(f"∂L/∂c = {c.grad}") # 1.0 (L = d + c → ∂(d+c)/∂c = 1)
print(f"∂L/∂a = {a.grad}") # 4.0 (∂L/∂d × ∂d/∂a = 1 × b = 1 × 4)
print(f"∂L/∂b = {b.grad}") # 3.0 (∂L/∂d × ∂d/∂b = 1 × a = 1 × 3)
Lo interesante es c: entra directamente a L sin pasar por ningún nodo intermedio. Su gradiente es 1 porque L = d + c y la derivada de una suma respecto de cualquiera de sus partes es 1.
a ──┐
├──[*]── d ──┐
b ──┘ ├──[+]── L
c ───────────────┘
Verificación manual:
Ejemplo 3: una variable en dos ramas (L = (a + b) * (a + c))
Este es el caso más instructivo: la variable a aparece en dos lugares distintos del grafo.
a = Value(2.0, label='a')
b = Value(1.0, label='b')
c = Value(3.0, label='c')
d = a + b; d.label = 'd' # d = 3
e = a + c; e.label = 'e' # e = 5
L = d * e; L.label = 'L' # L = 15
L.backward()
print(f"∂L/∂d = {d.grad}") # 5.0 (∂L/∂d = e = 5)
print(f"∂L/∂e = {e.grad}") # 3.0 (∂L/∂e = d = 3)
print(f"∂L/∂b = {b.grad}") # 5.0 (∂L/∂d × 1 = 5)
print(f"∂L/∂c = {c.grad}") # 3.0 (∂L/∂e × 1 = 3)
print(f"∂L/∂a = {a.grad}") # 8.0 ← suma de DOS caminos
El grafo tiene dos caminos que llegan a a:
a ──┐
├──[+]── d ──┐
b ──┘ ├──[*]── L
a ──┐ │
├──[+]── e ──┘
c ──┘
El gradiente de a se acumula desde ambas ramas:
rama d: ∂L/∂d × ∂d/∂a = 5 × 1 = 5
rama e: ∂L/∂e × ∂e/∂a = 3 × 1 = 3
─────────────────────────────────────
a.grad = 5 + 3 = 8
Verificación manual (expandiendo algebraicamente):
Este es exactamente el motivo por el que en _backward se usa += y no =: si usaras =, a.grad quedaría con el valor del último camino que se ejecute (3 o 5), perdiendo la contribución del otro.
El truco del += en los gradientes
Notás que en todos los _backward se usa += y no =. Esto es fundamental.
Una variable puede aparecer en múltiples lugares del grafo. Por ejemplo, en v4 = (x + y) * x, la variable x aparece dos veces.
Durante el backward, los gradientes que llegan por cada camino se acumulan:
x aparece en:
(1) la multiplicación: aporta grad_1
(2) la suma: aporta grad_2
x.grad = grad_1 + grad_2 ← por eso se usa +=
Si usaras = en lugar de +=, perderías las contribuciones de los caminos anteriores y el gradiente sería incorrecto.
El orden topológico: por qué es necesario
El backward debe procesar los nodos en un orden específico: primero los nodos que están más cerca del resultado final, luego los que están más cerca de las hojas.
Esto se garantiza con un ordenamiento topológico del grafo. Es el mismo concepto que en la teoría de grafos: si hay una arista de A a B, entonces A aparece antes que B en el orden.
Si el orden fuera incorrecto, un nodo podría propagarse antes de que su propio gradiente esté completo, generando resultados erróneos.
# El backward() construye este orden internamente:
# topo = [a, b, d, c, L] ← orden de hojas a raíz
# Luego lo recorre al revés:
# reversed(topo) = [L, c, d, b, a] ← de raíz a hojas
Resumen: los conceptos clave
| Concepto | Qué es |
|---|---|
| Grafo computacional | Registro de todas las operaciones y sus dependencias |
| Nodo hoja | Variable original, sin operación que la genere (ej: pesos del modelo) |
| Forward pass | Ejecutar el cálculo y construir el grafo |
| Backward pass | Recorrer el grafo al revés aplicando la regla de la cadena |
| grad | Gradiente acumulado: ∂L/∂nodo |
| _backward | Función guardada en cada nodo que sabe cómo propagar su gradiente |
| Orden topológico | Garantiza que cada nodo procesa su gradiente cuando ya está completo |
Lo que viene después
Con esta base ya es posible entender cómo se construyen motores de diferenciación automática completos, como el núcleo de PyTorch (autograd).
En el próximo post vamos a ver micrograd en detalle: la implementación completa de Andrej Karpathy que extiende estos conceptos para soportar más operaciones (tanh, exp, potencias) y llega a construir una red neuronal completa desde cero.
Si querés ir adelantando, te recomiendo:
- 📺 Video original: The spelled-out intro to neural networks and backpropagation: building micrograd — Andrej Karpathy
- 📦 Repositorio: github.com/karpathy/micrograd
- 📖 Para refrescar conceptos previos: Regresión Logística · Funciones de pérdida · Normalización de datos