Backpropagation: cómo aprende una red

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

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

Los 19 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; 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)
# Un poco por debajo de 0 para que no se corte el borde de la caja c
eje.set_ylim(-0.05, 3.3)
eje.set_aspect("equal")
eje.axis("off")
eje.set_title("L = (a·b + c)²: valores y gradientes")
plt.show()

Lo que necesitas de Python

class Punto:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, otro):          # Python lo llama con p + q
        return Punto(self.x + otro.x, self.y + otro.y)

    def __repr__(self):               # lo que muestra print(p)
        return f"Punto({self.x}, {self.y})"

p = Punto(1, 2) + Punto(10, 20)
print(p)
print(isinstance(p, Punto), isinstance(3, Punto), isinstance(3, int))
def crear_multiplicador(k):
    def multiplicar(x):
        return k * x                  # usa la k de afuera
    return multiplicar                # sin paréntesis: la función misma

doble = crear_multiplicador(2)
triple = crear_multiplicador(3)
print(doble(5), triple(5))
def contar_numeros(cosa):
    """Cuenta los números que hay dentro de listas de listas."""
    if not isinstance(cosa, list):
        return 1                      # caso directo: un número suelto
    total = 0
    for parte in cosa:
        total += contar_numeros(parte)  # se llama a sí misma
    return total

print(contar_numeros([1, [2, 3], [[4], 5]]))

vistos = set()
for letra in "banana":
    vistos.add(letra)                 # una letra repetida no se agrega
print(len(vistos), "a" in vistos, "z" in vistos)

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(*lista) pasa cada elemento de la lista como un argumento
        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 b, 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 = self.b
        for wi, xi in zip(self.w, x):
            z = z + wi * xi           # cada + y cada * arma el grafo
        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 b 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]))