Backpropagation: cómo aprende una red

Tema 17 · Fase 4, Redes neuronales · Toda la ruta

Los 15 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; en la ficha Teoría está la explicación de cada uno.

El grafo computacional

# Las entradas
a, b, c = 2.0, -3.0, 10.0

# Hacia adelante: un nodo a la vez, de izquierda a derecha
d = a * b
e = d + c
L = e ** 2
print(f"Adelante:  d = {d}   e = {e}   L = {L}")

Hacia atrás, caja por caja

# Hacia atrás: de derecha a izquierda.
# En cada nodo: gradiente que llega × gradiente local
dL_dL = 1.0
dL_de = dL_dL * 2 * e      # caja e²: local 2e
dL_dd = dL_de * 1          # caja d + c: local 1 (copia)
dL_dc = dL_de * 1
dL_da = dL_dd * b          # caja a·b: local respecto de a es b
dL_db = dL_dd * a          # y respecto de b es a
print(f"dL/de = {dL_de}   dL/dd = {dL_dd}   dL/dc = {dL_dc}")
print(f"dL/da = {dL_da}   dL/db = {dL_db}")
def calcular_L(a, b, c):
    return (a * b + c) ** 2

h = 1e-6
dL_da_num = (calcular_L(a + h, b, c) - calcular_L(a - h, b, c)) / (2 * h)
dL_db_num = (calcular_L(a, b + h, c) - calcular_L(a, b - h, c)) / (2 * h)
dL_dc_num = (calcular_L(a, b, c + h) - calcular_L(a, b, c - h)) / (2 * h)
print("Numéricas:", round(dL_da_num, 4), round(dL_db_num, 4),
      round(dL_dc_num, 4))
import numpy as np
import matplotlib.pyplot as plt

# Cada nodo: posición (x, y), rótulo, valor y gradiente
nodos = {
    "a": (0.5, 2.75, "a", a, dL_da),
    "b": (0.5, 1.6, "b", b, dL_db),
    "c": (0.5, 0.45, "c", c, dL_dc),
    "d": (1.77, 2.2, "d = a·b", d, dL_dd),
    "e": (3.03, 1.3, "e = d + c", e, dL_de),
    "L": (4.3, 1.3, "L = e²", L, dL_dL),
}
# Las flechas: de dónde sale cada número
aristas = [("a", "d"), ("b", "d"), ("d", "e"), ("c", "e"), ("e", "L")]

fig, eje = plt.subplots()
for origen, destino in aristas:
    x0, y0 = nodos[origen][:2]
    x1, y1 = nodos[destino][:2]
    # La flecha llega un poco arriba o abajo, según de dónde viene
    llegada = y1 + 0.18 * np.sign(y0 - y1)
    eje.annotate("", xy=(x1 - 0.48, llegada), xytext=(x0 + 0.48, y0),
                 arrowprops=dict(arrowstyle="-|>", color="C2", lw=1.5))
for nombre, (x, y, rotulo, valor, grad) in nodos.items():
    # Solo la salida L va resaltada
    borde = "C1" if nombre == "L" else "C0"
    eje.add_patch(plt.Rectangle((x - 0.48, y - 0.45), 0.96, 0.9,
                                facecolor="white", edgecolor=borde,
                                linewidth=2.5 if nombre == "L" else 1.5))
    eje.text(x, y + 0.22, rotulo, ha="center", va="center", fontsize=13,
             color="C2")
    eje.text(x, y, f"{valor:g}", ha="center", va="center",
             fontsize=15, fontweight="semibold")
    eje.text(x, y - 0.24, f"grad {grad:g}", ha="center", va="center",
             fontsize=13, color="C2")
eje.set_xlim(0, 4.8)
eje.set_ylim(0, 3.3)
eje.set_aspect("equal")
eje.axis("off")
eje.set_title("L = (a·b + c)²: valores y gradientes")
plt.show()

Un motor de autograd: la clase `Valor`

import math

class Valor:
    """Un número que recuerda de dónde salió y guarda su gradiente."""

    def __init__(self, dato, hijos=(), op=""):
        self.dato = dato              # el número (hacia adelante)
        self.grad = 0.0               # dSalida/dEste (hacia atrás)
        self._hijos = hijos           # los Valor de los que salió
        self._op = op                 # la operación que lo creó
        self._atras = lambda: None    # pasa el gradiente a los hijos

    def __repr__(self):
        return f"Valor(dato={self.dato:.4g}, grad={self.grad:.4g})"

    def __add__(self, otro):
        otro = otro if isinstance(otro, Valor) else Valor(otro)
        salida = Valor(self.dato + otro.dato, (self, otro), "+")
        def _atras():
            # Suma: el gradiente que llega se copia a las dos entradas
            self.grad += salida.grad
            otro.grad += salida.grad
        salida._atras = _atras
        return salida

    def __mul__(self, otro):
        otro = otro if isinstance(otro, Valor) else Valor(otro)
        salida = Valor(self.dato * otro.dato, (self, otro), "*")
        def _atras():
            # Multiplicación: cada entrada recibe la otra entrada
            self.grad += otro.dato * salida.grad
            otro.grad += self.dato * salida.grad
        salida._atras = _atras
        return salida

    def __pow__(self, k):
        # k es un número fijo (2, 3, -1...), no un Valor
        salida = Valor(self.dato ** k, (self,), f"**{k}")
        def _atras():
            self.grad += k * self.dato ** (k - 1) * salida.grad
        salida._atras = _atras
        return salida

    def tanh(self):
        t = math.tanh(self.dato)
        salida = Valor(t, (self,), "tanh")
        def _atras():
            # La derivada de tanh es 1 - tanh²
            self.grad += (1 - t ** 2) * salida.grad
        salida._atras = _atras
        return salida

    def relu(self):
        salida = Valor(max(0.0, self.dato), (self,), "relu")
        def _atras():
            # Pendiente 1 si la entrada fue positiva, 0 si no
            self.grad += (self.dato > 0) * salida.grad
        salida._atras = _atras
        return salida

    # Lo demás se arma con lo anterior
    def __neg__(self):                # -x
        return self * -1

    def __sub__(self, otro):          # x - y
        return self + (-otro)

    def __radd__(self, otro):         # 3 + x
        return self + otro

    def __rmul__(self, otro):         # 3 * x
        return self * otro

    def retropropagar(self):
        """Llena el grad de todos los Valor de los que depende este."""
        # Orden topológico: cada nodo va después de todos sus hijos
        orden, vistos = [], set()
        def visitar(v):
            if v not in vistos:
                vistos.add(v)
                for hijo in v._hijos:
                    visitar(hijo)
                orden.append(v)
        visitar(self)
        # dSalida/dSalida = 1, y de ahí hacia atrás
        self.grad = 1.0
        for v in reversed(orden):
            v._atras()
a = Valor(2.0)
b = Valor(-3.0)
c = Valor(10.0)
L = (a * b + c) ** 2
L.retropropagar()
print("L =", L)
print("a =", a)
print("b =", b)
print("c =", c)
x = Valor(3.0)
y = Valor(-2.0)
f = x * y + x
f.retropropagar()
print("f =", f.dato, "  df/dx =", x.grad, "  df/dy =", y.grad)

Verificar gradientes con diferencias finitas

def expresion(x, y, z):
    """Una expresión cualquiera que usa todo lo que sabe el motor."""
    return (x * y + z.tanh()) * (x - y).relu() + x ** 3

def gradiente_numerico(f, numeros, h=1e-6):
    """Derivada centrada de f respecto de cada número de la lista."""
    grads = []
    for i in range(len(numeros)):
        mas = list(numeros)
        menos = list(numeros)
        mas[i] += h
        menos[i] -= h
        f_mas = f(*[Valor(v) for v in mas]).dato
        f_menos = f(*[Valor(v) for v in menos]).dato
        grads.append((f_mas - f_menos) / (2 * h))
    return grads

numeros = [1.5, -0.5, 0.8]
entradas = [Valor(v) for v in numeros]
expresion(*entradas).retropropagar()

for nombre, v, g_num in zip("xyz", entradas,
                            gradiente_numerico(expresion, numeros)):
    print(f"{nombre}:  autograd = {v.grad:.6f}   numérico = {g_num:.6f}"
          f"   diferencia = {abs(v.grad - g_num):.1e}")

Una red neuronal encima del motor

import random

class Neurona:
    """Suma ponderada de las entradas, más el sesgo, y luego tanh."""

    def __init__(self, n_entradas):
        self.w = [Valor(random.uniform(-1, 1)) for _ in range(n_entradas)]
        self.b = Valor(0.0)

    def __call__(self, x):
        z = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        return z.tanh()

    def parametros(self):
        return self.w + [self.b]

class Capa:
    """Varias neuronas que reciben las mismas entradas."""

    def __init__(self, n_entradas, n_neuronas):
        self.neuronas = [Neurona(n_entradas) for _ in range(n_neuronas)]

    def __call__(self, x):
        salidas = [neurona(x) for neurona in self.neuronas]
        return salidas[0] if len(salidas) == 1 else salidas

    def parametros(self):
        return [p for n in self.neuronas for p in n.parametros()]

class MLP:
    """Capas en fila: la salida de una es la entrada de la siguiente."""

    def __init__(self, anchos):
        self.capas = [Capa(anchos[i], anchos[i + 1])
                      for i in range(len(anchos) - 1)]

    def __call__(self, x):
        for capa in self.capas:
            x = capa(x)
        return x

    def parametros(self):
        return [p for capa in self.capas for p in capa.parametros()]

random.seed(0)                        # pesos iniciales reproducibles
modelo = MLP([2, 8, 1])               # 2 entradas, 8 ocultas, 1 salida
print("Parámetros:", len(modelo.parametros()))
print("Salida para el punto (1, 0):", modelo([1.0, 0.0]))
from sklearn.datasets import make_moons

X_lunas, y_lunas = make_moons(n_samples=30, noise=0.1, random_state=0)
y_signo = 2 * y_lunas - 1             # clases -1 y +1, como la tanh

def perdida_y_exactitud(modelo, X, y):
    """MSE de la red sobre todos los puntos, y la fracción de aciertos."""
    salidas = [modelo(list(fila)) for fila in X]
    perdida = sum((s - yi) ** 2 for s, yi in zip(salidas, y))
    perdida = perdida * (1 / len(y))
    aciertos = sum((s.dato > 0) == (yi > 0) for s, yi in zip(salidas, y))
    return perdida, aciertos / len(y)

perdida, exactitud = perdida_y_exactitud(modelo, X_lunas, y_signo)
print(f"Antes de entrenar: pérdida = {perdida.dato:.4f}"
      f"   aciertos = {exactitud:.0%}")
tasa = 0.5
perdidas = []                         # la pérdida de cada época
fotos = {}                            # los parámetros en algunas épocas

for epoca in range(201):
    perdida, exactitud = perdida_y_exactitud(modelo, X_lunas, y_signo)
    perdidas.append(perdida.dato)
    if epoca in [0, 20, 100, 200]:
        fotos[epoca] = [p.dato for p in modelo.parametros()]
    if epoca % 50 == 0 or epoca == 20:
        print(f"Época {epoca:3d}:   pérdida = {perdida.dato:.4f}"
              f"   aciertos = {exactitud:.0%}")
    if epoca == 200:
        break                         # la última época solo mide
    for p in modelo.parametros():     # 2. gradientes en cero
        p.grad = 0.0
    perdida.retropropagar()           # 3. hacia atrás
    for p in modelo.parametros():     # 4. un paso en contra
        p.dato -= tasa * p.grad
plt.plot(perdidas, color="C0")
plt.xlabel("Época")
plt.ylabel("Pérdida (MSE)")
plt.title("El mini-MLP aprendiendo")
plt.show()
def cargar(modelo, numeros):
    """Pone en la red los parámetros guardados en una foto."""
    for p, v in zip(modelo.parametros(), numeros):
        p.dato = v

# Una cuadrícula de 25 × 25 puntos que cubre las dos lunas
xs = np.linspace(-1.5, 2.5, 25)
ys = np.linspace(-1.0, 1.5, 25)
A, B = np.meshgrid(xs, ys)

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8),
                         sharex=True, sharey=True)
for eje, epoca in zip(ejes.flat, [0, 20, 100, 200]):
    cargar(modelo, fotos[epoca])
    Z = np.array([[modelo([u, v]).dato for u in xs] for v in ys])
    # Fondo: la zona donde la red dice «clase 1»
    eje.contourf(A, B, Z, levels=[0, 1.1], colors="C3")
    eje.contour(A, B, Z, levels=[0], colors="C0", linewidths=1.5)
    eje.scatter(*X_lunas[y_lunas == 0].T, s=14, color="C0", marker="o")
    eje.scatter(*X_lunas[y_lunas == 1].T, s=14, color="C1", marker="s")
    eje.set_title(f"Época {epoca}")
plt.show()
cargar(modelo, fotos[200])            # la red queda como al final

Una capa entera, con matrices

rng = np.random.default_rng(0)
X_capa = rng.normal(size=(5, 3))      # 5 ejemplos, 3 entradas
W = rng.normal(size=(3, 4))           # 3 entradas, 4 neuronas
b_capa = rng.normal(size=4)           # un sesgo por neurona

def perdida_capa(W, b_capa):
    Z = X_capa @ W + b_capa
    S = np.maximum(Z, 0)              # ReLU
    return 0.5 * np.sum(S ** 2)

# Adelante, guardando lo que hará falta atrás
Z = X_capa @ W + b_capa
S = np.maximum(Z, 0)

# Atrás, con matrices
dS = S                                # derivada de 0,5·S² es S
dZ = dS * (Z > 0)                     # ReLU: pasa solo si Z > 0
dW = X_capa.T @ dZ
db = dZ.sum(axis=0)
dX = dZ @ W.T
print("Formas:  W", W.shape, " dW", dW.shape, "  b", b_capa.shape,
      " db", db.shape, "  X", X_capa.shape, " dX", dX.shape)

# Verificación numérica, peso por peso
h = 1e-6
dW_num = np.zeros_like(W)
for i in range(W.shape[0]):
    for j in range(W.shape[1]):
        W_mas, W_menos = W.copy(), W.copy()
        W_mas[i, j] += h
        W_menos[i, j] -= h
        dW_num[i, j] = (perdida_capa(W_mas, b_capa)
                        - perdida_capa(W_menos, b_capa)) / (2 * h)
print("Mayor diferencia en dW:", f"{np.abs(dW - dW_num).max():.1e}")

Gradientes que se desvanecen o explotan

# Un gradiente que cruza 10, 20 o 50 capas,
# con el mismo gradiente local en cada una
for local in [0.25, 0.9, 1.1, 1.5]:
    print(f"local {local}:", "   ".join(
        f"{n} capas: {local ** n:.1e}" for n in [10, 20, 50]))