Capítulo 8 de 15 8 secciones 14 min

Los tres frenos, escritos a mano y medidos

Penalizar los pesos, apagar neuronas al azar y ensuciar las entradas. Uno funciona, uno ayuda a medias y uno casi no.

Los tres frenos habituales son penalizar los pesos grandes (weight decay), apagar neuronas al azar (dropout) y ensuciar las entradas. Aquí el weight decay es el único que funciona del todo: baja la brecha entre entrenamiento y prueba de +0,2636 a -0,0013 y deja el AUC en 0,7207. El dropout ayuda a medias y con 0,5 destruye una red pequeña.

En el capítulo 7 la red memorizó las 2.250 filas y su prueba se hundió. Aquí vamos a impedírselo, con las tres herramientas que se usan de verdad 🛑

Y las vamos a escribir nosotras, que son tres o cuatro líneas cada una.

El punto de partida, con el problema puesto a propósito

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'

def sigmoide(z):
    return 1 / (1 + np.exp(-z))

def carga_limpia(url):
    v = pd.read_csv(url).drop_duplicates()
    v['ciudad'] = (v['ciudad'].str.strip().str.lower()
                   .str.normalize('NFKD')
                   .str.encode('ascii', 'ignore').str.decode('utf-8'))
    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
    for col in ['fecha', 'fecha_ultima_compra']:
        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
        falta = f.isna() & v[col].notna()
        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y', errors='coerce')
        v[col] = f
    return v

def prepara(v):
    v = v.sort_values(['cliente_id', 'fecha']).copy()
    v['sin_compra_previa'] = v['fecha_ultima_compra'].isna().astype(int)
    v['sin_descuento'] = v['descuento'].isna().astype(int)
    v['sin_satisfaccion'] = v['satisfaccion'].isna().astype(int)
    v['precio_unitario'] = v['monto'] / v['unidades']
    v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
    return v

NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario',
             'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'visita_numero']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']

datos = prepara(carga_limpia(URL))
X_tr, X_te, y_tr, y_te = train_test_split(
    datos[NUMERICAS + CATEGORICAS], datos['compro'],
    test_size=0.25, random_state=42, stratify=datos['compro'])

logistica = Pipeline([
    ('pre', ColumnTransformer([
        ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                          ('e', StandardScaler())]), NUMERICAS),
        ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                          ('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
    ])),
    ('mod', LogisticRegression(max_iter=1000, random_state=42)),
]).fit(X_tr, y_tr)

T_tr = logistica.named_steps['pre'].transform(X_tr)
T_te = logistica.named_steps['pre'].transform(X_te)
objetivo = y_tr.values.astype(float).reshape(-1, 1)
print('el listón:', round(roc_auc_score(y_te, logistica.predict_proba(X_te)[:, 1]), 4))
el listón: 0.7214

Los tres frenos, en la misma función

Fíjate en lo poco que ocupa cada uno. Están señalados con comentarios:

def corre(ocultas=32, vueltas=2000, paso=0.5, semilla=0,
          decay=0.0, dropout=0.0, ruido=0.0):
    r = np.random.default_rng(semilla)
    columnas = T_tr.shape[1]
    W1 = r.normal(0, 0.1, (columnas, ocultas)); b1 = np.zeros(ocultas)
    W2 = r.normal(0, 0.1, (ocultas, 1)); b2 = np.zeros(1)
    n = len(objetivo)

    for _ in range(vueltas):
        # FRENO 3: ensuciar las entradas
        A = T_tr + r.normal(0, ruido, T_tr.shape) if ruido else T_tr

        h = np.tanh(A @ W1 + b1)

        # FRENO 2: apagar neuronas al azar
        if dropout:
            mascara = (r.random(h.shape) > dropout) / (1 - dropout)
            h = h * mascara

        p = sigmoide(h @ W2 + b2)
        d2 = (p - objetivo) / n
        d1 = (d2 @ W2.T) * (1 - h ** 2)
        if dropout:
            d1 = d1 * mascara          # la culpa no pasa por las apagadas

        # FRENO 1: encoger los pesos un poquito en cada vuelta
        W2 -= paso * (h.T @ d2 + decay * W2); b2 -= paso * d2.sum(axis=0)
        W1 -= paso * (A.T @ d1 + decay * W1); b1 -= paso * d1.sum(axis=0)

    entrena = roc_auc_score(y_tr, sigmoide(np.tanh(T_tr @ W1 + b1) @ W2 + b2).ravel())
    prueba = roc_auc_score(y_te, sigmoide(np.tanh(T_te @ W1 + b1) @ W2 + b2).ravel())
    return entrena, prueba

print(f"{'freno':26} {'entrena':>8} {'prueba':>8} {'brecha':>8}")
for nombre, ajustes in [
    ('nada', {}),
    ('decay 0,001', dict(decay=0.001)),
    ('decay 0,01', dict(decay=0.01)),
    ('decay 0,1', dict(decay=0.1)),
    ('dropout 0,2', dict(dropout=0.2)),
    ('dropout 0,5', dict(dropout=0.5)),
    ('ruido 0,5', dict(ruido=0.5)),
]:
    e, p = corre(**ajustes)
    print(f'{nombre:26} {e:8.4f} {p:8.4f} {e - p:+8.4f}')
freno                       entrena   prueba   brecha
nada                         0.8847   0.6210  +0.2636
decay 0,001                  0.8368   0.6486  +0.1881
decay 0,01                   0.7194   0.7207  -0.0013
decay 0,1                    0.7072   0.7147  -0.0075
dropout 0,2                  0.7921   0.7032  +0.0889
dropout 0,5                  0.4964   0.5185  -0.0222
ruido 0,5                    0.7874   0.6774  +0.1100

Esa tabla es el capítulo entero, así que vamos freno por freno 👇

Cuatro curvas de AUC contra vueltas de entrenamiento: sin freno, la de entrenamiento se dispara y la de validación se queda atrás; con weight decay las dos van mucho más juntas.
La distancia entre la curva continua y la de puntos es el sobreajuste. Sin freno esa distancia crece con cada vuelta; con weight decay se mantiene chica, aunque el entrenamiento luzca peor.

Freno 1: encoger los pesos

Ltotal=L+λ2jwj2

le sumas al error un castigo por tener pesos grandes, que es la forma más barata de que la red deje de memorizar

Es una sola línea: + decay * W dentro de la corrección. En cada vuelta, además de corregir el error, se le resta a cada peso una fracción de sí mismo.

Lo que hace es castigar los pesos grandes, y los pesos grandes son justamente los que memorizan: para clavar una fila concreta hace falta un peso enorme en la columna que la distingue.

Y funciona clarísimo. Sin nada, entrena 0,8847 y prueba 0,6210, con una brecha de +0,2636. Con decay 0,01, entrena 0,7194 y prueba 0,7207, con una brecha de -0,0013 🎯

Brecha negativa significa que le va mejor en la prueba que en el entrenamiento, o sea que ya no memoriza nada y lo poco que aprendió es real.

Fíjate también en la dosis. Con 0,001 apenas hace nada (brecha +0,1881) y con 0,1 empieza a apretar de más: entrena 0,7072, que ya es menos de lo que la logística consigue. Hay una ventana y hay que buscarla.

Este freno es exactamente el Ridge del libro de machine learning, el mismo castigo a los pesos al cuadrado. En redes se le llama weight decay y en scikit-learn es el alpha de MLPClassifier, que en el capítulo 1 subió una red de 0,6584 a 0,7072 ⚖️

Red de tres capas con dos neuronas de la capa oculta tachadas y con el borde punteado: están apagadas en esta pasada, y las conexiones que salían de ellas han desaparecido.
En la siguiente pasada se apagan otras. Suena a sabotaje y es lo contrario: si en cada paso falta gente, ninguna neurona puede volverse imprescindible y el conocimiento tiene que quedar repartido.

Freno 2: apagar neuronas al azar

a~j=mj1paj,mj~Bernoulli(1p)

apagas neuronas al azar durante el entrenamiento y escalas las que quedan, para que la suma siga valiendo lo mismo cuando en producción no apagas ninguna

El dropout es más raro y más bonito. En cada vuelta, apagas una parte de las neuronas al azar y entrenas sin ellas.

La idea es que ninguna neurona pueda confiarse: como en cualquier momento sus compañeras pueden desaparecer, todas tienen que aprender algo útil por su cuenta en vez de repartirse una regla frágil entre varias.

La división por (1 - dropout) de la máscara es para que la suma total siga valiendo lo mismo aunque falte gente, y así al predecir con todas encendidas no haga falta ajustar nada.

Con 0,2 la brecha baja de +0,2636 a +0,0889 y la prueba sube a 0,7032. Ayuda, y se queda por debajo del weight decay.

Y con 0,5 pasa algo que hay que ver: entrena 0,4964 y prueba 0,5185. El modelo se destruyó, está por debajo del azar en entrenamiento 💀

La razón es el tamaño. Apagar la mitad de 32 neuronas deja 16, y con tan pocas, la red no llega a formar nada estable entre tanto apagón. El dropout de 0,5 es la receta estándar y está pensada para capas de cientos o miles de neuronas, no para esta.

Es un buen ejemplo de por qué las recetas copiadas fallan: 0,5 no es un número mágico, es un número que funciona en redes grandes 🎲

Freno 3: ensuciar las entradas

El tercero es el más simple de entender: en cada vuelta le sumas ruido aleatorio a los datos, para que la red nunca vea dos veces exactamente la misma fila.

Con ruido 0,5, que sobre columnas escaladas es muchísimo (media desviación típica), la brecha baja de +0,2636 a +0,1100 y la prueba sube a 0,6774.

O sea que ayuda menos que los otros dos y hace falta bastante cantidad. En datos tabulares no suele valer la pena.

Donde sí es la herramienta principal es en imágenes, y ahí tiene otro nombre: aumento de datos. Rotar la foto un poco, recortarla, cambiarle el brillo. Cada versión es una fila nueva que el modelo no puede memorizar, y en el capítulo 9 se entiende mejor por qué funciona tanto ahí y tan poco aquí 🖼️

Lo que dice la tabla en conjunto

El mejor resultado de todo el capítulo es 0,7207, con weight decay 0,01. Y el listón de la logística es 0,7214.

Entonces, otra vez lo mismo: el freno que funciona es el que devuelve la red a la solución lineal. No la mejora, la rescata 🙃

Y eso no es un fracaso del capítulo. Es lo que necesitas saber hacer el día que trabajes con datos donde la red sí tenga algo que aportar, porque ahí la diferencia entre una red con freno y una sin freno son treinta puntos de AUC.

Ejercicios

1. Buscar la dosis de weight decay

Barre varios valores y encuentra la ventana.

for decay in [0.0, 0.003, 0.01, 0.03, 0.1, 0.3]:
    e, p = corre(decay=decay)
    print(f'decay {decay:<6} entrena {e:.4f}  prueba {p:.4f}  brecha {e - p:+.4f}')
decay 0.0    entrena 0.8847  prueba 0.6210  brecha +0.2636
decay 0.003  entrena 0.7502  prueba 0.7122  brecha +0.0380
decay 0.01   entrena 0.7194  prueba 0.7207  brecha -0.0013
decay 0.03   entrena 0.7146  prueba 0.7179  brecha -0.0032
decay 0.1    entrena 0.7072  prueba 0.7147  brecha -0.0075
decay 0.3    entrena 0.5000  prueba 0.5000  brecha +0.0000

La ventana buena está entre 0,01 y 0,03. Con 0,1 ya se queda corto, y con 0,3 el modelo muere: entrena 0,5000 y prueba 0,5000, o sea que el freno apretó tanto que la red contesta lo mismo a todo.

Y fíjate en la brecha de esa última fila: +0,0000, perfecta. Una brecha impecable con un modelo inservible, que es el aviso de que la brecha nunca se mira sola 🚦

Esta curva tiene forma de U al revés igual que la del capítulo 7, pero en otro eje: allá era el tiempo y aquí es la fuerza del freno. Casi todo en deep learning tiene esta forma, y el trabajo consiste en encontrar el pico 🎢

2. Dropout en una red más grande

Si 0,5 mató a la red de 32, prueba con 128 neuronas.

for ocultas in [32, 128]:
    for dr in [0.0, 0.5]:
        e, p = corre(ocultas=ocultas, dropout=dr)
        print(f'{ocultas:4d} neuronas, dropout {dr}: entrena {e:.4f}  prueba {p:.4f}')
  32 neuronas, dropout 0.0: entrena 0.8847  prueba 0.6210
  32 neuronas, dropout 0.5: entrena 0.4964  prueba 0.5185
 128 neuronas, dropout 0.0: entrena 0.9364  prueba 0.6365
 128 neuronas, dropout 0.5: entrena 0.7973  prueba 0.6997

Con 128 neuronas el dropout 0,5 ya no destruye nada: la prueba pasa de 0,6365 sin dropout a 0,6997 con él. Ahí sí ayuda, y bastante.

Compáralo con la red de 32, donde ese mismo 0,5 la dejaba en 0,5185. La misma receta, resultados opuestos.

Confirmado entonces lo de arriba: el 0,5 no era malo, era demasiado para una capa de 32. La regla práctica que uso es que la capa tenga al menos cien neuronas antes de plantearme un dropout alto 📏

3. Los dos frenos juntos

Combina weight decay con dropout y mira si suman.

e, p = corre(decay=0.01, dropout=0.2)
print(f'los dos juntos: entrena {e:.4f}  prueba {p:.4f}')
print('solo decay era: entrena 0.7194  prueba 0.7207   (de la tabla de arriba)')
los dos juntos: entrena 0.7179  prueba 0.7207
solo decay era: entrena 0.7194  prueba 0.7207   (de la tabla de arriba)

Los dos juntos dan 0,7207 de prueba, o sea exactamente lo mismo que el decay solo. No suman.

Y tiene sentido: el problema era el sobreajuste, el decay ya lo resolvió del todo, y a partir de ahí lo que añadas solo puede quitar. Los frenos no se apilan por si acaso 🧱

4. Qué le pasa a los pesos con el freno

Mide cuánto valen los pesos con y sin weight decay.

def tamano_pesos(decay):
    r = np.random.default_rng(0)
    W1 = r.normal(0, 0.1, (T_tr.shape[1], 32)); b1 = np.zeros(32)
    W2 = r.normal(0, 0.1, (32, 1)); b2 = np.zeros(1)
    for _ in range(2000):
        h = np.tanh(T_tr @ W1 + b1)
        p = sigmoide(h @ W2 + b2)
        d2 = (p - objetivo) / len(objetivo)
        d1 = (d2 @ W2.T) * (1 - h ** 2)
        W2 -= 0.5 * (h.T @ d2 + decay * W2); b2 -= 0.5 * d2.sum(axis=0)
        W1 -= 0.5 * (T_tr.T @ d1 + decay * W1); b1 -= 0.5 * d1.sum(axis=0)
    return float(np.abs(W1).mean()), float(np.abs(W1).max())

for decay in [0.0, 0.01]:
    medio, maximo = tamano_pesos(decay)
    print(f'decay {decay}: peso medio {medio:.4f}  el más grande {maximo:.4f}')
decay 0.0: peso medio 0.2415  el más grande 1.1419
decay 0.01: peso medio 0.0208  el más grande 0.3018

Sin freno el peso medio es 0,2415 y el mayor llega a 1,1419. Con freno, 0,0208 y 0,3018: once veces más chicos de media.

El freno hace literalmente lo que su nombre dice: mantiene los pesos chicos. Y pesos chicos significa una función más suave, que no puede dar los saltos que hacen falta para clavar filas sueltas 📉

5. Más datos también es un freno

Entrena la red sin ningún freno pero con la mitad de las filas, y con todas.

mitad = np.arange(len(objetivo)) % 2 == 0
T_mitad, obj_mitad = T_tr[mitad], objetivo[mitad]

def con_estos(T, obj, ocultas=32, vueltas=2000):
    r = np.random.default_rng(0)
    W1 = r.normal(0, 0.1, (T.shape[1], ocultas)); b1 = np.zeros(ocultas)
    W2 = r.normal(0, 0.1, (ocultas, 1)); b2 = np.zeros(1)
    for _ in range(vueltas):
        h = np.tanh(T @ W1 + b1)
        p = sigmoide(h @ W2 + b2)
        d2 = (p - obj) / len(obj)
        d1 = (d2 @ W2.T) * (1 - h ** 2)
        W2 -= 0.5 * (h.T @ d2); b2 -= 0.5 * d2.sum(axis=0)
        W1 -= 0.5 * (T.T @ d1); b1 -= 0.5 * d1.sum(axis=0)
    return roc_auc_score(y_te, sigmoide(np.tanh(T_te @ W1 + b1) @ W2 + b2).ravel())

print(f'con {mitad.sum()} filas: {con_estos(T_mitad, obj_mitad):.4f}')
print(f'con {len(objetivo)} filas: {con_estos(T_tr, objetivo):.4f}')
con 1125 filas: 0.5701
con 2250 filas: 0.6210

Con la mitad de las filas saca 0,5701 y con todas 0,6210.

Los datos son el freno más eficaz que existe, y el único que no tiene contraindicaciones. El problema es que casi nunca puedes conseguir más, y por eso existen los otros tres 🗄️

6. El error de olvidar la máscara en la vuelta atrás

Quita el d1 = d1 * mascara y compara el gradiente con el numérico.

r = np.random.default_rng(0)
W1 = r.normal(0, 0.1, (T_tr.shape[1], 8)); b1 = np.zeros(8)
W2 = r.normal(0, 0.1, (8, 1)); b2 = np.zeros(1)

h = np.tanh(T_tr @ W1 + b1)
mascara = (r.random(h.shape) > 0.5) / 0.5
h_apagada = h * mascara
p = sigmoide(h_apagada @ W2 + b2)
d2 = (p - objetivo) / len(objetivo)

bien = ((d2 @ W2.T) * mascara) * (1 - h ** 2)
mal = (d2 @ W2.T) * (1 - h ** 2)

print('gradiente con máscara, media:', round(float(np.abs(T_tr.T @ bien).mean()), 6))
print('gradiente sin máscara, media:', round(float(np.abs(T_tr.T @ mal).mean()), 6))
print('¿son iguales?', np.allclose(bien, mal))
gradiente con máscara, media: 0.002287
gradiente sin máscara, media: 0.002234
¿son iguales? False

No son iguales, y el fallo es silencioso: la red entrena igual, sin ningún error, corrigiendo neuronas que en esa vuelta estaban apagadas.

Es de los errores más comunes al escribir dropout a mano, y la única forma de cazarlo es el gradient checking del capítulo 6 🔍

7. El error de la dosis imposible

Pon un dropout de 1, o sea apagarlo todo.

corre(dropout=1.0)
ValueError: Input contains NaN.

Apagar el 100% de las neuronas deja la división / (1 - 1), que es dividir entre cero.

Numpy no lanza excepción por dividir entre cero en un array: devuelve inf con un aviso, y ese inf viaja hasta que algo revienta más adelante. Por eso el error que sale no habla de dropout ni de división: habla del sitio donde el inf acabó rompiendo algo 🧯

Los errores de deep learning casi siempre aparecen lejos de donde nacieron, y esa es la parte que más cuesta al principio.

Comprueba que lo tienes

Dropout 0,5 destruye una red de 32 neuronas (0,4964) y ayuda a una de 128. ¿Por qué?

  • Porque apagar la mitad de 32 deja demasiado poco para representar nada
  • Porque dropout solo funciona en redes grandes por definición
  • Porque 0,5 es un valor mal elegido siempre
  • Porque la red de 32 necesita más vueltas

Lo que te llevas

  • ⚖️ Weight decay es una línea (+ decay * W) y es el que funciona: la brecha pasa de +0,2636 a -0,0013 y la prueba sube a 0,7207.
  • 🎯 Y tiene dosis: con 0,001 no hace nada y con 0,1 aprieta de más.
  • 🎲 Dropout 0,2 ayuda a medias. Dropout 0,5 destruye una red de 32 neuronas (0,4964 en entrenamiento) y funciona bien con 128.
  • 🖼️ Ensuciar las entradas ayuda poco en tabla. En imágenes es la herramienta principal y se llama aumento de datos.
  • 🧱 Los frenos no se apilan por si acaso: decay más dropout da lo mismo que decay solo.
  • 📉 El freno hace lo que dice: el peso medio baja de 0,2415 a 0,0208.
  • 🚦 La brecha nunca se mira sola: con decay 0,3 sale +0,0000 perfecta y el modelo contesta 0,5000, o sea nada.
  • 🗄️ Más datos es el freno más eficaz y el único sin contraindicaciones.

En el capítulo 9 cambiamos de tipo de dato: las convolucionales, y por fin un problema donde la red gana de calle.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?