Saltar al contenido
← Deep learning a secas

Lección 8 de 10 · Entrenar bien · 15 min

Regularización

Dropout, weight decay y early stopping

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 ocho: regularización. En el episodio siete, una red entrenada con cien dígitos se los aprendió de memoria: la pérdida de train llegó a casi cero y la de validación empezó a subir. Hoy, tres formas de frenar eso, medidas sobre el mismo experimento.

Tres frenos contra la memoria

  • Dropout: apagar neuronas al azar al entrenar
  • Weight decay: castigar los pesos grandes
  • Early stopping: cortar en la mejor epoch
  • Las tres se juzgan en validación

Regularizar es cualquier cosa que le hace más difícil a la red ajustarse a los detalles del train set, para que aprenda lo general. Dropout apaga neuronas al azar en cada paso, así ninguna puede depender de otra en particular. Weight decay empuja todos los pesos hacia cero en cada paso: una red con pesos chicos dibuja fronteras más suaves. Early stopping no toca la red: guarda la mejor versión y corta cuando validación deja de mejorar. Y las tres se miden igual: con el validation set, nunca con train.

Parte 1 · Dropout

La idea más rara de las tres: en cada paso de entrenamiento, la mitad de las neuronas no existe.

nn.Dropout, por dentro

nn punto Dropout con p igual a cero coma cinco: cada valor que entra se apaga con probabilidad un medio. En modo train, le pasamos seis activaciones dos veces. En modo eval, una vez. Y promediamos diez mil pasadas en modo train. La primera vez sobrevivió una sola, el uno coma cinco, y salió como tres: duplicada. La segunda sobrevivieron cinco, todas duplicadas. En eval sale todo igual, sin tocar. Y el promedio de diez mil máscaras da casi exactamente las activaciones originales: cero coma cuatro nueve, uno coma cero dos, uno coma cinco. Duplicar a las que sobreviven compensa a las que se apagan.

python
drop = nn.Dropout(p=0.5)        # apaga cada neurona con prob. 0,5
h = torch.tensor([0.5, 1.0, 1.5, 2.0, 0.8, 1.2])   # 6 activaciones

drop.train()                    # modo entrenamiento
print("h      :", h.numpy())
print("train 1:", drop(h).numpy().round(2))
print("train 2:", drop(h).numpy().round(2))   # otra máscara al azar

drop.eval()                     # modo evaluación
print("eval   :", drop(h).numpy().round(2))

drop.train()                    # promedio de 10.000 máscaras
media = torch.stack([drop(h) for _ in range(10_000)]).mean(0)
print("media  :", media.numpy().round(2))
text
h      : [0.5 1.  1.5 2.  0.8 1.2]
train 1: [0. 0. 3. 0. 0. 0.]
train 2: [1.  2.  0.  4.  1.6 2.4]
eval   : [0.5 1.  1.5 2.  0.8 1.2]
media  : [0.49 1.02 1.5  1.97 0.8  1.2 ]

La máscara, y por qué se divide por 1 − p

mi∼Bernoulli(1−p),mi∈{0,1}m_i \sim \mathrm{Bernoulli}(1-p),\qquad m_i \in \{0, 1\}

una moneda por neurona: 1 sobrevive, 0 se apaga

h~i=mi hi1−p=2 mi hi(p=0,5)\tilde h_i = \frac{m_i\, h_i}{1 - p} = 2\, m_i\, h_i \quad (p = 0{,}5)

las que sobreviven, agrandadas

E[h~i]=(1−p)⋅hi1−p+p⋅0=hi\mathbb{E}[\tilde h_i] = (1-p)\cdot\frac{h_i}{1-p} + p \cdot 0 = h_i

en promedio, lo mismo que sin dropout

m sub i es una moneda por neurona. Bernoulli de uno menos p quiere decir: vale uno con probabilidad uno menos p, y cero con probabilidad p. En el dibujo, la primera máscara del snippet: solo la tercera barra cuenta, las otras están tachadas. La activación con dropout, h tilde, es la máscara por la activación, dividida por uno menos p. Con p un medio, es multiplicar por dos a las que sobreviven. En el dibujo, la segunda pasada. ¿Por qué dividir? Por la esperanza, esa E de doble trazo, que es el promedio sobre todas las máscaras posibles: con probabilidad uno menos p sale h dividido uno menos p, y con probabilidad p sale cero. Se cancela, y queda h. En el dibujo, esa cuenta como suma pesada: un medio por dos h, y la columna del cero apagada, que no aporta. Y la media real de diez mil máscaras lo confirma.

Cuatro pasos, cuatro redes distintas

Cada paso de entrenamiento sortea una máscara nueva. En el primero, se apaga h cinco. En el segundo, tres de seis. En el tercero, otra vez dos. Y en el cuarto, por puro azar, se apagan las seis. No es un error: con p un medio, eso pasa una de cada sesenta y cuatro veces, un medio a la sexta. Es como entrenar en cada paso una red distinta, más chica, que comparte los pesos con todas las demás. Ninguna neurona puede contar con que otra esté: cada una tiene que servir sola.

Dropout en la red de dígitos

En una red, el dropout va después de la activación. Uno después de cada ReLU, rectified linear unit, de las dos hidden layers. En modo train pasamos la misma imagen dos veces e imprimimos los tres primeros logits. Y lo mismo en modo eval. En train, dos pasadas de la misma imagen dan logits distintos: menos cero coma uno tres siete y menos cero coma uno cero cinco en el primero. En eval, los dos son idénticos. Por eso la función evaluar del episodio siete llamaba a eval: con dropout, olvidarse cambia los números.

python
modelo = nn.Sequential(
    nn.Linear(64, 256), nn.ReLU(), nn.Dropout(0.5),
    nn.Linear(256, 256), nn.ReLU(), nn.Dropout(0.5),
    nn.Linear(256, 10))           # dropout después de cada ReLU

x = X[val[:1]]                    # una imagen
modelo.train()
a, b = modelo(x)[0, :3], modelo(x)[0, :3]
print("train 1:", a.detach().numpy().round(3))
print("train 2:", b.detach().numpy().round(3))
modelo.eval()
a, b = modelo(x)[0, :3], modelo(x)[0, :3]
print("eval 1 :", a.detach().numpy().round(3))
print("eval 2 :", b.detach().numpy().round(3))
text
train 1: [-0.137  0.005 -0.14 ]
train 2: [-0.105  0.041 -0.061]
eval 1 : [-0.116  0.054 -0.09 ]
eval 2 : [-0.116  0.054 -0.09 ]

Srivastava, Hinton, Krizhevsky, Sutskever y Salakhutdinov, 2014 — Dropout: A Simple Way to Prevent Neural Networks from Overfitting · fuente

thinned networks · «During training, dropout samples from an exponential number of different thinned networks.»

El paper es de dos mil catorce, de Srivastava, Hinton y otros tres autores. La idea clave, dicen, es apagar unidades al azar, con sus conexiones, durante el entrenamiento. Así se muestrea entre una cantidad exponencial de redes adelgazadas, thinned networks. Y para evaluar usan una sola red sin adelgazar, con pesos más chicos. PyTorch hace lo equivalente al revés: en vez de achicar en eval, agranda en train, dividiendo por uno menos p.

Parte 2 · Weight decay

Un empujón constante hacia cero, en cada paso, para cada peso.

weight_decay en el optimizador

Para verlo solo, sin pérdida que lo tape. Un vector w de dos pesos, uno y menos dos. SGD, stochastic gradient descent, con learning rate cero coma uno y weight decay cero coma cinco: eso es lambda. Una pérdida que da cero siempre, así su gradiente es cero. Un step. Y nueve pasos más, comparados con cero coma noventa y cinco a la diez. Después de un paso, w vale cero coma noventa y cinco y menos uno coma nueve: cada peso se multiplicó por uno menos eta por lambda, cero coma noventa y cinco. Después de diez pasos, cero coma cinco nueve ocho siete: exactamente cero coma noventa y cinco a la diez. Sin gradiente, los pesos se encogen solos.

python
w = torch.tensor([1.0, -2.0], requires_grad=True)
opt = torch.optim.SGD([w], lr=0.1, weight_decay=0.5)   # η = 0,1 y λ = 0,5

loss = (w * 0).sum()          # una pérdida que no empuja: g = 0
loss.backward()
opt.step()                    # solo actúa el weight decay
print("w después de un paso:", w.detach().numpy().round(4))
print("w · (1 − η·λ)       :", (torch.tensor([1.0, -2.0]) * (1 - 0.1 * 0.5)).numpy())

for paso in range(9):         # nueve pasos más, sin gradiente
    opt.zero_grad(); (w * 0).sum().backward(); opt.step()
print("después de 10 pasos :", w.detach().numpy().round(4))
print("0,95 ** 10          :", round(0.95 ** 10, 4))
text
w después de un paso: [ 0.95 -1.9 ]
w · (1 − η·λ)       : [ 0.95 -1.9 ]
después de 10 pasos : [ 0.5987 -1.1975]
0,95 ** 10          : 0.5987

De la penalización al paso

∥w∥2=∑iwi2=12+(−2)2=5\|w\|^2 = \textstyle\sum_i w_i^2 = 1^2 + (-2)^2 = 5

la norma al cuadrado: qué tan grandes son los pesos

L~=L+λ2∥w∥2  ⇒  ∇wL~=g+λ w\tilde L = L + \frac{\lambda}{2}\|w\|^2 \;\Rightarrow\; \nabla_w \tilde L = g + \lambda\, w

la derivada de λ/2 · w² es λ·w

w←w−η (g+λw)=(1−ηλ) w−η gw \leftarrow w - \eta\,(g + \lambda w) = (1 - \eta\lambda)\, w - \eta\, g

η = 0,1, λ = 0,5 → factor 0,95

Primero, cómo se mide el tamaño de los pesos. Las dos barras verticales son la norma: la longitud del vector. Al cuadrado, es la suma de cada peso al cuadrado. Con uno y menos dos, uno más cuatro, cinco. Weight decay es sumarle a la pérdida lambda medios por esa norma al cuadrado. Lambda es un número chico que elegimos. Al derivar, cada w al cuadrado da dos w, y el dos se cancela con el medio: al gradiente g se le suma lambda w. En el dibujo, la parábola: cuanto más lejos de cero, más empinada, más empuja. Y el paso de SGD: distribuimos eta, y agrupamos los términos con w. Queda uno menos eta lambda, por w, menos eta g. En el dibujo, la suma: cero coma noventa y cinco veces w, menos cero coma uno veces el gradiente.

Parte 3 · Early stopping

La más simple: mirar validación cada epoch, guardar la mejor red y cortar cuando deja de mejorar.

Guardar la mejor y cortar

El mismo entrenamiento del episodio siete, con tres agregados. Después de cada epoch medimos validación. Si es la menor hasta ahora, esa epoch pasa a ser e estrella, el arg min: la epoch donde la pérdida es mínima. Y guardamos una copia de todos los pesos, theta estrella. state dict es el diccionario de tensores de la red; esa copia es un checkpoint. Si pasaron veinte epochs sin mejorar, cortamos. Ese veinte es la paciencia: validación es ruidosa, y no queremos cortar por un mal día. Al terminar, cargamos el checkpoint. Cortó en la epoch treinta y ocho, y la mejor fue la dieciocho, la misma del episodio siete. Restaurada, la red da pérdida cero coma cuatro cinco seis y ochenta y seis coma cuatro por ciento de accuracy.

Cada línea, su término:

  • if lv < mejor_loss → e∗=arg⁡min⁡e  Lval(e)e^\ast = \arg\min_{e}\; L_{\text{val}}(e) — la mejor epoch hasta ahora
  • copy.deepcopy(modelo.state_dict()) → θ∗←θe∗\theta^\ast \leftarrow \theta_{e^\ast} — un checkpoint de todos los pesos
  • epoch - mejor_epoch >= paciencia → e−e∗≥P=20e - e^\ast \ge P = 20 — P: la paciencia
  • modelo.load_state_dict(mejor) → θ←θ∗\theta \leftarrow \theta^\ast — volver a la mejor
python
paciencia = 20                    # epochs sin mejorar que toleramos
mejor_loss, mejor_epoch = float("inf"), 0
for epoch in range(1, 201):
    una_epoch(modelo, opt, epoch)
    lv, _ = evaluar(modelo, val)
    if lv < mejor_loss:           # mejoró: guardar un checkpoint
        mejor_loss, mejor_epoch = lv, epoch
        mejor = copy.deepcopy(modelo.state_dict())
    elif epoch - mejor_epoch >= paciencia:
        break                     # 20 epochs sin mejorar: cortar

modelo.load_state_dict(mejor)     # volver al mejor
print("cortó en la epoch", epoch, "· mejor:", mejor_epoch)
lv, av = evaluar(modelo, val)
print(f"val restaurado: pérdida {lv:.3f} acc {av:.3f}")
text
cortó en la epoch 38 · mejor: 18
val restaurado: pérdida 0.456 acc 0.864

Parte 4 · Las tres, comparadas

Mismo modelo, mismos cien ejemplos, misma semilla. Cambia una sola cosa por vez.

Base, dropout y weight decay

entrenar es el loop de siempre, doscientas epochs, con dos perillas. La base, sin nada. Dropout de cero coma cinco. Weight decay con lambda cinco milésimas. Al final imprimimos la pérdida y la accuracy de validación, y la norma de todos los pesos. La base termina en cero coma cinco cuatro cinco y ochenta y seis coma nueve por ciento. Dropout sube la accuracy a ochenta y ocho coma tres, pero su pérdida es peor: cero coma seis cuatro seis. Weight decay deja la pérdida en cero coma cuatro uno seis, la mejor, con ochenta y siete coma seis por ciento, y la norma de los pesos baja de diecisiete a nueve.

python
variantes = {"base": entrenar(),
             "dropout 0,5": entrenar(p=0.5),
             "weight decay": entrenar(wd=5e-3)}
for nombre, (modelo, hist) in variantes.items():
    _, lv, av = hist[-1]                 # epoch 200
    print(f"{nombre:12} val {lv:.3f} acc {av:.3f} |W| {norma(modelo):.1f}")
text
base         val 0.545 acc 0.869 |W| 17.1
dropout 0,5  val 0.646 acc 0.883 |W| 21.7
weight decay val 0.416 acc 0.876 |W| 9.0

Pérdida de validación, epoch a epoch

Las tres curvas de validación, desde la epoch dieciséis. La base, la del episodio siete: toca el mínimo en la dieciocho, que es donde early stopping la deja, y después sube despacio. Dropout tarda más en bajar, porque cada paso entrena una red recortada, y después sube más que la base. Weight decay baja y se queda plana: el empujón hacia cero no la deja seguir afinando los pesos para esas cien imágenes. Un detalle: el mínimo de dropout llega recién en la epoch veintiocho, cero coma cuatro tres seis, más bajo que el de la base. Con early stopping encima, dropout también habría ganado.

Al final de las 200 epochs

Las pérdidas finales, en barras. Base, cero coma cinco cuatro cinco. Dropout, cero coma seis cuatro seis. Weight decay, cero coma cuatro uno seis. Y early stopping, que es la base cortada en la dieciocho, cero coma cuatro cinco seis. La accuracy, en cambio, queda en las cuatro entre ochenta y seis y ochenta y ocho por ciento: con cien ejemplos, ninguna de las tres hace milagros. Early stopping y weight decay terminan cerca; la diferencia es que weight decay no necesita decidir cuándo cortar.

  • base: 0,545
  • dropout: 0,646
  • weight decay: 0,416
  • early stopping: 0,456

Cómo leer este resultado

  • Dropout: más aciertos, pero errores más seguros
  • Weight decay: pesos chicos, pérdida estable
  • Early stopping: gratis, siempre conviene
  • Un experimento chico: no es una ley

Dropout ganó en accuracy y perdió en pérdida. Como vimos en el episodio siete, eso pasa cuando la red acierta más, pero cuando se equivoca lo hace con mucha seguridad. Weight decay es el que mejor frenó la subida, y lo hizo achicando los pesos a la mitad. Early stopping no cuesta nada: solo guardar un checkpoint, y se combina con cualquiera de los otros. Y una advertencia: esto es una red, una semilla y cien imágenes. Con otra red u otro p, el orden puede cambiar. Por eso cada perilla se elige mirando validación.

El tamaño de los pesos

Una última mirada, a la norma de todos los pesos al final. La base termina en diecisiete coma uno. Dropout, en veintiuno coma siete: más grandes, porque cada neurona tiene que hacer su trabajo con la mitad de compañeras. Y weight decay, en nueve: casi la mitad que la base. La regularización no es magia: es una restricción sobre los pesos que se ve en los números.

  • base: 17,1
  • dropout: 21,7
  • weight decay: 9,0

Resumiendo

  • Dropout: h~=m h/(1−p)\tilde h = m\,h/(1-p); en promedio, hh
  • En eval, dropout no hace nada: por eso model.eval()
  • Weight decay: w←(1−ηλ) w−η gw \leftarrow (1-\eta\lambda)\,w - \eta\,g
  • Early stopping: checkpoint de la mejor epoch y paciencia
  • Acá: weight decay 0,416 de pérdida; base 0,545
  • Próximo: normalización, BatchNorm y LayerNorm

Papers del episodio

¿Qué tal esta lección?

Sé el primero en calificarla

Entra para calificar

¿Terminaste la lección?

Márcala para sumar +50 XP y mantener tu racha.