Phase 01 - Lesson 05
Regla de la Cadena y Diferenciación Automática
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
La regla de la cadena es el motor detrás de toda red neuronal que aprende.
Tipo: Build Lenguaje: Python Requisitos previos: Fase 1, Lección 04 (Derivadas y Gradientes) Tiempo: ~90 minutos
Objetivos de Aprendizaje
- Construir un motor de autograd mínimo (clase Value) que registra operaciones y calcula gradientes mediante autodiff en modo reverso
- Implementar pasadas hacia adelante y hacia atrás por un grafo de cómputo usando ordenamiento topológico
- Construir y entrenar un perceptrón multicapa en XOR usando solo el motor de autograd hecho desde cero
- Verificar la correctitud del autodiff usando la verificación de gradiente contra diferencias finitas numéricas
El Problema
Sabes calcular derivadas de funciones simples. Pero una red neuronal no es una función simple. Es cientos de funciones compuestas: multiplicación de matrices, suma del sesgo, aplicación de la activación, otra multiplicación de matrices, softmax, pérdida de entropía cruzada. La salida es una función de una función de una función.
Para entrenar la red, necesitas el gradiente de la pérdida respecto a cada peso individual. Hacer esto a mano es imposible para millones de parámetros. Hacerlo numéricamente (diferencias finitas) es demasiado lento.
La regla de la cadena te da las matemáticas. La diferenciación automática te da el algoritmo. Juntas permiten calcular gradientes exactos a través de composiciones arbitrarias de funciones en un tiempo proporcional a una sola pasada hacia adelante.
Así es como funcionan PyTorch, TensorFlow y JAX. Vas a construir una versión en miniatura desde cero.
El Concepto
La Regla de la Cadena
Si y = f(g(x)), la derivada de y respecto a x es:
dy/dx = dy/dg * dg/dx = f'(g(x)) * g'(x)
Multiplica las derivadas a lo largo de la cadena. Cada eslabón aporta su derivada local.
Ejemplo: y = sin(x^2)
g(x) = x^2 g'(x) = 2x
f(g) = sin(g) f'(g) = cos(g)
dy/dx = cos(x^2) * 2x
Para composiciones más profundas, la cadena se extiende:
y = f(g(h(x)))
dy/dx = f'(g(h(x))) * g'(h(x)) * h'(x)
Cada capa de una red neuronal es un eslabón de esta cadena.
Grafos de Cómputo
Un grafo de cómputo hace visual la regla de la cadena. Cada operación se convierte en un nodo. Los datos fluyen hacia adelante por el grafo. Los gradientes fluyen hacia atrás.
Pasada hacia adelante (calcula valores):
graph TD
x1["x1 = 2"] --> mul["* (multiply)"]
x2["x2 = 3"] --> mul
mul -->|"a = 6"| add["+ (add)"]
b["b = 1"] --> add
add -->|"c = 7"| relu["relu"]
relu -->|"y = 7"| y["output y"]
Pasada hacia atrás (calcula gradientes):
graph TD
dy["dy/dy = 1"] -->|"relu'(c)=1 since c>0"| dc["dy/dc = 1"]
dc -->|"dc/da = 1"| da["dy/da = 1"]
dc -->|"dc/db = 1"| db["dy/db = 1"]
da -->|"da/dx1 = x2 = 3"| dx1["dy/dx1 = 3"]
da -->|"da/dx2 = x1 = 2"| dx2["dy/dx2 = 2"]
La pasada hacia atrás aplica la regla de la cadena en cada nodo, propagando gradientes desde la salida hacia las entradas.
Modo Directo vs Modo Reverso
Hay dos formas de aplicar la regla de la cadena a través de un grafo.
El modo directo comienza en las entradas y empuja las derivadas hacia adelante. Calcula dx/dx = 1 y propaga por cada operación. Bueno cuando tienes pocas entradas y muchas salidas.
Modo directo: siembra dx/dx = 1, propaga hacia adelante
x = 2 (dx/dx = 1)
a = x^2 (da/dx = 2x = 4)
y = sin(a) (dy/dx = cos(a) * da/dx = cos(4) * 4 = -2.615)
El modo reverso comienza en la salida y jala los gradientes hacia atrás. Calcula dy/dy = 1 y propaga por cada operación en orden inverso. Bueno cuando tienes muchas entradas y pocas salidas.
Modo reverso: siembra dy/dy = 1, propaga hacia atras
y = sin(a) (dy/dy = 1)
a = x^2 (dy/da = cos(a) = cos(4) = -0.654)
x = 2 (dy/dx = dy/da * da/dx = -0.654 * 4 = -2.615)
Las redes neuronales tienen millones de entradas (pesos) y una salida (pérdida). El modo reverso calcula todos los gradientes en una sola pasada hacia atrás. Por eso la retropropagación usa el modo reverso.
| Modo | Semilla | Dirección | Mejor cuando |
|---|---|---|---|
| Directo | dx_i/dx_i = 1 |
De entrada a salida | Pocas entradas, muchas salidas |
| Reverso | dy/dy = 1 |
De salida a entrada | Muchas entradas, pocas salidas (redes neuronales) |
Números Duales para el Modo Directo
El modo directo puede implementarse de forma elegante con números duales. Un número dual tiene la forma a + b*epsilon donde epsilon^2 = 0.
Numero dual: (valor, derivada)
(2, 1) significa: el valor es 2, la derivada respecto a x es 1
Reglas aritmeticas:
(a, a') + (b, b') = (a+b, a'+b')
(a, a') * (b, b') = (a*b, a'*b + a*b')
sin(a, a') = (sin(a), cos(a)*a')
Siembra la variable de entrada con derivada 1. La derivada se propaga automáticamente por cada operación.
Construyendo un Motor de Autograd
Un motor de autograd necesita tres cosas:
- Envoltura de valor. Envuelve cada número en un objeto que almacena su valor y gradiente.
- Registro del grafo. Cada operación registra sus entradas y la función de gradiente local.
- Pasada hacia atrás. Haz el ordenamiento topológico del grafo, luego recorrelo en orden inverso, aplicando la regla de la cadena en cada nodo.
Esto es exactamente lo que hace el autograd de PyTorch. La clase torch.Tensor envuelve valores, registra operaciones cuando requires_grad=True y calcula gradientes cuando llamas a .backward().
Como Funciona el Autograd de PyTorch Por Dentro
Cuando escribes código en PyTorch:
x = torch.tensor(2.0, requires_grad=True)
y = x ** 2 + 3 * x + 1
y.backward()
print(x.grad) # 7.0 = 2*x + 3 = 2*2 + 3
PyTorch internamente:
- Crea un nodo
Tensorparaxconrequires_grad=True - Cada operación (
**,*,+) crea un nuevo nodo y registra la función hacia atrás y.backward()dispara el autodiff en modo reverso a través del grafo registrado- El
grad_fnde cada nodo calcula gradientes locales y los pasa a los nodos padres - Los gradientes se acumulan en los atributos
.gradmediante suma (no reemplazo)
El grafo es dinámico (definido por la ejecución). Se construye un grafo nuevo en cada pasada hacia adelante. Por eso PyTorch admite flujo de control (if/else, bucles) dentro de los modelos.
Construye
Paso 1: La clase Value
class Value:
def __init__(self, data, children=(), op=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(children)
self._op = op
def __repr__(self):
return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"
Cada Value almacena su dato numérico, su gradiente (inicialmente cero), una función hacia atrás y punteros a los nodos hijos que lo produjeron.
Paso 2: Operaciones aritméticas con seguimiento de gradiente
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(max(0, self.data), (self,), 'relu')
def _backward():
self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
out._backward = _backward
return out
Cada operación crea una closure que sabe como calcular gradientes locales y multiplicar por el gradiente que viene de arriba (out.grad). El += maneja el caso en que un valor se usa en múltiples operaciones.
Paso 3: La pasada hacia atrás
def backward(self):
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)
self.grad = 1.0
for v in reversed(topo):
v._backward()
El ordenamiento topológico garantiza que el gradiente de cada nodo este completamente calculado antes de propagarse a sus hijos. El gradiente semilla es 1.0 (dy/dy = 1).
Paso 4: Más operaciones para un motor completo
La clase Value básica maneja suma, multiplicación y relu. Un motor de autograd real necesita más. Aquí están las operaciones que necesitas para construir redes neuronales:
def __neg__(self):
return self * -1
def __sub__(self, other):
return self + (-other)
def __radd__(self, other):
return self + other
def __rmul__(self, other):
return self * other
def __rsub__(self, other):
return other + (-self)
def __pow__(self, n):
out = Value(self.data ** n, (self,), f'**{n}')
def _backward():
self.grad += n * (self.data ** (n - 1)) * out.grad
out._backward = _backward
return out
def __truediv__(self, other):
return self * (other ** -1) if isinstance(other, Value) else self * (Value(other) ** -1)
def exp(self):
import math
e = math.exp(self.data)
out = Value(e, (self,), 'exp')
def _backward():
self.grad += e * out.grad
out._backward = _backward
return out
def log(self):
import math
out = Value(math.log(self.data), (self,), 'log')
def _backward():
self.grad += (1.0 / self.data) * out.grad
out._backward = _backward
return out
def tanh(self):
import math
t = math.tanh(self.data)
out = Value(t, (self,), 'tanh')
def _backward():
self.grad += (1 - t ** 2) * out.grad
out._backward = _backward
return out
Por que importa cada operación:
| Operación | Regla hacia atrás | Usada en |
|---|---|---|
__sub__ |
Reutiliza add + neg | Cálculo de la pérdida (pred - target) |
__pow__ |
n * x^(n-1) | Activaciones polinómicas, MSE (error^2) |
__truediv__ |
Reutiliza mul + pow(-1) | Normalización, escalado de la tasa de aprendizaje |
exp |
exp(x) * de arriba | Softmax, log-verosimilitud |
log |
(1/x) * de arriba | Pérdida de entropía cruzada, log-probabilidades |
tanh |
(1 - tanh^2) * de arriba | Función de activación clásica |
La parte ingeniosa: __sub__ y __truediv__ se definen en términos de operaciones existentes. Obtienen gradientes correctos gratis porque la regla de la cadena se compone a través de las operaciones subyacentes de add/mul/pow.
Paso 5: Mini MLP desde cero
Con una clase Value completa, puedes construir una red neuronal. Sin PyTorch. Sin NumPy. Solo Values y la regla de la cadena.
import random
class Neuron:
def __init__(self, n_inputs):
self.w = [Value(random.uniform(-1, 1)) for _ in range(n_inputs)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, n_inputs, n_outputs):
self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]
def __call__(self, x):
return [n(x) for n in self.neurons]
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, sizes):
self.layers = [Layer(sizes[i], sizes[i+1]) for i in range(len(sizes)-1)]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x[0] if len(x) == 1 else x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]
Una Neuron calcula tanh(w1*x1 + w2*x2 + ... + b). Una Layer es una lista de neuronas. Un MLP apila capas. Cada peso es un Value, así que llamar a loss.backward() propaga gradientes a cada parámetro.
Entrenamiento en XOR:
random.seed(42)
model = MLP([2, 4, 1]) # 2 inputs, 4 hidden neurons, 1 output
xs = [[0, 0], [0, 1], [1, 0], [1, 1]]
ys = [-1, 1, 1, -1] # XOR pattern (using -1/1 for tanh)
for step in range(100):
preds = [model(x) for x in xs]
loss = sum((p - y) ** 2 for p, y in zip(preds, ys))
for p in model.parameters():
p.grad = 0.0
loss.backward()
lr = 0.05
for p in model.parameters():
p.data -= lr * p.grad
if step % 20 == 0:
print(f"step {step:3d} loss = {loss.data:.4f}")
print("\nPredictions after training:")
for x, y in zip(xs, ys):
print(f" input={x} target={y:2d} pred={model(x).data:6.3f}")
Esto es micrograd. Un bucle completo de entrenamiento de red neuronal en Python puro con diferenciación automática. Todo framework comercial de deep learning hace lo mismo a escala masiva.
Paso 6: Verificación de gradiente
¿Cómo sabes que tu autodiff es correcto? Compáralo con derivadas numéricas. Esto es la verificación de gradiente.
def gradient_check(build_expr, x_val, h=1e-7):
x = Value(x_val)
y = build_expr(x)
y.backward()
autodiff_grad = x.grad
y_plus = build_expr(Value(x_val + h)).data
y_minus = build_expr(Value(x_val - h)).data
numerical_grad = (y_plus - y_minus) / (2 * h)
diff = abs(autodiff_grad - numerical_grad)
return autodiff_grad, numerical_grad, diff
Pruebalo en una expresión compleja:
def expr(x):
return (x ** 3 + x * 2 + 1).tanh()
ad, num, diff = gradient_check(expr, 0.5)
print(f"Autodiff: {ad:.8f}")
print(f"Numerical: {num:.8f}")
print(f"Difference: {diff:.2e}")
# Difference should be < 1e-5
La verificación de gradiente es esencial al implementar nuevas operaciones. Si tu pasada hacia atrás tiene un bug, la verificación numérica lo detecta. Toda implementación seria de deep learning ejecuta verificaciones de gradiente durante el desarrollo.
Cuando usar la verificación de gradiente:
| Situación | Hacer verificación de gradiente? |
|---|---|
| Agregar una nueva operación a tu autograd | Si, siempre |
| Depurar un bucle de entrenamiento que no converge | Si, verifica los gradientes primero |
| Entrenamiento en producción | No, demasiado lento (2x pasadas hacia adelante por parámetro) |
| Pruebas unitarias para código de autograd | Si, automatizalo |
Paso 7: Verificar contra el cálculo manual
x1 = Value(2.0)
x2 = Value(3.0)
a = x1 * x2 # a = 6.0
b = a + Value(1.0) # b = 7.0
y = b.relu() # y = 7.0
y.backward()
print(f"y = {y.data}") # 7.0
print(f"dy/dx1 = {x1.grad}") # 3.0 (= x2)
print(f"dy/dx2 = {x2.grad}") # 2.0 (= x1)
Verificación manual: y = relu(x1*x2 + 1). Como x1*x2 + 1 = 7 > 0, relu es la identidad.
dy/dx1 = x2 = 3. dy/dx2 = x1 = 2. El motor coincide.
Usa
Verificar contra PyTorch
import torch
x1 = torch.tensor(2.0, requires_grad=True)
x2 = torch.tensor(3.0, requires_grad=True)
a = x1 * x2
b = a + 1.0
y = torch.relu(b)
y.backward()
print(f"PyTorch dy/dx1 = {x1.grad.item()}") # 3.0
print(f"PyTorch dy/dx2 = {x2.grad.item()}") # 2.0
Los mismos gradientes. Tu motor calcula el mismo resultado que PyTorch porque las matemáticas son las mismas: autodiff en modo reverso mediante la regla de la cadena.
Una expresión más compleja
a = Value(2.0)
b = Value(-3.0)
c = Value(10.0)
f = (a * b + c).relu() # relu(2*(-3) + 10) = relu(4) = 4
f.backward()
print(f"df/da = {a.grad}") # -3.0 (= b)
print(f"df/db = {b.grad}") # 2.0 (= a)
print(f"df/dc = {c.grad}") # 1.0
Entrega
Esta lección produce:
outputs/skill-autodiff.md-- una skill para construir y depurar sistemas de autogradcode/autodiff.py-- un motor de autograd mínimo que puedes extender
La clase Value construida aquí es la base para el bucle de entrenamiento de red neuronal en la Fase 3.
Ejercicios
Agrega
__pow__a la clase Value para que puedas calcularx ** n. Verifica qued/dx(x^3)enx=2sea igual a12.0.Agrega
tanhcomo función de activación. Verifica quetanh'(0) = 1ytanh'(2) = 0.0707(aprox).Construye un grafo de cómputo para una sola neurona:
y = relu(w1*x1 + w2*x2 + b). Calcula los cinco gradientes y verifica contra PyTorch.Implementa el autodiff en modo directo usando números duales. Crea una clase
Dualy verifica que da las mismas derivadas que tu motor en modo reverso.
Términos Clave
| Término | Lo que dice la gente | Lo que realmente significa |
|---|---|---|
| Regla de la cadena | "Multiplica las derivadas" | La derivada de funciones compuestas es igual al producto de la derivada local de cada función, evaluada en el punto correcto |
| Grafo de cómputo | "El diagrama de la red" | Un grafo acíclico dirigido donde los nodos son operaciones y las aristas llevan valores (hacia adelante) o gradientes (hacia atrás) |
| Modo directo | "Empuja derivadas hacia adelante" | Autodiff que propaga derivadas desde las entradas hacia las salidas. Una pasada por variable de entrada. |
| Modo reverso | "Retropropagación" | Autodiff que propaga gradientes desde las salidas hacia las entradas. Una pasada por variable de salida. |
| Autograd | "Gradientes automáticos" | Un sistema que registra operaciones sobre valores, construye un grafo y calcula gradientes exactos mediante la regla de la cadena |
| Números duales | "Valor más derivada" | Números de la forma a + b*epsilon (epsilon^2 = 0) que llevan información de derivada por la aritmética |
| Ordenamiento topológico | "Orden de dependencia" | Ordenar los nodos del grafo de modo que cada nodo venga después de todas sus dependencias. Necesario para la propagación correcta de gradientes. |
| Acumulación de gradiente | "Suma, no reemplaces" | Cuando un valor alimenta múltiples operaciones, su gradiente es la suma de todas las contribuciones de gradiente recibidas |
| Grafo dinámico | "Definido por la ejecución" | Un grafo de cómputo reconstruido en cada pasada hacia adelante, que permite flujo de control de Python dentro de los modelos (estilo PyTorch) |
| Verificación de gradiente | "Verificación numérica" | Comparar gradientes del autodiff con gradientes numéricos por diferencias finitas para verificar la correctitud. Esencial para la depuración. |
| MLP | "Perceptrón multicapa" | Una red neuronal con una o más capas ocultas de neuronas. Cada neurona calcula una suma ponderada más sesgo, luego aplica una función de activación. |
| Neurona | "Suma ponderada + activación" | La unidad básica: salida = activación(w1x1 + w2x2 + ... + b). Los pesos y el sesgo son parámetros aprendibles. |
Lectura Adicional
- 3Blue1Brown: Backpropagation calculus -- explicación visual de la regla de la cadena en redes neuronales
- PyTorch Autograd mechanics -- como funciona el sistema real
- Baydin et al., Automatic Differentiation in Machine Learning: a Survey -- referencia exhaustiva