Backpropagation: cómo aprende una red
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.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 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]))¿Terminaste este tema?
Márcalo y en la ruta verás cuánto llevas. Se guarda solo en este navegador.