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

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

  2. Agrega un método relu al 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.

  3. Implementa un método __pow__ en Value para potencias enteras. Úsalo para reemplazar mse_loss por una expresión (predicted - target) ** 2 apropiada. Verifica que los gradientes coincidan con la implementación original.

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

  5. 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
0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).