Llevamos nueve capítulos diciendo "el AUC es 0,7214" 📐
Ese número salió de partir los datos una vez, con
random_state=42. Hoy vamos a ver qué habría pasado con otra semilla,
y la respuesta incomoda un poquito.
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 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']
def arma(clasificador=None):
pre = ColumnTransformer([
('num', Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]), NUMERICAS),
('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
])
if clasificador is None:
clasificador = LogisticRegression(max_iter=1000, random_state=42)
return Pipeline([('pre', pre), ('mod', clasificador)])
datos = prepara(carga_limpia(URL))
X = datos[NUMERICAS + CATEGORICAS]
y = datos['compro']
print(X.shape)
(3000, 13)
El mismo modelo, diez veces
aucs = []
for semilla in range(10):
X_a, X_b, y_a, y_b = train_test_split(X, y, test_size=0.25,
random_state=semilla, stratify=y)
m = arma().fit(X_a, y_a)
aucs.append(roc_auc_score(y_b, m.predict_proba(X_b)[:, 1]))
print(np.round(aucs, 4))
print('mínimo', round(min(aucs), 4), '| máximo', round(max(aucs), 4),
'| media', round(np.mean(aucs), 4))
[0.7105 0.7091 0.687 0.7046 0.7419 0.7017 0.6976 0.7124 0.707 0.7152] mínimo 0.687 | máximo 0.7419 | media 0.7087
De 0,6870 a 0,7419. Cinco puntos y medio de diferencia, y lo único que cambió fue qué filas cayeron en cada lado 😳
Y aquí va la parte incómoda: nuestro 0,7214 de siempre está por encima de la media (0,7087). O sea que el 42 nos tocó bueno.
Esto es lo que pasa cuando alguien prueba cinco semillas y reporta la mejor. No hace falta mala intención: basta con parar de probar cuando sale un número bonito 🎲
Validación cruzada, que es la respuesta
entrenas K veces dejando fuera un trozo distinto cada vez y promedias, para que la nota no dependa de qué filas cayeron en el test
En vez de partir una vez, se parte cinco: cada trozo hace de examen una vez y de entrenamiento cuatro. Así todas las filas se usan para las dos cosas y el resultado no depende de la suerte.
from sklearn.model_selection import StratifiedKFold, cross_val_score
particion = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
notas = cross_val_score(arma(), X, y, cv=particion, scoring='roc_auc')
print(np.round(notas, 4))
print('media:', round(notas.mean(), 4), '± desviación:', round(notas.std(), 4))
[0.6957 0.7009 0.692 0.7104 0.7291] media: 0.7056 ± desviación: 0.0133
0,7056 ± 0,0133. Ese es el número que hay que reportar, y con la desviación al lado.
Y la desviación no es decoración: dice que una diferencia de menos de un punto entre dos modelos no significa nada. Si un modelo saca 0,706 y otro 0,712, están empatados 📐
El Stratified importa: reparte manteniendo la proporción de la
clase positiva en cada trozo. Sin él, con clases desbalanceadas, algún trozo se
puede quedar casi sin positivos.
La curva de aprendizaje
Antes de tocar hiperparámetros, hay una pregunta que sale más barata: ¿me faltan datos o me falta modelo?
from sklearn.model_selection import learning_curve
tam, train, validacion = learning_curve(
arma(), X, y, train_sizes=[0.1, 0.3, 0.5, 0.75, 1.0],
cv=particion, scoring='roc_auc', n_jobs=-1)
for n, a, b in zip(tam, train.mean(axis=1), validacion.mean(axis=1)):
print(f'{n:5d} filas train {a:.4f} validación {b:.4f} hueco {a - b:.4f}')
240 filas train 0.8049 validación 0.6672 hueco 0.1378 720 filas train 0.7369 validación 0.6956 hueco 0.0413 1200 filas train 0.7191 validación 0.7015 hueco 0.0176 1800 filas train 0.7196 validación 0.7041 hueco 0.0155 2400 filas train 0.7223 validación 0.7056 hueco 0.0167
Esa tabla se lee así, y es una de las herramientas más infravaloradas que existen:
- 📉 El hueco se cierra, de 0,1378 con 240 filas a 0,0167 con 2.400. O sea que el sobreajuste desapareció al tener datos.
- 📈 La validación sigue subiendo, aunque despacito: de 0,6672 a 0,7056. Todavía no se aplanó del todo.
La conclusión práctica: más datos ayudarían un poquito, y cambiar de modelo casi nada. Si la curva de validación se hubiera aplanado, más datos no servirían de nada y habría que buscar columnas nuevas.
Es la diferencia entre pedir presupuesto para "más datos" y pedirlo para "más tiempo de análisis", y contestarla con una tabla en vez de con una opinión 💼
Buscar hiperparámetros sin engañarte
Un hiperparámetro es una decisión tuya, no algo que el modelo aprenda. El
max_depth del árbol, el k de los vecinos, el
C de la logística.
Probarlos a mano es lento y peligroso: si pruebas veinte y te quedas con el mejor en el examen, el examen dejó de ser examen.
from sklearn.model_selection import GridSearchCV
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
random_state=42, stratify=y)
rejilla = GridSearchCV(arma(), {'mod__C': [0.01, 0.1, 1, 10, 100]},
cv=particion, scoring='roc_auc', n_jobs=-1)
rejilla.fit(X_tr, y_tr)
print('mejor:', rejilla.best_params_)
print('CV :', round(rejilla.best_score_, 4))
print('test :', round(roc_auc_score(y_te, rejilla.predict_proba(X_te)[:, 1]), 4))
mejor: {'mod__C': 0.1}
CV : 0.7
test : 0.721
Fíjate en el mod__C con doble guion bajo: es la forma de decirle
"el parámetro C del paso que se llama mod". Con esa
sintaxis puedes ajustar también la preparación, por ejemplo la estrategia del
imputador.
Y ahora mira los cinco resultados:
for p, s in zip(rejilla.cv_results_['params'], rejilla.cv_results_['mean_test_score']):
print(p, round(s, 4))
{'mod__C': 0.01} 0.6989
{'mod__C': 0.1} 0.7
{'mod__C': 1} 0.6997
{'mod__C': 10} 0.6995
{'mod__C': 100} 0.6995
De 0,6989 a 0,7000. Once diezmilésimas entre el mejor y el peor, cuando la desviación de la validación cruzada era 0,0133.
Traducido: ajustar la logística en este problema no sirvió de nada. Y eso también es un resultado, y de los que ahorran semanas 😅
Es lo contrario de lo que se suele contar. El orden de rentabilidad casi siempre es: primero limpiar (capítulo 4), después construir columnas (capítulo 9), después el umbral (capítulo 15), y solo al final los hiperparámetros.
Qué es ese C, que es Ridge y que es Lasso
le sumas al error un castigo por tener pesos grandes, y la versión con valor absoluto además los empuja hasta cero, o sea que selecciona columnas
Llevamos dos capítulos ajustando C sin decir qué es. Es el freno
de la regresión logística, y merece su propia sección porque es el
hiperparámetro que más te vas a encontrar.
Al entrenar, el modelo busca los pesos que mejor separan. Si lo dejas suelto, los estira todo lo que haga falta para acertar en entrenamiento, que es justamente memorizar. La regularización le añade a la cuenta un castigo por tener pesos grandes, y ahí hay dos formas de castigar:
- 📏 Ridge, o penalización L2, castiga la suma de los pesos
al cuadrado. Los encoge a todos y no anula ninguno. Es la que trae
LogisticRegressionpuesta de fábrica. - ✂️ Lasso, o penalización L1, castiga la suma de los valores absolutos. Esta sí pone pesos en cero, o sea que elige columnas mientras entrena.
Y C es el freno al revés: C chico castiga más.
Es de las convenciones más incómodas de scikit-learn y no hay que darle vueltas,
solo recordarla.
from sklearn.linear_model import LogisticRegression
print(f'{"C":<8}{"Ridge":<9}{"Lasso":<9}pesos en cero')
for C in [0.001, 0.01, 0.1, 1.0, 10.0]:
ridge = arma(LogisticRegression(max_iter=1000, C=C, random_state=42)).fit(X_tr, y_tr)
lasso = arma(LogisticRegression(max_iter=1000, C=C, penalty='l1',
solver='liblinear', random_state=42)).fit(X_tr, y_tr)
ceros = (lasso.named_steps['mod'].coef_[0] == 0).sum()
print(f'{C:<8}'
f'{roc_auc_score(y_te, ridge.predict_proba(X_te)[:, 1]):<9.4f}'
f'{roc_auc_score(y_te, lasso.predict_proba(X_te)[:, 1]):<9.4f}'
f'{ceros} de 28')
C Ridge Lasso pesos en cero 0.001 0.7145 0.5000 28 de 28 0.01 0.7175 0.6813 24 de 28 0.1 0.7210 0.7229 14 de 28 1.0 0.7214 0.7224 6 de 28 10.0 0.7217 0.7217 4 de 28
Aquí hay tres cosas y las tres valen 👀
Ridge apenas se mueve, de 0,7145 a 0,7217 en un rango de C que va de mil a una diezmilésima. Con trece columnas hay poco que frenar.
Lasso con C=0,001 pone los 28 pesos en cero y saca 0,5000 exacto, que es tirar una moneda. Es la demostración más limpia que conozco de qué hace el castigo: apretado del todo, el modelo se queda sin nada que decir.
Y con C=0,1, Lasso anula 14 de 28 pesos y saca 0,7229, que es mejor que el modelo completo. La mitad de las columnas sobraba, y esto lo descubrió solo mientras entrenaba 🎉
Cuando C sube, los ceros bajan: 24, luego 14, luego 6, luego 4. El freno se suelta y el modelo se permite usar más columnas.
Miremos cuáles sobreviven cuando apretamos fuerte:
fuerte = arma(LogisticRegression(max_iter=1000, C=0.05, penalty='l1',
solver='liblinear', random_state=42)).fit(X_tr, y_tr)
nombres = fuerte.named_steps['pre'].get_feature_names_out()
pesos = fuerte.named_steps['mod'].coef_[0]
for i in np.argsort(-np.abs(pesos))[:6]:
print(f'{nombres[i]:32} {pesos[i]:+.4f}')
cat__segmento_Bodega -0.5157 num__satisfaccion +0.3564 num__sin_compra_previa -0.3158 num__monto +0.2366 cat__canal_Marketplace -0.2007 cat__canal_WhatsApp +0.1191
Segmento, satisfacción, sin_compra_previa y canal están las cuatro ahí, que son exactamente las cuatro que la importancia por permutación va a encontrar en el capítulo 19, por un camino completamente distinto.
Cuando dos métodos que no se parecen en nada te señalan las mismas columnas, eso ya no es casualidad 💪
Se cuela el monto en cuarto lugar, que allá salía quinto y flojito. Y tiene explicación: Lasso mira el tamaño del peso y la permutación mide cuánto AUC se pierde al revolver la columna. No son lo mismo, y el monto es el caso donde se separan.
Y para elegir entre las dos: Ridge por defecto, porque casi siempre va igual o mejor y no te quita nada. Lasso cuando tienes muchas columnas y quieres que el modelo elija, que es su gracia de verdad.
El bosque, con su rejilla
from sklearn.ensemble import RandomForestClassifier
rejilla_bosque = GridSearchCV(
arma(RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)),
{'mod__max_depth': [3, 5, 8, None], 'mod__min_samples_leaf': [1, 10, 30]},
cv=particion, scoring='roc_auc', n_jobs=-1)
rejilla_bosque.fit(X_tr, y_tr)
print('mejor:', rejilla_bosque.best_params_)
print('CV :', round(rejilla_bosque.best_score_, 4))
print('test :', round(roc_auc_score(y_te, rejilla_bosque.predict_proba(X_te)[:, 1]), 4))
mejor: {'mod__max_depth': 8, 'mod__min_samples_leaf': 10}
CV : 0.693
test : 0.711
Doce combinaciones, y el mejor bosque saca 0,693 en validación cruzada contra el 0,7056 de la logística sin ajustar.
El modelo complicado, ajustado, sigue perdiendo contra el simple sin ajustar. Es el capítulo 13 confirmado con el método correcto esta vez 🥇
Ejercicios
Siete. Intenta antes de abrir 💛
1. Validación cruzada por cliente
Del capítulo 12: repite la validación cruzada sin que un cliente caiga en los dos lados.
from sklearn.model_selection import GroupKFold
por_cliente = cross_val_score(arma(), X, y, groups=datos['cliente_id'],
cv=GroupKFold(n_splits=5), scoring='roc_auc')
print('por cliente:', np.round(por_cliente, 4), 'media', round(por_cliente.mean(), 4))
print('normal :', round(notas.mean(), 4))
por cliente: [0.7319 0.6732 0.7241 0.7283 0.6919] media 0.7099 normal : 0.7056
0,7099 contra 0,7056: prácticamente lo mismo, e incluso un poquito más alto.
Y esto matiza el capítulo 12, donde con una sola partición la diferencia salía de casi cuatro puntos. Con cinco particiones, esa diferencia se disuelve: era ruido de una partición concreta, no un efecto.
Me parece importante dejarlo escrito así, porque es exactamente el error que este capítulo enseña a no cometer 🎲
2. Cuántos trozos
Compara 3, 5 y 10 particiones.
for k in (3, 5, 10):
n = cross_val_score(arma(), X, y, scoring='roc_auc',
cv=StratifiedKFold(k, shuffle=True, random_state=42))
print(f'{k:2d} trozos media {n.mean():.4f} desviación {n.std():.4f}')
3 trozos media 0.7056 desviación 0.0085 5 trozos media 0.7056 desviación 0.0133 10 trozos media 0.7081 desviación 0.0188
Las medias se parecen y la desviación sube con más trozos, porque cada examen tiene menos filas y es más variable.
Cinco es el estándar y está bien. Diez cuando tienes pocos datos y quieres entrenar con más en cada vuelta; tres cuando el modelo tarda mucho ⏱️
3. Ajustar también la preparación
Deja que la búsqueda elija cómo rellenar los huecos.
rej3 = GridSearchCV(
arma(),
{'pre__num__r__strategy': ['median', 'mean', 'most_frequent'],
'mod__C': [0.1, 1]},
cv=particion, scoring='roc_auc', n_jobs=-1)
rej3.fit(X_tr, y_tr)
print('mejor:', rej3.best_params_)
print('CV :', round(rej3.best_score_, 4))
mejor: {'mod__C': 0.1, 'pre__num__r__strategy': 'most_frequent'}
CV : 0.7001
Ese pre__num__r__strategy se lee de fuera hacia dentro: el paso
pre, dentro el bloque num, dentro el paso
r, y su parámetro strategy.
Esto es lo que el pipeline del capítulo 11 hace posible. Sin él, probar tres formas de imputar dentro de una validación cruzada honesta es imposible de escribir 🔧
4. Búsqueda al azar cuando la rejilla es grande
Con muchos parámetros, probarlos todos no cabe. Prueba 10 combinaciones al azar.
from sklearn.model_selection import RandomizedSearchCV
rej4 = RandomizedSearchCV(
arma(RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)),
{'mod__max_depth': [3, 5, 8, 12, None],
'mod__min_samples_leaf': [1, 5, 10, 20, 30],
'mod__max_features': ['sqrt', 'log2', 0.5]},
n_iter=10, cv=particion, scoring='roc_auc', random_state=42, n_jobs=-1)
rej4.fit(X_tr, y_tr)
print('probó 10 de', 5 * 5 * 3, 'combinaciones')
print('mejor CV:', round(rej4.best_score_, 4))
probó 10 de 75 combinaciones mejor CV: 0.692
Diez de setenta y cinco, y llega casi al mismo sitio que la rejilla completa. Con muchos parámetros la búsqueda al azar es mejor idea que la exhaustiva, porque casi siempre solo unos pocos parámetros importan y al azar los explora más 🎲
5. El error de buscar y medir en el mismo sitio
Compara el mejor resultado de la búsqueda con el resultado en el examen de verdad.
print('lo que dice la búsqueda:', round(rejilla_bosque.best_score_, 4))
print('lo que da en el examen :', round(roc_auc_score(y_te,
rejilla_bosque.predict_proba(X_te)[:, 1]), 4))
lo que dice la búsqueda: 0.693 lo que da en el examen : 0.711
Aquí el examen sale mejor que la búsqueda (0,711 contra 0,693), y eso es normal: la búsqueda entrena con cuatro quintos de los datos y el modelo final con todos.
Lo que hay que vigilar es el caso contrario. Si el examen sale bastante peor que la búsqueda, probaste demasiadas combinaciones y una de ellas acertó por casualidad. Con doce como aquí no pasa; con quinientas, sí 🎯
6. El error del doble guion bajo
Pásale a la rejilla un parámetro escrito sin el prefijo del paso.
GridSearchCV(arma(), {'C': [0.1, 1]}, cv=3).fit(X_tr, y_tr)
ValueError: Invalid parameter 'C' for estimator Pipeline(steps=[('pre',
ColumnTransformer(transformers=[('num',
Pipeline(steps=[('r',
SimpleImputer(strategy='median')),
('e',
StandardScaler())]),
['unidades', 'monto',
'descuento', 'satisfaccion',
'precio_unitario',
'sin_compra_previa',
'sin_descuento',
'sin_satisfaccion',
'visita_numero']),
('cat',
Pipeline(steps=[('r',
SimpleImputer(strategy='most_frequent')),
('c',
OneHotEncoder(handle_unknown='ignore'))]),
['ciudad', 'segmento',
'canal', 'categoria'])])),
('mod', LogisticRegression(max_iter=1000, random_state=42))]). Valid parameters are: ['memory', 'steps', 'transform_input', 'verbose'].
Y el mensaje es de los buenos: te dice que C no es un parámetro
del pipeline y encima te lista los que sí valen.
Cuando no te acuerdes de cómo se llama algo, arma().get_params()
te devuelve la lista completa 🔑
7. El resultado final, escrito como se debe
Junta todo lo del capítulo en el párrafo que iría en el informe.
final = cross_val_score(arma(), X, y, cv=particion, scoring='roc_auc')
print(f'AUC {final.mean():.4f} ± {final.std():.4f} '
f'(validación cruzada de 5, {len(X)} filas)')
print(f'rango de las 5: {final.min():.4f} a {final.max():.4f}')
print(f'una sola partición habría dado entre {min(aucs):.4f} y {max(aucs):.4f}')
AUC 0.7056 ± 0.0133 (validación cruzada de 5, 3000 filas) rango de las 5: 0.6920 a 0.7291 una sola partición habría dado entre 0.6870 y 0.7419
Esas tres líneas son lo que separa un número de un resultado.
Cualquiera que lea eso sabe cuánto puede confiar, y sobre todo sabe que una mejora de dos décimas no es una mejora. Es la frase que más discusiones ahorra en una reunión de datos 💛
Comprueba que lo tienes
El mismo modelo da AUC de 0,66 a 0,74 según cómo partas los datos. ¿Cuál reportas?
- El promedio de varias particiones, con su variación al lado
- El mejor, porque demuestra de lo que es capaz
- El peor, para ser conservador
- El de la partición con random_state=42
Lo que te llevas
- 🎲 Diez semillas dan de 0,6870 a 0,7419. Un solo
train_test_splitno es un resultado. - 📐 La validación cruzada da 0,7056 ± 0,0133, y la desviación se reporta siempre.
- 📊 Una diferencia menor que la desviación no es una diferencia.
- 📈 La curva de aprendizaje contesta si faltan datos o falta modelo. Aquí el hueco se cierra de 0,1377 a 0,0167 y la validación todavía sube.
- 🔧 Ajustar la logística movió once diezmilésimas. Limpiar, construir columnas y elegir el umbral rinden mucho más.
- 🥇 El bosque ajustado (0,693) sigue perdiendo contra la logística sin ajustar (0,7056).
- 🔍 Con cinco particiones, la diferencia entre partir por fila y por cliente se disuelve: era ruido de una partición.
En el capítulo 18 vamos a la clase rara: qué hacer cuando lo que te importa pasa el 5% de las veces.
Que tengas lindo día! 🌸