Hasta aquí hemos usado un solo modelo, la regresión logística, porque hacía falta uno para poder hablar de lo demás. Hoy probamos ocho 🎰
Y te adelanto el final, porque es lo interesante del capítulo: gana la regresión logística, que es el más simple y el más viejo de todos. Los que salen en las noticias quedan por debajo.
Esto pasa mucho más de lo que te cuentan, y saber por qué es más útil que saberse los ocho algoritmos.
El punto de partida
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):
"""El pipeline del capítulo 11 con el modelo que le pases."""
pre = ColumnTransformer([
('num', Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]), NUMERICAS),
('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
])
return Pipeline([('pre', pre), ('mod', clasificador)])
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)
print(X_tr.shape, X_te.shape)
(2250, 13) (750, 13)
1. Regresión logística: la que hay que entender
el modelo suma las columnas con un peso cada una y esa suma la aplasta entre 0 y 1 para que se pueda leer como probabilidad
Le da un peso a cada columna, los suma y convierte esa suma en una probabilidad. Nada más. Es de 1958 y sigue ganando.
logistica = arma(LogisticRegression(max_iter=1000, random_state=42)).fit(X_tr, y_tr)
print('exactitud:', round(logistica.score(X_te, y_te), 4))
print('AUC :', round(roc_auc_score(y_te, logistica.predict_proba(X_te)[:, 1]), 4))
exactitud: 0.6733 AUC : 0.7214
Sus dos ventajas son enormes y no se ven en el número: se puede explicar (cada peso dice cuánto suma o resta cada columna) y no se rompe con datos que no vio.
Su límite: solo sabe sumar. No puede aprender que "WhatsApp funciona solo para mayoristas", que en el capítulo 4 vimos que pasa. Esas interacciones hay que dárselas escritas.
2. Árbol de decisión: el que se lee
cuánto se mezclan las clases dentro de una hoja: cero si todas son iguales y máximo si están mitad y mitad
from sklearn.tree import DecisionTreeClassifier
arbol = arma(DecisionTreeClassifier(max_depth=4, random_state=42)).fit(X_tr, y_tr)
print('exactitud:', round(arbol.score(X_te, y_te), 4))
print('AUC :', round(roc_auc_score(y_te, arbol.predict_proba(X_te)[:, 1]), 4))
exactitud: 0.6413 AUC : 0.6643
Peor que la logística. Un árbol solo hace cortes rectos ("monto mayor que 532") y con cuatro niveles se queda corto.
Lo que sí tiene es que se puede dibujar y enseñar en una reunión, y eso a veces vale más que cinco puntos de AUC 🌳
3. El mismo árbol sin límite: la lección del capítulo
lo mismo medido en bits, o sea cuántas preguntas de sí o no harían falta para adivinar la clase de una fila
libre = arma(DecisionTreeClassifier(random_state=42)).fit(X_tr, y_tr)
print('en train:', round(libre.score(X_tr, y_tr), 4))
print('en test :', round(libre.score(X_te, y_te), 4))
print('AUC :', round(roc_auc_score(y_te, libre.predict_proba(X_te)[:, 1]), 4))
en train: 1.0 en test : 0.5613 AUC : 0.5525
1,0 en entrenamiento y 0,5613 en examen. Cien por ciento perfecto en lo que ya vio, y peor que el listón en lo que no 😱
Eso es el sobreajuste, el overfitting, en su forma más pura: el árbol creció hasta que cada hoja tenía una sola venta, o sea que memorizó las 2.250 filas una por una. Un diccionario, no un modelo.
Guárdate el par de números. 1,0 en train es siempre una alarma, nunca un logro.
4. Bosque aleatorio: muchos árboles votando
from sklearn.ensemble import RandomForestClassifier
bosque = arma(RandomForestClassifier(n_estimators=300, random_state=42,
n_jobs=-1)).fit(X_tr, y_tr)
print('en train:', round(bosque.score(X_tr, y_tr), 4))
print('AUC :', round(roc_auc_score(y_te, bosque.predict_proba(X_te)[:, 1]), 4))
en train: 1.0 AUC : 0.677
0,677. Mejor que el árbol solo (0,6643) y todavía por debajo de la logística (0,7214).
La idea del bosque es preciosa: entrena 300 árboles, cada uno con una muestra distinta de filas y de columnas, y los hace votar. Los errores de uno los corrigen los otros.
Pero fíjate en el train: 1,0 otra vez. Los
árboles siguen memorizando por dentro; lo que pasa es que el promedio de 300
memorizaciones distintas generaliza algo mejor. Y por defecto scikit-learn los
deja crecer sin límite 😬
podado = arma(RandomForestClassifier(n_estimators=300, max_depth=5,
random_state=42, n_jobs=-1)).fit(X_tr, y_tr)
print('en train:', round(podado.score(X_tr, y_tr), 4))
print('AUC :', round(roc_auc_score(y_te, podado.predict_proba(X_te)[:, 1]), 4))
en train: 0.704 AUC : 0.7115
Limitando la profundidad a 5: el train baja a 0,704 y el AUC sube a 0,7115. Casi cuatro puntos ganados por poner un freno.
Es de las cosas más rentables que existen y casi nadie la hace, porque
RandomForestClassifier() a secas ya "funciona" 🌲
5 y 6. Boosting: los árboles que se corrigen
el error se parte en tres, y bajar uno de los dos primeros normalmente sube el otro, mientras el tercero no se puede tocar por más modelo que le eches
from sklearn.ensemble import GradientBoostingClassifier, HistGradientBoostingClassifier
boosting = arma(GradientBoostingClassifier(random_state=42)).fit(X_tr, y_tr)
print('boosting :', round(roc_auc_score(y_te, boosting.predict_proba(X_te)[:, 1]), 4))
rapido = arma(HistGradientBoostingClassifier(random_state=42)).fit(X_tr, y_tr)
print('hist boosting:', round(roc_auc_score(y_te, rapido.predict_proba(X_te)[:, 1]), 4))
boosting : 0.6876 hist boosting: 0.6799
0,6876 y 0,6799. Los dos por debajo de la logística.
El boosting es distinto del bosque: en vez de 300 árboles independientes, entrena uno detrás de otro y cada uno se dedica a los casos que falló el anterior. Suele ser lo que gana las competencias con datos de tabla, y aquí no gana. Ya llegaremos a por qué.
HistGradientBoosting es la versión rápida, la que compite con
XGBoost y LightGBM, y además acepta nulos sin imputar, que es
comodísimo.
7 y 8. Los otros dos que hay que conocer
from sklearn.neighbors import KNeighborsClassifier
from sklearn.naive_bayes import GaussianNB
vecinos = arma(KNeighborsClassifier(n_neighbors=25)).fit(X_tr, y_tr)
print('vecinos :', round(roc_auc_score(y_te, vecinos.predict_proba(X_te)[:, 1]), 4))
bayes = arma(GaussianNB()).fit(X_tr, y_tr)
print('naive bayes:', round(roc_auc_score(y_te, bayes.predict_proba(X_te)[:, 1]), 4))
vecinos : 0.6766 naive bayes: 0.7145
Vecinos no aprende nada: guarda todas las filas y, cuando le preguntas, busca las 25 más parecidas y vota. Es lentísimo con datos grandes y necesita sí o sí que las columnas estén escaladas, cosa que nuestro pipeline ya hace.
Naive Bayes saca 0,7145, que es el segundo mejor de los ocho. Y es el más simple de todos: asume que las columnas son independientes entre sí, cosa que es falsa siempre, y aun así funciona. Se llama "naive" (ingenuo) por eso 🤓
La tabla completa
| Modelo | AUC | Train | Para qué sirve |
|---|---|---|---|
| Regresión logística | 0,7214 | 0,6658 | Casi siempre el primero. Se explica y no se rompe |
| Naive Bayes | 0,7145 | 0,6480 | Rapidísimo, buen listón, muy usado en texto |
| Bosque podado | 0,7115 | 0,7040 | Cuando hay interacciones y no linealidad |
| Boosting | 0,6876 | 0,7493 | Suele ganar con más datos y con ajuste |
| SVM | 0,6826 | 0,7320 | Pocos datos y muchas columnas |
| Hist boosting | 0,6799 | 0,9387 | Como el boosting pero rápido y aguanta nulos |
| Bosque sin podar | 0,6770 | 1,0000 | Lo de arriba, mal configurado |
| Vecinos | 0,6766 | 0,6787 | Didáctico. En producción casi nunca |
| Árbol d=4 | 0,6643 | 0,6658 | Cuando hay que dibujarlo en una reunión |
| Árbol sin límite | 0,5525 | 1,0000 | Nada. Es el ejemplo de qué no hacer |
Mira la columna train de arriba abajo: los que están arriba
tienen train parecido al test, y los que están abajo lo tienen altísimo.
Esa columna predice el orden mejor que el nombre del algoritmo 📐
Por qué gana la simple, que es lo que hay que entender
Los modelos complicados ganan cuando hay algo complicado que aprender. Aquí no lo hay, y por tres razones concretas:
- 📉 Poca señal. El mejor AUC del archivo es 0,72. Cuando la señal es débil, lo que un modelo flexible encuentra de más suele ser ruido.
- 📊 Pocas filas. 2.250 para entrenar es poco. El boosting y los bosques empiezan a brillar con decenas de miles.
- ➕ Relaciones simples. Del capítulo 4: más monto, más cierre; más satisfacción, más cierre. Todo monótono, que es justo lo que una recta hace bien.
Y la regla que yo sigo: empieza siempre por la logística, y solo cambia si otro modelo le gana de verdad. Con "de verdad" quiero decir en validación cruzada, que es el capítulo 16, y no en una sola partición 💛
Ejercicios
Siete. Intenta antes de abrir 💛
1. El SVM, sin la trampa de las probabilidades
Entrena una máquina de vectores de soporte y saca su AUC sin pedirle probabilidades.
from sklearn.svm import SVC
svm = arma(SVC(random_state=42)).fit(X_tr, y_tr)
print('exactitud:', round(svm.score(X_te, y_te), 4))
print('AUC :', round(roc_auc_score(y_te, svm.decision_function(X_te)), 4))
exactitud: 0.644 AUC : 0.6826
Fíjate en decision_function en vez de
predict_proba: el SVM no da probabilidades de forma natural, da una
distancia a la frontera. Para el AUC da igual, porque el AUC solo mira el
orden 📐
Pedirle probabilidades con probability=True lo hace muchísimo más
lento y además está en camino de desaparecer de scikit-learn.
2. Cuántos vecinos
Prueba k = 1, 5, 25 y 100 y mira el patrón.
for k in (1, 5, 25, 100):
m = arma(KNeighborsClassifier(n_neighbors=k)).fit(X_tr, y_tr)
print(f'k={k:3} train {m.score(X_tr, y_tr):.4f} '
f'AUC {roc_auc_score(y_te, m.predict_proba(X_te)[:, 1]):.4f}')
k= 1 train 1.0000 AUC 0.5658 k= 5 train 0.7289 AUC 0.6201 k= 25 train 0.6787 AUC 0.6766 k=100 train 0.6520 AUC 0.6985
Con k=1 el train da 1,0 (cada fila es su propio vecino más cercano) y el AUC se hunde. Al subir k, el train baja y el AUC sube.
Ese k es el ejemplo más limpio que existe del equilibrio entre
memorizar y generalizar: es literalmente un dial 🎚️
3. Qué columnas usa el bosque
Las cinco columnas más importantes según el bosque podado.
import numpy as np
nombres = podado.named_steps['pre'].get_feature_names_out()
peso = podado.named_steps['mod'].feature_importances_
for i in np.argsort(peso)[::-1][:5]:
print(f'{nombres[i]:30} {peso[i]:.4f}')
num__monto 0.1872 num__satisfaccion 0.1281 num__precio_unitario 0.1193 cat__segmento_Bodega 0.1046 num__sin_compra_previa 0.0894
Ojo con esta lista, que engaña: la importancia de un bosque favorece a las columnas con muchos valores distintos, porque ofrecen más sitios donde cortar.
Aquí se ve: el monto se lleva 0,1872 y sin_compra_previa, que en
el capítulo 4 medimos en veinte puntos de tasa de cierre, se queda en 0,0894, la
mitad. No es que valga la mitad: es que es una columna de dos valores y solo
ofrece un sitio donde cortar.
La forma honesta de medir importancia es otra y está en el capítulo 19 🔍
4. El árbol, dibujado en palabras
Enseña las primeras reglas del árbol de profundidad 3.
from sklearn.tree import export_text corto = arma(DecisionTreeClassifier(max_depth=3, random_state=42)).fit(X_tr, y_tr) nombres3 = corto.named_steps['pre'].get_feature_names_out() print(export_text(corto.named_steps['mod'], feature_names=list(nombres3))[:700])
|--- num__monto <= -0.64 | |--- num__satisfaccion <= 1.09 | | |--- num__sin_compra_previa <= 0.83 | | | |--- class: 0 | | |--- num__sin_compra_previa > 0.83 | | | |--- class: 0 | |--- num__satisfaccion > 1.09 | | |--- num__precio_unitario <= -0.46 | | | |--- class: 0 | | |--- num__precio_unitario > -0.46 | | | |--- class: 1 |--- num__monto > -0.64 | |--- num__sin_compra_previa <= 0.83 | | |--- num__satisfaccion <= 0.36 | | | |--- class: 1 | | |--- num__satisfaccion > 0.36 | | | |--- class: 1 | |--- num__sin_compra_previa > 0.83 | | |--- num__visita_numero <= -0.43 | | | |--- class: 1 | | |--- num__visita_
Eso se puede pegar en un correo y lo entiende cualquiera. Es la razón por la que los árboles sobreviven: en algunos sitios, poder explicar la regla vale más que acertar más.
Un banco que rechaza un crédito tiene que decir por qué. Con esto puede; con un bosque de 300 árboles, no 🏦
5. El boosting, cuánto le importa la tasa de aprendizaje
Prueba tres tasas y mira el sobreajuste.
for tasa in (0.01, 0.1, 0.5):
m = arma(GradientBoostingClassifier(learning_rate=tasa,
random_state=42)).fit(X_tr, y_tr)
print(f'tasa {tasa:<5} train {m.score(X_tr, y_tr):.4f} '
f'AUC {roc_auc_score(y_te, m.predict_proba(X_te)[:, 1]):.4f}')
tasa 0.01 train 0.6796 AUC 0.6977 tasa 0.1 train 0.7493 AUC 0.6876 tasa 0.5 train 0.9044 AUC 0.6515
Con 0,01 aprende despacito y generaliza mejor; con 0,5 se lanza y memoriza. Esa es la perilla más importante del boosting, y la regla es tasa baja y muchos árboles.
Con 0,01 este boosting ya se acerca a la logística. Con más datos la pasaría 📈
6. El error de pedirle probabilidades a quien no las tiene
Pídele predict_proba a un SVM normal.
svm.predict_proba(X_te)
AttributeError: This 'Pipeline' has no attribute 'predict_proba'
No lo tiene. Y es un error honesto: en vez de inventarse un número, el objeto te dice que esa operación no existe para él.
Es un buen recordatorio de que no todos los modelos dan lo
mismo. Si tu código pide predict_proba a ciegas, el día que
alguien cambie el clasificador se rompe 🔌
7. Los ocho de una pasada
Un bucle que los entrena todos y los ordena por AUC. Es el que yo corro al empezar cualquier proyecto.
candidatos = {
'logistica': LogisticRegression(max_iter=1000, random_state=42),
'naive bayes': GaussianNB(),
'bosque podado': RandomForestClassifier(n_estimators=300, max_depth=5,
random_state=42, n_jobs=-1),
'boosting': GradientBoostingClassifier(random_state=42),
'vecinos': KNeighborsClassifier(n_neighbors=25),
'arbol d=4': DecisionTreeClassifier(max_depth=4, random_state=42),
}
resultados = []
for nombre, clf in candidatos.items():
m = arma(clf).fit(X_tr, y_tr)
resultados.append((roc_auc_score(y_te, m.predict_proba(X_te)[:, 1]),
m.score(X_tr, y_tr), nombre))
for auc, train, nombre in sorted(resultados, reverse=True):
print(f'{nombre:15} AUC {auc:.4f} train {train:.4f}')
logistica AUC 0.7214 train 0.6658 naive bayes AUC 0.7145 train 0.6480 bosque podado AUC 0.7115 train 0.7040 boosting AUC 0.6876 train 0.7493 vecinos AUC 0.6766 train 0.6787 arbol d=4 AUC 0.6643 train 0.6658
Ese bucle son diez líneas y contesta en cinco segundos la pregunta que más tiempo se pierde discutiendo: ¿qué modelo usamos?
Y ojo con lo que no hace: no ajusta hiperparámetros ni valida en varias particiones. Sirve para descartar rápido, no para decidir. Decidir es el capítulo 16 🎯
Comprueba que lo tienes
La regresión logística le gana a un bosque aleatorio en estos datos. ¿Qué significa?
- Que la relación es sencilla y no hace falta un modelo complicado
- Que el bosque está mal configurado
- Que hay pocos datos
- Que hay que usar redes neuronales
Lo que te llevas
- 🥇 Aquí gana la regresión logística con 0,7214, por delante de bosques y boosting. Empieza siempre por ella.
- 🚨 Un árbol sin límite da 1,0 en train y 0,5525 en test. 1,0 en train es una alarma, nunca un logro.
- 🌲 El bosque por defecto crece sin límite: limitándolo a profundidad 5, el AUC sube de 0,677 a 0,7115.
- 📈 El boosting entrena árboles que corrigen al anterior. Brilla con muchos datos; con 2.250 filas, no.
- 🎚️ El
kde los vecinos y la tasa del boosting son diales de memorizar contra generalizar. - 📐 La columna
trainordena los modelos mejor que su nombre. - 🔍 La importancia de columnas de un bosque favorece a las que tienen muchos valores. No es de fiar.
En el capítulo 14 dejamos de mirar un solo número: matriz de confusión, precisión, exhaustividad y por qué un 95% de exactitud puede ser una vergüenza.
Que tengas lindo día! 🌸