Lección 7 de 10 · Aprender · 16 min
El training loop
Train, validación y overfitting con dígitos escritos a mano
Notebook del episodio
El código de esta lección, para correrlo y modificarlo en Google Colab, gratis y con tu cuenta. Para guardar tus cambios: Archivo → Guardar una copia en Drive.
Deep learning a secas. Episodio siete: el training loop. Ya tenemos neuronas, capas, una pérdida y gradientes automáticos. Hoy los juntamos en el bucle que entrena cualquier red, la entrenamos para leer dígitos escritos a mano, y vemos el primer problema serio: la red que memoriza.
El loop, en cinco pasos
Todo entrenamiento es este ciclo, repetido miles de veces. Primero se borran los gradientes viejos. Después, el forward: la red calcula su salida para un grupo de ejemplos, un batch. Con esa salida, la pérdida: un número que dice qué tan mal le fue. El backward llena el gradiente de cada peso, como en el episodio seis. Y el step mueve los pesos un poquito en contra del gradiente. Vuelta a empezar con el batch siguiente.
Parte 1 · Los datos
Mil setecientos noventa y siete dígitos escritos a mano, de ocho por ocho píxeles. Vienen con scikit-learn: no se descarga nada.
Cargar, escalar y separar
load digits devuelve X, una matriz con una fila por imagen y sesenta y cuatro columnas, una por píxel, e y, la etiqueta: qué dígito es. Los píxeles van de cero a dieciséis; dividimos por dieciséis para dejarlos entre cero y uno. Mezclamos los índices al azar, con una semilla fija para que el resultado sea reproducible. Y los partimos: mil doscientos para entrenar, el train set, y el resto para validar. Mil setecientos noventa y siete imágenes de sesenta y cuatro píxeles, diez clases, y en validación quedan quinientas noventa y siete, entre cincuenta y dos y sesenta y ocho por dígito.
X, y = load_digits(return_X_y=True) # viene con scikit-learn
print("X:", X.shape, " y:", y.shape) # 1797 imágenes de 8x8
print("píxeles de", X.min(), "a", X.max())
print("clases:", np.unique(y))
X = torch.tensor(X / 16, dtype=torch.float32) # píxeles en [0, 1]
y = torch.tensor(y)
g = torch.Generator().manual_seed(0) # semilla: reproducible
perm = torch.randperm(len(X), generator=g) # mezclar los índices
train, val = perm[:1200], perm[1200:]
print("train:", len(train), " val:", len(val))
n = torch.bincount(y[val]) # ejemplos de cada dígito
print("por clase en val:", n.min().item(), "a", n.max().item())
X: (1797, 64) y: (1797,)
píxeles de 0.0 a 16.0
clases: [0 1 2 3 4 5 6 7 8 9]
train: 1200 val: 597
por clase en val: 52 a 68
Un ejemplo de train: un 6
Así se ve una de esas filas, devuelta a su forma de ocho por ocho. Cada casilla es un píxel, y el número dice cuánta tinta hay: cero es blanco, dieciséis es lleno. Si mirás las casillas más intensas, se dibuja un seis. Para la red, en cambio, son solo sesenta y cuatro números en fila: no sabe que la casilla de al lado está al lado.
Alpaydin y Kaynak, 1998 — Optical Recognition of Handwritten Digits · fuente
32×32 → 8×8 · «32x32 bitmaps are divided into nonoverlapping blocks of 4x4 and the number of on pixels are counted in each block.»
¿Por qué de cero a dieciséis? Estos datos los donaron Alpaydin y Kaynak al repositorio de la Universidad de California en Irvine en mil novecientos noventa y ocho. Las imágenes originales eran de treinta y dos por treinta y dos, en blanco y negro. Las cortaron en bloques de cuatro por cuatro y contaron los píxeles encendidos de cada bloque: dieciséis como máximo. La copia de scikit-learn es el conjunto de test original: los dígitos de trece de las cuarenta y tres personas.
Por qué separar train y validación
- La red ve train: ajusta los pesos con esos
- Validación nunca toca los pesos
- Bien en train no prueba nada
- Bien en validación: generaliza
La separación es la idea más importante del episodio. Con el train set se calculan los gradientes y se mueven los pesos. El validation set, o validación, solo se usa para medir: nunca entra en un backward. Que la red acierte en train no prueba que aprendió a leer dígitos: podría haberse aprendido esas mil doscientas imágenes de memoria. Si acierta en imágenes que nunca vio, generaliza. En proyectos reales hay un tercer corte, el de test, que se mira una sola vez al final.
Parte 2 · El modelo y la pérdida
Una red de tres capas lineales, con una activación entre ellas. Lo mismo de los episodios dos a cuatro, ahora con diez salidas.
Un MLP para dígitos
Es un MLP, multilayer perceptron: capas lineales apiladas con una ReLU, rectified linear unit, en el medio. La primera capa lleva los sesenta y cuatro píxeles a doscientas cincuenta y seis neuronas. La última da diez números, los logits: un puntaje por dígito. Contamos los parámetros. Y calculamos la entropía cruzada de un batch de cuatro imágenes, primero ejemplo por ejemplo. Ochenta y cinco mil dos parámetros. Los logits son de cuatro por diez. Cada ejemplo da una pérdida cerca de dos coma tres, y el promedio, dos coma dos nueve nueve, es casi exactamente logaritmo natural de diez. No es casualidad: lo vemos en la fórmula.
modelo = nn.Sequential(
nn.Linear(64, 256), nn.ReLU(), # 64 píxeles → 256
nn.Linear(256, 256), nn.ReLU(), # hidden layer
nn.Linear(256, 10)) # un logit por dígito
n = sum(p.numel() for p in modelo.parameters())
print("parámetros:", n)
logits = modelo(X[train[:4]]) # un batch de 4 imágenes
por_ejemplo = F.cross_entropy(logits, y[train[:4]], reduction="none")
print("logits:", tuple(logits.shape))
print("por ejemplo:", por_ejemplo.detach().numpy().round(3))
print("promedio:", round(por_ejemplo.mean().item(), 3),
" ln(10) =", round(math.log(10), 3))
parámetros: 85002
logits: (4, 10)
por ejemplo: [2.338 2.277 2.308 2.273]
promedio: 2.299 ln(10) = 2.303
La red de este episodio
Dibujada, con menos círculos de los que tiene. A la izquierda, los píxeles. La primera hidden layer, de doscientas cincuenta y seis neuronas con ReLU. La segunda, igual. Y a la derecha, diez logits, uno por dígito. El pulso que cruza la red es el forward: cada batch hace este viaje, y el backward el de vuelta.
La pérdida de un batch
z: los 10 logits de una imagen
entropía cruzada de la imagen i
B: tamaño del batch
El softmax, que vimos en el episodio cuatro, convierte los diez logits, z, en diez probabilidades, p, que suman uno: e a la cada logit, dividido por la suma de todos. En el dibujo, una red sin entrenar tiene logits casi iguales, así que cada dígito recibe más o menos cero coma uno. La entropía cruzada de una imagen es menos el logaritmo de la probabilidad que la red le dio a la respuesta correcta, y sub i. En el dibujo, el logaritmo: en uno vale cero, y en cero coma uno, menos dos coma tres. Por eso una red que reparte parejo empieza en ln de diez. Y la pérdida del batch es el promedio de las B pérdidas. En el dibujo, las cuatro barras del snippet y su promedio, dos coma dos nueve nueve.
Parte 3 · Un paso de entrenamiento
Cinco líneas que vas a ver en todo código de PyTorch, siempre en el mismo orden.
Las cinco líneas
El optimizador es el que mueve los pesos. Usamos SGD, stochastic gradient descent: descenso por gradiente, estocástico porque cada paso mira un batch al azar y no todos los datos. Learning rate cero coma cero cinco y momentum cero coma nueve. Primero, borrar los gradientes. Después del forward y la pérdida, backward llena el punto grad de cada peso. Y step los mueve. La prueba de por qué hay que borrar: dos backward seguidos sin zero grad. Sobre el mismo batch, la pérdida baja: dos coma tres uno cuatro, dos coma tres cero tres, dos coma dos ocho tres, dos coma dos cinco siete. Y la norma del gradiente pasa de cero coma uno tres dos a cero coma dos seis tres: el doble. PyTorch acumula, como nuestro más igual del episodio seis.
opt = torch.optim.SGD(modelo.parameters(), lr=0.05, momentum=0.9)
b = train[:25] # un batch de 25
for paso in range(4):
opt.zero_grad() # 1. borrar los .grad
logits = modelo(X[b]) # 2. forward
loss = F.cross_entropy(logits, y[b]) # 3. pérdida
loss.backward() # 4. backward: llena .grad
opt.step() # 5. mover los pesos
print(f"paso {paso}: pérdida {loss.item():.3f}")
# ¿y sin zero_grad? los .grad se suman
w = modelo[0].weight
opt.zero_grad(); F.cross_entropy(modelo(X[b]), y[b]).backward()
g1 = w.grad.norm().item()
F.cross_entropy(modelo(X[b]), y[b]).backward() # segunda vez
print(f"norma .grad: {g1:.3f} → {w.grad.norm().item():.3f}")
paso 0: pérdida 2.314
paso 1: pérdida 2.303
paso 2: pérdida 2.283
paso 3: pérdida 2.257
norma .grad: 0.132 → 0.263
Lo que hace step
el gradiente: una derivada por peso
SGD: un paso cuesta abajo
momentum: arrastra la dirección de antes
La nabla, ese triángulo invertido, es el gradiente: un vector con una derivada parcial por cada peso, los ochenta y cinco mil. Cada una dice cuánto sube la pérdida si ese peso sube un poquito. En el dibujo, la pendiente en un punto. SGD resta eta veces el gradiente: un paso en la dirección en que la pérdida baja. Eta es el learning rate. En el dibujo, el punto baja por la curva. El momentum agrega una velocidad, v: cero coma nueve veces la velocidad anterior más el gradiente nuevo, y el paso se da con v. En el dibujo, las dos columnas que se suman. Si los gradientes apuntan siempre para el mismo lado, v crece; si zigzaguean, se cancelan. Los optimizadores tienen su propia serie; acá alcanza con esto.
SGD y momentum en un valle
Un valle alargado: muy empinado de arriba a abajo y casi plano de izquierda a derecha, como suelen ser las pérdidas de verdad. Los caminos están calculados con las dos reglas, cuarenta pasos cada uno. SGD, con learning rate cero coma tres, rebota entre las paredes empinadas y avanza lento por el fondo plano: termina a casi cuatro décimas del mínimo. Momentum, con un learning rate diez veces más chico, también oscila al principio, pero la velocidad se acumula a lo largo del valle y termina a menos de una décima.
Parte 4 · Entrenar y medir
Ahora el bucle completo sobre las mil doscientas imágenes, midiendo en validación.
Medir sin entrenar
Una función para medir. eval pone la red en modo evaluación. Con esta red no cambia nada, pero hay capas que se portan distinto al entrenar, como dropout y BatchNorm, y son los episodios ocho y nueve. no grad le dice a PyTorch que no arme el grafo de cómputo: no vamos a derivar, y ahorra memoria. La predicción es el dígito con el logit más grande: argmax. Y al terminar, volvemos a modo train. Sin entrenar, en validación: pérdida dos coma tres cero seis, y accuracy cero coma cero nueve cinco. Una de cada diez, lo mismo que tirar un dado de diez caras.
def evaluar(idx):
modelo.eval() # modo evaluación
with torch.no_grad(): # sin grafo: no vamos a derivar
logits = modelo(X[idx])
loss = F.cross_entropy(logits, y[idx]).item()
pred = logits.argmax(1) # el logit más grande gana
acc = (pred == y[idx]).float().mean().item()
modelo.train() # de vuelta a modo entrenamiento
return loss, acc
loss, acc = evaluar(val)
print(f"val sin entrenar: pérdida {loss:.3f} acc {acc:.3f}")
val sin entrenar: pérdida 2.306 acc 0.095
El loop completo
Veinte epochs: veinte pasadas completas por el train set. En cada una, mezclamos el orden de nuevo. Y lo cortamos en batches de veinticinco, con las cinco líneas de siempre. Cada tanto, medimos. Después de una epoch, ya acierta ochenta coma seis por ciento en validación. En la epoch diez, noventa y ocho. En la veinte, noventa y ocho coma tres. Pero mirá las pérdidas: en train llega a cero coma cero cero uno, y en validación se queda en cero coma cero ocho ocho, y entre la diez y la veinte subió un poco.
for epoch in range(1, 21):
g = torch.Generator().manual_seed(epoch)
orden = train[torch.randperm(len(train), generator=g)]
for i in range(0, len(orden), 25): # batches de 25
b = orden[i:i + 25]
opt.zero_grad()
F.cross_entropy(modelo(X[b]), y[b]).backward()
opt.step()
if epoch in (1, 2, 5, 10, 20):
lt, _ = evaluar(train)
lv, av = evaluar(val)
print(f"ep {epoch:2}: train {lt:.3f} val {lv:.3f} acc {av:.3f}")
ep 1: train 0.600 val 0.642 acc 0.806
ep 2: train 0.205 val 0.261 acc 0.923
ep 5: train 0.102 val 0.164 acc 0.950
ep 10: train 0.009 val 0.080 acc 0.980
ep 20: train 0.001 val 0.088 acc 0.983
Dos formas de medir
la clase con el logit más grande
corchetes: 1 si acierta, 0 si no
La pérdida y la accuracy miden cosas distintas. Para la accuracy, la predicción, y sombrero sub i, es el k que hace máximo el logit: arg max. En el dibujo, el seis tiene el logit más grande y gana. La accuracy es el promedio de los aciertos: los corchetes valen uno si la predicción es igual a la etiqueta. En el dibujo, cuatro de cinco: cero coma ocho. La accuracy solo mira si el ganador es el correcto. La pérdida mira además cuánta probabilidad puso en la respuesta correcta. Por eso la pérdida de validación puede empeorar mientras la accuracy no cambia: la red sigue acertando, pero se equivoca con más seguridad.
Parte 5 · Overfitting
Con mil doscientos ejemplos la red generaliza bien. Con cien, se ve lo que pasa cuando memoriza.
La misma red, cien ejemplos
Mismo modelo, mismo loop, pero entrenamos con cien imágenes. Validación sigue siendo la misma, de quinientas noventa y siete. El loop de antes quedó en una función. Guardamos las pérdidas de cada epoch. Y buscamos la epoch con menor pérdida de validación. En la epoch dieciocho, la pérdida de validación toca su mínimo: cero coma cuatro cinco seis, con ochenta y seis coma cuatro por ciento de accuracy. En la doscientos, train está en cero coma cero cero uno, pero validación subió a cero coma cinco cuatro cinco. La accuracy, ochenta y seis coma nueve: casi igual. Eso es overfitting.
chico = train[:100] # solo 100 ejemplos
historia = []
for epoch in range(1, 201):
entrenar_una_epoch(chico, epoch) # el mismo loop de antes
lt, _ = evaluar(chico)
lv, av = evaluar(val)
historia.append((epoch, lt, lv, av))
mejor = min(historia, key=lambda h: h[2]) # menor pérdida en val
for e, lt, lv, av in (historia[0], mejor, historia[-1]):
print(f"ep {e:3}: train {lt:.3f} val {lv:.3f} acc {av:.3f}")
ep 1: train 2.271 val 2.290 acc 0.126
ep 18: train 0.040 val 0.456 acc 0.864
ep 200: train 0.001 val 0.545 acc 0.869
Overfitting, en una curva
Las epochs de la diez a la doscientas, cuando las dos pérdidas ya bajaron de dos coma tres a menos de cero coma ocho. La de train baja hasta casi cero: la red se aprendió las cien imágenes. La de validación baja al principio, junto con la otra, y después se separa y empieza a subir despacio. El punto donde deja de mejorar es la epoch dieciocho. Todo lo que viene después es overfitting: la red se ajusta a detalles de esas cien imágenes que no sirven para otras. La distancia entre las dos curvas se llama generalization gap.
Resumiendo
- Loop: zero_grad, forward, pérdida, backward, step
- Entropía cruzada: ; sin entrenar,
- SGD: ; momentum suma los recientes
- Validación mide; nunca entra en un backward
- 1200 ejemplos: 98,3 % en val; 100 ejemplos: 86,9 %
- Próximo: regularización contra el overfitting
Papers del episodio
¿Qué tal esta lección?
Sé el primero en calificarla
¿Terminaste la lección?
Márcala para sumar +50 XP y mantener tu racha.
Tutor IA
Contexto: El training loop
Conectando con el tutor…