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 👇
Freno 1: encoger los pesos
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 ⚖️
Freno 2: apagar neuronas al azar
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! 🌸