Phase 03 - Lesson 03
Backpropagation desde cero
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Backpropagation es el algoritmo que hace posible el aprendizaje. Sin él, las redes neuronales son apenas generadores de números aleatorios costosos.
Tipo: Build Lenguajes: Python Prerrequisitos: Lección 03.02 (Redes Multicapa) Tiempo: ~120 minutos
Objetivos de aprendizaje
- Implementar un motor de autograd basado en Value que construye un grafo computacional y calcula gradientes mediante orden topológico
- Derivar el paso backward para la suma, la multiplicación y la sigmoide usando la regla de la cadena
- Entrenar una red multicapa en XOR y clasificación de círculo usando únicamente tu motor de backpropagation hecho desde cero
- Identificar el problema del gradiente que se desvanece en redes sigmoides profundas y explicar por qué los gradientes se encogen exponencialmente
El Problema
Tu red tiene una única capa oculta con 768 entradas y 3072 salidas. Eso son 2.359.296 pesos. Hizo una predicción incorrecta. ¿Qué pesos causaron el error? Probar cada peso individualmente significa 2,3 millones de pasos forward. Backpropagation calcula los 2,3 millones de gradientes en un solo paso backward. Eso no es una optimización. Es la diferencia entre entrenable e imposible.
El enfoque ingenuo: toma un peso, ajústalo en una cantidad mínima, ejecuta el paso forward de nuevo, mide si la pérdida subió o bajó. Eso te da el gradiente para ese peso. Ahora hazlo para cada peso en la red. Multiplica por miles de pasos de entrenamiento y millones de puntos de datos. Necesitarías tiempo geológico para entrenar cualquier cosa útil.
Backpropagation resuelve esto. Un paso forward, un paso backward, todos los gradientes calculados. El truco es la regla de la cadena del cálculo, aplicada sistemáticamente a un grafo computacional. Este es el algoritmo que hizo práctico el deep learning. Sin él, todavía estaríamos atascados en problemas de juguete.
El Concepto
La Regla de la Cadena, Aplicada a Redes
Viste la regla de la cadena en la Fase 01, Lección 05. Repaso rápido: si y = f(g(x)), entonces dy/dx = f'(g(x)) * g'(x). Multiplicas derivadas a lo largo de la cadena.
En una red neuronal, la "cadena" es la secuencia de operaciones desde la entrada hasta la pérdida. Cada capa aplica pesos, suma sesgos, pasa por una activación. La función de pérdida compara la salida final con el objetivo. Backpropagation recorre esta cadena hacia atrás, calculando cómo cada operación contribuyó al error.
Grafos Computacionales
Cada paso forward construye un grafo. Cada nodo es una operación (multiplicar, sumar, sigmoide). Cada arista lleva un valor hacia adelante y un gradiente hacia atrás.
graph LR
x["x"] --> mul["*"]
w["w"] --> mul
mul -- "z1 = w*x" --> add["+"]
b["b"] --> add
add -- "z2 = z1 + b" --> sig["sigmoid"]
sig -- "a = sigmoid(z2)" --> loss["Pérdida"]
y["objetivo"] --> loss
Paso forward: los valores fluyen de izquierda a derecha. x y w producen z1 = w*x. Suma b para obtener z2. La sigmoide da la activación a. Compara a con el objetivo y usando la función de pérdida.
Paso backward: los gradientes fluyen de derecha a izquierda. Comienza con dL/da (cómo cambia la pérdida con la activación). Multiplica por da/dz2 (derivada de la sigmoide). Eso da dL/dz2. Divide en dL/db (que es igual a dL/dz2, ya que z2 = z1 + b) y dL/dz1. Luego dL/dw = dL/dz1 * x y dL/dx = dL/dz1 * w.
Cada nodo en el grafo tiene un trabajo durante el paso backward: tomar el gradiente que viene desde arriba, multiplicar por su derivada local y pasarlo hacia abajo.
Forward vs Backward
graph TB
subgraph Forward["Paso Forward"]
direction LR
f1["Entrada x"] --> f2["z = Wx + b"]
f2 --> f3["a = sigmoid(z)"]
f3 --> f4["Pérdida = (a - y)^2"]
end
subgraph Backward["Paso Backward"]
direction RL
b4["dL/dL = 1"] --> b3["dL/da = 2(a-y)"]
b3 --> b2["dL/dz = dL/da * a(1-a)"]
b2 --> b1["dL/dW = dL/dz * x\ndL/db = dL/dz"]
end
Forward --> Backward
El paso forward almacena cada valor intermedio: z, a, las entradas de cada capa. El paso backward necesita esos valores almacenados para calcular gradientes. Este es el compromiso memoria-computación en el corazón del backprop. Intercambias memoria (almacenar activaciones) por velocidad (un paso en lugar de millones).
Flujo de Gradiente a Través de una Red
Para una red de 3 capas, los gradientes se encadenan a través de cada capa:
graph RL
L["Pérdida"] -- "dL/da3" --> L3["Capa 3\na3 = sigmoid(z3)"]
L3 -- "dL/dz3 = dL/da3 * sigmoid'(z3)" --> L2["Capa 2\na2 = sigmoid(z2)"]
L2 -- "dL/dz2 = dL/da2 * sigmoid'(z2)" --> L1["Capa 1\na1 = sigmoid(z1)"]
L1 -- "dL/dz1 = dL/da1 * sigmoid'(z1)" --> I["Entrada"]
En cada capa, el gradiente se multiplica por la derivada de la sigmoide. La derivada de la sigmoide es a * (1 - a), que alcanza como máximo 0,25 (cuando a = 0,5). Tres capas de profundidad, el gradiente se ha multiplicado por como máximo 0,25^3 = 0,0156. Diez capas de profundidad: 0,25^10 = 0,000001.
Gradientes que se Desvanecen
Este es el problema del gradiente que se desvanece. La sigmoide comprime su salida entre 0 y 1. Su derivada es siempre menor que 0,25. Apila suficientes sigmoides y los gradientes se encogen hasta nada. Las capas iniciales casi no aprenden porque reciben gradientes cercanos a cero.
sigmoid(z): Rango de salida [0, 1]
sigmoid'(z): Valor máximo 0.25 (en z = 0)
Tras 5 capas: gradiente * 0.25^5 = 0.001x del original
Tras 10 capas: gradiente * 0.25^10 = 0.000001x del original
Por eso las redes sigmoides profundas son casi imposibles de entrenar. La solución -- ReLU y sus variantes -- es el tema de la Lección 04. Por ahora, entiende que el backprop funciona perfectamente. El problema es aquello a través de lo cual está trabajando.
Derivando Gradientes para una Red de 2 Capas
Matemática concreta para una red con entrada x, capa oculta con sigmoide, capa de salida con sigmoide y pérdida MSE.
Paso forward:
z1 = W1 * x + b1
a1 = sigmoid(z1)
z2 = W2 * a1 + b2
a2 = sigmoid(z2)
L = (a2 - y)^2
Paso backward (aplicando la regla de la cadena paso a paso):
dL/da2 = 2(a2 - y)
da2/dz2 = a2 * (1 - a2)
dL/dz2 = dL/da2 * da2/dz2 = 2(a2 - y) * a2 * (1 - a2)
dL/dW2 = dL/dz2 * a1
dL/db2 = dL/dz2
dL/da1 = dL/dz2 * W2
da1/dz1 = a1 * (1 - a1)
dL/dz1 = dL/da1 * da1/dz1
dL/dW1 = dL/dz1 * x
dL/db1 = dL/dz1
Cada gradiente es un producto de derivadas locales rastreadas hacia atrás desde la pérdida. Eso es todo lo que es backpropagation.
Constrúyelo
Paso 1: El Nodo Value
Cada número en nuestra computación se convierte en un Value. Almacena sus datos, su gradiente y cómo fue creado (para que sepa cómo calcular gradientes hacia atrás).
class Value:
def __init__(self, data, children=(), op=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._children = set(children)
self._op = op
def __repr__(self):
return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"
Todavía sin gradiente (0.0). Todavía sin función backward (no-op). Los _children rastrean qué Values produjeron este, para que podamos ordenar topológicamente el grafo después.
Paso 2: Operaciones con Funciones Backward
Cada operación crea un nuevo Value y define cómo fluyen los gradientes hacia atrás a través de ella.
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
Para la suma: d(a+b)/da = 1, d(a+b)/db = 1. Así que ambas entradas reciben el gradiente de la salida directamente.
Para la multiplicación: d(ab)/da = b, d(ab)/db = a. Cada entrada recibe el valor de la otra por el gradiente de la salida.
El += es crítico. Un Value puede usarse en múltiples operaciones. Su gradiente es la suma de los gradientes de todos los caminos.
Paso 3: Sigmoide y Pérdida
import math
def sigmoid(self):
x = self.data
x = max(-500, min(500, x))
s = 1.0 / (1.0 + math.exp(-x))
out = Value(s, (self,), 'sigmoid')
def _backward():
self.grad += (s * (1 - s)) * out.grad
out._backward = _backward
return out
Derivada de la sigmoide: sigmoid(x) * (1 - sigmoid(x)). Calculamos sigmoid(x) = s durante el paso forward. Reutilízalo. Sin trabajo extra.
def mse_loss(predicted, target):
diff = predicted + Value(-target)
return diff * diff
MSE para una única salida: (predicted - target)^2. Expresamos la resta como suma con un Value negado.
Paso 4: Paso Backward
El orden topológico garantiza que procesamos los nodos en el orden correcto -- el gradiente de un nodo se acumula por completo antes de propagar a través de él.
def backward(self):
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._children:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1.0
for v in reversed(topo):
v._backward()
Comienza en la pérdida (gradiente = 1.0, ya que dL/dL = 1). Camina hacia atrás a través del grafo ordenado. El _backward de cada nodo empuja gradientes hacia sus hijos.
Paso 5: Capa y Red
import random
class Neuron:
def __init__(self, n_inputs):
scale = (2.0 / n_inputs) ** 0.5
self.weights = [Value(random.uniform(-scale, scale)) for _ in range(n_inputs)]
self.bias = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.weights, x)), self.bias)
return act.sigmoid()
def parameters(self):
return self.weights + [self.bias]
class Layer:
def __init__(self, n_inputs, n_outputs):
self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
def parameters(self):
params = []
for n in self.neurons:
params.extend(n.parameters())
return params
class Network:
def __init__(self, sizes):
self.layers = []
for i in range(len(sizes) - 1):
self.layers.append(Layer(sizes[i], sizes[i + 1]))
def __call__(self, x):
for layer in self.layers:
x = layer(x)
if not isinstance(x, list):
x = [x]
return x[0] if len(x) == 1 else x
def parameters(self):
params = []
for layer in self.layers:
params.extend(layer.parameters())
return params
def zero_grad(self):
for p in self.parameters():
p.grad = 0.0
Un Neuron recibe entradas, calcula la suma ponderada + sesgo y aplica la sigmoide. La inicialización de pesos escala por sqrt(2/n_inputs) para evitar la saturación de la sigmoide en redes más profundas. Una Layer es una lista de Neurons. Una Network es una lista de Layers. El método parameters() recolecta todos los Values aprendibles para que podamos actualizarlos.
Paso 6: Entrenar en XOR
random.seed(42)
net = Network([2, 4, 1])
xor_data = [
([0.0, 0.0], 0.0),
([0.0, 1.0], 1.0),
([1.0, 0.0], 1.0),
([1.0, 1.0], 0.0),
]
learning_rate = 1.0
for epoch in range(1000):
total_loss = Value(0.0)
for inputs, target in xor_data:
x = [Value(i) for i in inputs]
pred = net(x)
loss = mse_loss(pred, target)
total_loss = total_loss + loss
net.zero_grad()
total_loss.backward()
for p in net.parameters():
p.data -= learning_rate * p.grad
if epoch % 100 == 0:
print(f"Epoch {epoch:4d} | Loss: {total_loss.data:.6f}")
print("\nXOR Results:")
for inputs, target in xor_data:
x = [Value(i) for i in inputs]
pred = net(x)
print(f" {inputs} -> {pred.data:.4f} (expected {target})")
Observa la pérdida disminuir. De predicciones aleatorias a salidas XOR correctas, impulsado enteramente por backpropagation calculando gradientes y ajustando pesos en la dirección correcta.
Paso 7: Clasificación de Círculo
En la Lección 02, ajustaste pesos manualmente para clasificación de círculo. Ahora deja que la red los aprenda.
random.seed(7)
def generate_circle_data(n=100):
data = []
for _ in range(n):
x1 = random.uniform(-1.5, 1.5)
x2 = random.uniform(-1.5, 1.5)
label = 1.0 if x1 * x1 + x2 * x2 < 1.0 else 0.0
data.append(([x1, x2], label))
return data
circle_data = generate_circle_data(80)
circle_net = Network([2, 8, 1])
learning_rate = 0.5
for epoch in range(2000):
random.shuffle(circle_data)
total_loss_val = 0.0
for inputs, target in circle_data:
x = [Value(i) for i in inputs]
pred = circle_net(x)
loss = mse_loss(pred, target)
circle_net.zero_grad()
loss.backward()
for p in circle_net.parameters():
p.data -= learning_rate * p.grad
total_loss_val += loss.data
if epoch % 200 == 0:
correct = 0
for inputs, target in circle_data:
x = [Value(i) for i in inputs]
pred = circle_net(x)
predicted_class = 1.0 if pred.data > 0.5 else 0.0
if predicted_class == target:
correct += 1
accuracy = correct / len(circle_data) * 100
print(f"Epoch {epoch:4d} | Loss: {total_loss_val:.4f} | Accuracy: {accuracy:.1f}%")
Usamos SGD online aquí -- actualizamos los pesos después de cada muestra en lugar de acumular el batch completo. Esto rompe la simetría más rápido y evita la saturación de la sigmoide en el panorama de pérdida completo. Mezclar los datos en cada época impide que la red memorice el orden.
Sin ajuste manual. La red descubre la frontera de decisión circular por su cuenta. Ese es el poder de backpropagation: tú defines la arquitectura, la función de pérdida y los datos. El algoritmo descubre los pesos.
Úsalo
PyTorch hace todo lo anterior en unas pocas líneas. La idea central es idéntica -- autograd construye un grafo computacional durante el paso forward y lo recorre hacia atrás para calcular gradientes.
import torch
import torch.nn as nn
model = nn.Sequential(
nn.Linear(2, 4),
nn.Sigmoid(),
nn.Linear(4, 1),
nn.Sigmoid(),
)
optimizer = torch.optim.SGD(model.parameters(), lr=1.0)
criterion = nn.MSELoss()
X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32)
y = torch.tensor([[0],[1],[1],[0]], dtype=torch.float32)
for epoch in range(1000):
pred = model(X)
loss = criterion(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print("PyTorch XOR Results:")
with torch.no_grad():
for i in range(4):
pred = model(X[i])
print(f" {X[i].tolist()} -> {pred.item():.4f} (expected {y[i].item()})")
loss.backward() es tu total_loss.backward(). optimizer.step() es tu p.data -= lr * p.grad manual. optimizer.zero_grad() es tu net.zero_grad(). Mismo algoritmo, implementación de fuerza industrial. PyTorch maneja la aceleración por GPU, la precisión mixta, el gradient checkpointing y cientos de tipos de capas. Pero el paso backward es la misma regla de la cadena aplicada al mismo grafo computacional.
El entrenamiento ejecuta el paso forward, luego el paso backward, luego actualiza los pesos. La inferencia ejecuta únicamente el paso forward. Sin gradientes, sin actualizaciones. Esta distinción importa porque la inferencia es lo que ocurre en producción. Cuando llamas a una API como Claude o GPT, estás ejecutando inferencia -- tu prompt fluye hacia adelante a través de la red, y los tokens salen por el otro lado. Ningún peso cambia. Entender backprop importa porque moldeó cada peso en esa red.
Entrégalo
Esta lección produce:
outputs/prompt-gradient-debugger.md-- un prompt reutilizable para diagnosticar problemas de gradiente (que se desvanece, que explota, NaN) en cualquier red neuronal
Ejercicios
Agrega un método
__sub__a la clase Value (a - b = a + (-1 * b)). Luego implementa un método__neg__. Verifica que los gradientes sean correctos comparando con el cálculo manual para una expresión simple como (a - b)^2.Agrega un método
relual Value (salida max(0, x), la derivada es 1 si x > 0, si no 0). Reemplaza la sigmoide por relu en las capas ocultas y entrena en XOR de nuevo. Compara la velocidad de convergencia. Deberías ver un entrenamiento más rápido -- esto anticipa la Lección 04.Implementa un método
__pow__en Value para potencias enteras. Úsalo para reemplazarmse_losspor una expresión(predicted - target) ** 2apropiada. Verifica que los gradientes coincidan con la implementación original.Agrega clipping de gradiente al loop de entrenamiento: después de llamar a
backward(), recorta todos los gradientes a [-1, 1]. Entrena una red más profunda (4+ capas con sigmoide) y compara las curvas de pérdida con y sin clipping. Esta es tu primera defensa contra los gradientes que explotan.Construye una visualización: después de entrenar en XOR, imprime el gradiente de cada parámetro en la red. Identifica qué capa tiene los gradientes más pequeños. Esto demuestra el problema del gradiente que se desvanece sobre el que leíste en la sección del Concepto.
Términos clave
| Término | Lo que la gente dice | Lo que realmente significa |
|---|---|---|
| Backpropagation | "La red aprende" | Un algoritmo que calcula dL/dw para cada peso aplicando la regla de la cadena hacia atrás a través del grafo computacional |
| Grafo computacional | "La estructura de la red" | Un grafo acíclico dirigido donde los nodos son operaciones y las aristas llevan valores (forward) y gradientes (backward) |
| Regla de la cadena | "Multiplica las derivadas" | Si y = f(g(x)), entonces dy/dx = f'(g(x)) * g'(x) -- el fundamento matemático de backpropagation |
| Gradiente | "La dirección de ascenso más pronunciada" | La derivada parcial de la pérdida con respecto a un parámetro -- te dice cómo cambiar ese parámetro para reducir la pérdida |
| Gradiente que se desvanece | "Las redes profundas no aprenden" | Los gradientes se encogen exponencialmente conforme se propagan a través de capas con activaciones saturantes como la sigmoide |
| Paso forward | "Ejecutar la red" | Calcular la salida a partir de las entradas aplicando secuencialmente las operaciones de cada capa y almacenando valores intermedios |
| Paso backward | "Calcular gradientes" | Recorrer el grafo computacional en reverso, acumulando gradientes en cada nodo usando la regla de la cadena |
| Tasa de aprendizaje | "Qué tan rápido aprende" | Un escalar que controla el tamaño del paso al actualizar pesos: w_new = w_old - lr * gradient |
| Orden topológico | "El orden correcto" | Un ordenamiento de nodos del grafo donde cada nodo aparece después de todos los nodos de los que depende -- garantiza que los gradientes se acumulen por completo antes de la propagación |
| Autograd | "Diferenciación automática" | Un sistema que construye grafos computacionales durante la computación forward y calcula gradientes automáticamente -- lo que hace el motor de PyTorch |
Lectura Adicional
- Rumelhart, Hinton & Williams, "Learning representations by back-propagating errors" (1986) -- el artículo que llevó backpropagation al mainstream y desbloqueó el entrenamiento de redes multicapa
- 3Blue1Brown, serie "Neural Networks" (https://www.youtube.com/playlist?list=PLZHQObOWTQDNU6R1_67000Dx_ZCJB-3pi) -- la mejor explicación visual de backpropagation y flujo de gradiente a través de redes