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:

  1. Envoltura de valor. Envuelve cada número en un objeto que almacena su valor y gradiente.
  2. Registro del grafo. Cada operación registra sus entradas y la función de gradiente local.
  3. 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:

  1. Crea un nodo Tensor para x con requires_grad=True
  2. Cada operación (**, *, +) crea un nuevo nodo y registra la función hacia atrás
  3. y.backward() dispara el autodiff en modo reverso a través del grafo registrado
  4. El grad_fn de cada nodo calcula gradientes locales y los pasa a los nodos padres
  5. Los gradientes se acumulan en los atributos .grad mediante 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 autograd
  • code/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

  1. Agrega __pow__ a la clase Value para que puedas calcular x ** n. Verifica que d/dx(x^3) en x=2 sea igual a 12.0.

  2. Agrega tanh como función de activación. Verifica que tanh'(0) = 1 y tanh'(2) = 0.0707 (aprox).

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

  4. Implementa el autodiff en modo directo usando números duales. Crea una clase Dual y 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

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).