Ya tenemos todas las piezas: la neurona, la activación, las capas, el gradiente y la retropropagación. Toca montarlas bien y soltarlas sobre las ventas de la distribuidora 🚀
Y el capítulo va a consistir, básicamente, en subir botones y ver cómo todo empeora. Que suena raro y es lo más útil que te puedo enseñar aquí.
El punto de partida
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 = datos[NUMERICAS + CATEGORICAS]
y = datos['compro']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
random_state=42, stratify=y)
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('entreno con', T_tr.shape[0], 'filas y', T_tr.shape[1], 'columnas')
print('el listón a batir:', round(roc_auc_score(
y_te, logistica.predict_proba(X_te)[:, 1]), 4))
entreno con 2250 filas y 28 columnas el listón a batir: 0.7214
Uso el preprocesamiento de scikit-learn a propósito, porque ya lo tenemos resuelto del libro anterior y lo que interesa aquí es la red. De ahí para adelante, todo es nuestro 🔧
La red, con los dos números que hay que mirar
cada capa aporta un peso por cada par de neuronas conectadas más un sesgo por neurona, que es de dónde salen los millones de los que todo el mundo habla
def entrena(ocultas=8, paso=0.5, semilla=0, marcas=(0, 500, 2000, 5000, 10000)):
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)
for i in range(max(marcas) + 1):
if i in marcas:
en_train = roc_auc_score(y_tr, sigmoide(np.tanh(T_tr @ W1 + b1) @ W2 + b2).ravel())
en_prueba = roc_auc_score(y_te, sigmoide(np.tanh(T_te @ W1 + b1) @ W2 + b2).ravel())
print(f' vuelta {i:6d} entrena {en_train:.4f} prueba {en_prueba:.4f} '
f'brecha {en_train - en_prueba:+.4f}')
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 -= paso * (h.T @ d2); b2 -= paso * d2.sum(axis=0)
W1 -= paso * (T_tr.T @ d1); b1 -= paso * d1.sum(axis=0)
print('8 neuronas ocultas, paso 0,5')
entrena()
8 neuronas ocultas, paso 0,5 vuelta 0 entrena 0.4003 prueba 0.4044 brecha -0.0041 vuelta 500 entrena 0.7343 prueba 0.7150 brecha +0.0194 vuelta 2000 entrena 0.7948 prueba 0.6651 brecha +0.1296 vuelta 5000 entrena 0.8245 prueba 0.6290 brecha +0.1955 vuelta 10000 entrena 0.8328 prueba 0.6174 brecha +0.2154
Léelo de arriba abajo despacio, porque esta tabla es el capítulo 😨
En la vuelta 500 la red va bien: 0,7343 en entrenamiento y 0,7150 en prueba, con una brecha de dos centésimas.
En la vuelta 2.000 el entrenamiento ha subido a 0,7948 y la prueba ha bajado a 0,6651. En la 10.000, entrenamiento 0,8328 y prueba 0,6174.
O sea que a partir de la vuelta 500, cada vuelta que le das la empeora. No es que deje de mejorar: es que empeora activamente mientras el número que estabas mirando sube.
Eso es sobreajuste, y en el capítulo 6 ya lo habíamos visto en su forma más pura, con etiquetas de moneda. Aquí, con datos de verdad, tiene la misma pinta 🎲
El botón de más neuronas
print('32 neuronas ocultas, paso 0,5')
entrena(ocultas=32)
32 neuronas ocultas, paso 0,5 vuelta 0 entrena 0.4230 prueba 0.4176 brecha +0.0054 vuelta 500 entrena 0.7365 prueba 0.7165 brecha +0.0200 vuelta 2000 entrena 0.8847 prueba 0.6210 brecha +0.2636 vuelta 5000 entrena 0.9921 prueba 0.5887 brecha +0.4033 vuelta 10000 entrena 1.0000 prueba 0.5870 brecha +0.4130
Con 32 neuronas la red llega a 1,0000 de AUC en entrenamiento en la vuelta 10.000. Perfecto, sin un solo error. Y su prueba en ese momento es 0,5870, o sea casi tirar una moneda.
Ahí tienes la memorización en su forma más literal: se aprendió las 2.250 filas una por una. La brecha llega a 0,4130 😱
Y fíjate en algo importante de las dos tablas: en la vuelta 500 las dos redes van casi igual (0,7150 y 0,7165). La red grande no aprendió más rápido, solo se estropeó más rápido.
Los tres botones, medidos
Vamos a subirlos uno por uno y quedarnos solo con la prueba:
def solo_prueba(ocultas=8, paso=0.5, vueltas=2000, semilla=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)
for _ in range(vueltas):
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 -= paso * (h.T @ d2); b2 -= paso * d2.sum(axis=0)
W1 -= paso * (T_tr.T @ d1); b1 -= paso * d1.sum(axis=0)
return roc_auc_score(y_te, sigmoide(np.tanh(T_te @ W1 + b1) @ W2 + b2).ravel())
print('subiendo neuronas:')
for ocultas in [4, 8, 16, 32]:
print(f' {ocultas:3d} neuronas: {solo_prueba(ocultas=ocultas):.4f}')
print('subiendo el paso:')
for paso in [0.1, 0.5, 2.0]:
print(f' paso {paso:<4}: {solo_prueba(paso=paso):.4f}')
print('subiendo vueltas:')
for vueltas in [2000, 5000, 10000]:
print(f' {vueltas:6d} vueltas: {solo_prueba(vueltas=vueltas):.4f}')
subiendo neuronas:
4 neuronas: 0.6934
8 neuronas: 0.6651
16 neuronas: 0.6380
32 neuronas: 0.6210
subiendo el paso:
paso 0.1 : 0.7183
paso 0.5 : 0.6651
paso 2.0 : 0.6422
subiendo vueltas:
2000 vueltas: 0.6651
5000 vueltas: 0.6290
10000 vueltas: 0.6174
Los tres botones, y los tres van en la misma dirección: subirlos empeora 🎚️
Más neuronas: de 0,6934 con cuatro a 0,6210 con treinta y dos. Más paso: de 0,7183 con 0,1 a 0,6422 con 2,0. Más vueltas: de 0,6651 con dos mil a 0,6174 con diez mil.
Y mira dónde está el mejor de toda la tabla: paso 0,1, que es el ajuste más conservador de todos, con 0,7183. Casi el 0,7214 de la logística.
Esto va justo en contra de la intuición que vende el marketing de la IA, donde más grande siempre es mejor. Con 2.250 filas de datos tabulares, cada aumento de capacidad es capacidad para memorizar 🧠
Parar a tiempo, y hacerlo bien
Si en la vuelta 500 la red iba bien y luego se estropea, la solución es evidente: parar en la 500.
Y aquí viene la trampa que arruina esto en la mitad de los proyectos que veo: si eliges dónde parar mirando la prueba, la prueba deja de ser prueba. Es la fuga del capítulo 6 del libro de machine learning, con otro disfraz.
Se hace con un tercer corte, la validación, que sale de los datos de entrenamiento:
X_ent, X_val, y_ent, y_val = train_test_split(X_tr, y_tr, test_size=0.25,
random_state=42, stratify=y_tr)
pre = logistica.named_steps['pre']
E = pre.transform(X_ent)
V = pre.transform(X_val)
P = pre.transform(X_te)
obj_ent = y_ent.values.astype(float).reshape(-1, 1)
def con_parada(ocultas=8, paso=0.5, paciencia=20, maximo=20000, semilla=0):
r = np.random.default_rng(semilla)
W1 = r.normal(0, 0.1, (E.shape[1], ocultas)); b1 = np.zeros(ocultas)
W2 = r.normal(0, 0.1, (ocultas, 1)); b2 = np.zeros(1)
mejor, guardados, espera, cuando = -1, None, 0, 0
for i in range(maximo + 1):
if i % 100 == 0:
auc = roc_auc_score(y_val, sigmoide(np.tanh(V @ W1 + b1) @ W2 + b2).ravel())
if auc > mejor:
mejor, espera, cuando = auc, 0, i
guardados = (W1.copy(), b1.copy(), W2.copy(), b2.copy())
else:
espera += 1
if espera >= paciencia:
break
h = np.tanh(E @ W1 + b1)
p = sigmoide(h @ W2 + b2)
d2 = (p - obj_ent) / len(obj_ent)
d1 = (d2 @ W2.T) * (1 - h ** 2)
W2 -= paso * (h.T @ d2); b2 -= paso * d2.sum(axis=0)
W1 -= paso * (E.T @ d1); b1 -= paso * d1.sum(axis=0)
W1, b1, W2, b2 = guardados
return cuando, mejor, roc_auc_score(y_te, sigmoide(np.tanh(P @ W1 + b1) @ W2 + b2).ravel())
for ocultas in [4, 8, 16, 32]:
cuando, val, prueba = con_parada(ocultas=ocultas)
print(f'{ocultas:3d} neuronas: paró en la vuelta {cuando:5d} '
f'validación {val:.4f} prueba {prueba:.4f}')
4 neuronas: paró en la vuelta 300 validación 0.7048 prueba 0.7190 8 neuronas: paró en la vuelta 300 validación 0.7061 prueba 0.7176 16 neuronas: paró en la vuelta 200 validación 0.7046 prueba 0.7172 32 neuronas: paró en la vuelta 200 validación 0.7038 prueba 0.7205
Y aquí está el final del capítulo, que a mí me parece precioso 💛
Los cuatro tamaños paran entre la vuelta 200 y la 300, de 20.000 posibles. Y los cuatro sacan entre 0,7172 y 0,7205, o sea prácticamente lo mismo.
Con parada temprana, el tamaño de la red dejó de importar. Cuatro neuronas o treinta y dos, da igual: todas encuentran lo mismo, porque lo mismo es lo único que hay en estos datos.
Y ese "lo mismo" es la solución lineal. Comparado con el 0,7217 de la logística sobre el mismo corte, la red no aporta nada y tampoco pierde nada, siempre que la pares a tiempo 🛑
La paciencia de esa función es lo que hace MLPClassifier
con early_stopping=True, que en el capítulo 1 subió una red de
0,5834 a 0,7018. Ahora ya sabes exactamente qué hace por dentro.
Ejercicios
1. Guardar el mejor y no el último
Compara parar y quedarte con los pesos del momento, contra volver a los mejores que viste.
def sin_guardar(ocultas=8, paso=0.5, paciencia=20, maximo=20000, semilla=0):
r = np.random.default_rng(semilla)
W1 = r.normal(0, 0.1, (E.shape[1], ocultas)); b1 = np.zeros(ocultas)
W2 = r.normal(0, 0.1, (ocultas, 1)); b2 = np.zeros(1)
mejor, espera = -1, 0
for i in range(maximo + 1):
if i % 100 == 0:
auc = roc_auc_score(y_val, sigmoide(np.tanh(V @ W1 + b1) @ W2 + b2).ravel())
if auc > mejor:
mejor, espera = auc, 0
else:
espera += 1
if espera >= paciencia:
break
h = np.tanh(E @ W1 + b1)
p = sigmoide(h @ W2 + b2)
d2 = (p - obj_ent) / len(obj_ent)
d1 = (d2 @ W2.T) * (1 - h ** 2)
W2 -= paso * (h.T @ d2); b2 -= paso * d2.sum(axis=0)
W1 -= paso * (E.T @ d1); b1 -= paso * d1.sum(axis=0)
return roc_auc_score(y_te, sigmoide(np.tanh(P @ W1 + b1) @ W2 + b2).ravel())
print('guardando el mejor :', round(con_parada()[2], 4))
print('quedándote el último:', round(sin_guardar(), 4))
guardando el mejor : 0.7176 quedándote el último: 0.6597
0,7176 guardando el mejor contra 0,6597 quedándote el último. Casi seis centésimas por una línea de código.
Porque con paciencia 20 sigues entrenando 2.000 vueltas después del mejor momento, y en esas 2.000 vueltas la red se sigue estropeando. La parada temprana sin guardar los pesos está a medio hacer 💾
2. Cuánta paciencia conviene
Prueba varias y mira dónde para y qué saca.
for paciencia in [3, 10, 20, 50]:
cuando, val, prueba = con_parada(paciencia=paciencia)
print(f'paciencia {paciencia:3d}: paró en {cuando:5d} '
f'validación {val:.4f} prueba {prueba:.4f}')
paciencia 3: paró en 300 validación 0.7061 prueba 0.7176 paciencia 10: paró en 300 validación 0.7061 prueba 0.7176 paciencia 20: paró en 300 validación 0.7061 prueba 0.7176 paciencia 50: paró en 300 validación 0.7061 prueba 0.7176
Las cuatro paran exactamente en la vuelta 300 y sacan exactamente 0,7176. Da igual cuál pongas, y eso es buena señal.
La paciencia importa cuando la curva de validación es irregular y sube y baja: con poca paciencia paras en el primer bache y te pierdes un pico posterior. Aquí la curva baja limpio desde el máximo, así que cualquier valor la caza 🧘♀️
3. El mismo corte para la logística
Compara la red con parada contra la logística entrenada solo con la parte de entrenamiento.
log_corta = 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_ent, y_ent)
print('red con parada :', round(con_parada(ocultas=32)[2], 4))
print('logística :', round(roc_auc_score(
y_te, log_corta.predict_proba(X_te)[:, 1]), 4))
red con parada : 0.7205 logística : 0.7217
0,7205 la red y 0,7217 la logística, con las dos entrenadas sobre las mismas 1.687 filas. Doce diezmilésimas de diferencia.
Y la logística tarda dos décimas de segundo, se explica leyendo sus 28 coeficientes y no tiene ni un botón que ajustar. Empatar no es ganar 🏁
4. Diez semillas, para saber si el empate es real
Repite con arranques distintos y mira cuánto baila.
resultados = [con_parada(ocultas=8, semilla=s)[2] for s in range(10)]
print('mejor :', round(max(resultados), 4))
print('peor :', round(min(resultados), 4))
print('media :', round(float(np.mean(resultados)), 4))
print('logística:', round(roc_auc_score(y_te, log_corta.predict_proba(X_te)[:, 1]), 4))
mejor : 0.7203 peor : 0.7064 media : 0.7159 logística: 0.7217
Diez arranques distintos y la media queda en 0,7159, con el mejor en 0,7203 y el peor en 0,7064.
O sea que ni con suerte pasa a la logística: su mejor arranque de diez se queda catorce diezmilésimas por debajo del 0,7217.
Es la lección del capítulo 10 del libro de machine learning aplicada aquí. Si me hubiera quedado con el mejor de los diez y lo hubiera reportado como "la red saca 0,7203", habría contado una verdad engañosa: un número suelto no dice nada, hay que ver el rango 🎲
5. La curva de validación, vuelta a vuelta
Imprime la validación cada 100 vueltas para ver la forma que tiene.
r = np.random.default_rng(0)
W1 = r.normal(0, 0.1, (E.shape[1], 8)); b1 = np.zeros(8)
W2 = r.normal(0, 0.1, (8, 1)); b2 = np.zeros(1)
for i in range(1501):
if i % 300 == 0:
v = roc_auc_score(y_val, sigmoide(np.tanh(V @ W1 + b1) @ W2 + b2).ravel())
e = roc_auc_score(y_ent, sigmoide(np.tanh(E @ W1 + b1) @ W2 + b2).ravel())
print(f'vuelta {i:5d} entrena {e:.4f} validación {v:.4f}')
h = np.tanh(E @ W1 + b1)
p = sigmoide(h @ W2 + b2)
d2 = (p - obj_ent) / len(obj_ent)
d1 = (d2 @ W2.T) * (1 - h ** 2)
W2 -= 0.5 * (h.T @ d2); b2 -= 0.5 * d2.sum(axis=0)
W1 -= 0.5 * (E.T @ d1); b1 -= 0.5 * d1.sum(axis=0)
vuelta 0 entrena 0.4041 validación 0.3888 vuelta 300 entrena 0.7280 validación 0.7061 vuelta 600 entrena 0.7497 validación 0.6997 vuelta 900 entrena 0.7680 validación 0.6930 vuelta 1200 entrena 0.7845 validación 0.6801 vuelta 1500 entrena 0.7991 validación 0.6685
La validación sube hasta la vuelta 300 y a partir de ahí baja, mientras el entrenamiento sigue subiendo tan contento.
Esa forma de U al revés es la más importante de todo el deep learning práctico. El punto más alto de la curva de validación es dónde hay que parar, y todo lo que hay a la derecha es tiempo de cómputo gastado en empeorar el modelo 📈
6. Cuántos pesos contra cuántas filas
Pon los dos números al lado para cada tamaño.
for ocultas in [4, 8, 16, 32]:
pesos = E.shape[1] * ocultas + ocultas + ocultas + 1
print(f'{ocultas:3d} neuronas: {pesos:4d} pesos para {len(y_ent)} filas '
f'({pesos / len(y_ent):.3f} por fila)')
4 neuronas: 121 pesos para 1687 filas (0.072 por fila) 8 neuronas: 241 pesos para 1687 filas (0.143 por fila) 16 neuronas: 481 pesos para 1687 filas (0.285 por fila) 32 neuronas: 961 pesos para 1687 filas (0.570 por fila)
Con 32 neuronas hay 961 pesos para 1.687 filas, o sea 0,570 pesos por fila. Todavía no llega a uno por fila, y aun así memoriza hasta el 1,0000.
Eso desmonta la regla de oro que se repite mucho, la de "necesitas diez veces más filas que parámetros". No es falsa del todo, pero es muchísimo más optimista de lo que la realidad aguanta 📏
7. Entrenar sin mirar nada
Corre la red 30.000 vueltas sin validación y mira el desastre.
print('10.000 vueltas a ciegas, 32 neuronas:', round(
solo_prueba(ocultas=32, vueltas=10000), 4))
print('con parada temprana :', round(con_parada(ocultas=32)[2], 4))
10.000 vueltas a ciegas, 32 neuronas: 0.587 con parada temprana : 0.7205
0,5870 a ciegas contra 0,7205 con parada. Trece puntos de AUC entre correr el bucle y vigilarlo.
Y el que corrió a ciegas hizo diez mil vueltas donde el otro hizo doscientas, o sea que tardó cincuenta veces más. Es de los pocos casos donde hacerlo bien es además más rápido 🏃♀️
8. El error de validar con lo que entrenaste
Pásale a con_parada el conjunto de
entrenamiento como validación.
V_mal = E roc_auc_score(y_val, sigmoide(np.tanh(V_mal @ W1 + b1) @ W2 + b2).ravel())
ValueError: Found input variables with inconsistent numbers of samples: [563, 1687]
Revienta porque y_val tiene 563 filas y E tiene
1.687. Los tamaños no cuadran y scikit-learn lo dice claro.
Menos mal, porque si hubieran cuadrado (que pasa si te confundes de variable entre dos cortes del mismo tamaño) no habría dado error: la validación sobre datos de entrenamiento nunca baja, así que la parada temprana no pararía jamás y volverías a las 20.000 vueltas 😬
Comprueba que lo tienes
La curva de entrenamiento sigue subiendo y la de validación empezó a bajar. ¿Qué haces?
- Paro ahí y me quedo con los pesos de antes de que empezara a bajar
- Sigo, porque el entrenamiento todavía mejora
- Añado más neuronas
- Bajo la tasa de aprendizaje
Lo que te llevas
- 👀 Se entrena vigilando dos números: cómo va en lo que ve y cómo va en lo que no.
- 📉 A partir de la vuelta 500 esta red empeora activamente mientras el número que estabas mirando sube.
- 😱 Con 32 neuronas llega a 1,0000 de AUC en entrenamiento y 0,5870 en prueba.
- 🎚️ Los tres botones (neuronas, paso, vueltas) empeoran al subirlos. El mejor resultado sale del ajuste más conservador.
- 🛑 Con parada temprana los cuatro tamaños paran entre la vuelta 200 y la 300 y sacan lo mismo: el tamaño deja de importar.
- 💾 Hay que guardar los mejores pesos, no quedarse los últimos: 0,7176 contra 0,6597.
- 🏁 Y aun bien parada, la red empata con la logística (0,7205 contra 0,7217), que tarda dos décimas de segundo y se lee entera.
En el capítulo 8 nos dedicamos entero a lo que aquí salió por todas partes: el sobreajuste, y las tres formas de frenarlo.
Que tengas lindo día! 🌸