Backpropagation: cómo aprende una red
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.gradplt.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 finalUna 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]))