Capítulo 13 de 25 11 secciones 14 min

Ocho modelos, y gana el más simple

Logística, árbol, bosque, boosting, vecinos, Bayes y SVM sobre las mismas columnas, y por qué la de 1958 queda primera.

Sobre este archivo gana la regresión logística con 0,7214 de AUC, por delante de naive Bayes (0,7145), el bosque podado (0,7115), el boosting (0,6876) y el SVM (0,6826). Pasa cuando hay poca señal, pocas filas y relaciones simples, que es la mayoría de los problemas de empresa. Y un árbol sin límite de profundidad da 1,0 en entrenamiento y 0,5525 en examen, que es el sobreajuste en su forma más pura.

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)
Nube de puntos de dos clases separadas por una recta: la frontera de decisión de una regresión logística. A un lado el modelo predice una clase y al otro la contraria, y lo que aprende el modelo es dónde va esa recta.
Esto es todo lo que hace una regresión logística: buscar dónde poner la raya. Y de paso explica su límite, porque si tus clases no se separan con una recta, no hay ajuste de parámetros que la salve: hace falta otro modelo.

1. Regresión logística: la que hay que entender

p=σ(z)=11+ez,z=b+jwjxj

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.

Los mismos puntos partidos por un árbol de decisión de profundidad 2: un primer corte vertical parte el plano en dos, y dentro de la mitad izquierda un segundo corte horizontal vuelve a partir. Quedan tres rectángulos, uno por clase.
Un árbol no traza rayas inclinadas: parte siempre por una variable a la vez, así que sus fronteras son escalones. Ahí está su ventaja y su límite en la misma imagen: cada corte se lee en voz alta como una regla, y por eso se puede auditar.

2. Árbol de decisión: el que se lee

Gini=1kpk2

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 🌳

Los mismos puntos con tres modelos encima: a la izquierda una recta que no captura la curva, en el centro una curva suave que pasa por el medio de los puntos, y a la derecha una línea retorcida que pasa por todos los puntos incluido el ruido.
Los dos extremos fallan y no fallan igual. El de la izquierda es demasiado simple y lo dice desde el primer día; el de la derecha acierta en entrenamiento y falla fuera, que es el que engaña. El del medio no pasa por todos los puntos a propósito.

3. El mismo árbol sin límite: la lección del capítulo

H=kpklog2pk

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

𝔼[(yf^)2]=Sesgo2modelo muy simple+Varianzamodelo muy pegado+σ2ruido, no se toca

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.

Dos clases separadas por un hiperplano con dos líneas punteadas paralelas a los lados que marcan el margen. Los dos puntos que tocan esas líneas están rodeados: son los vectores de soporte.
De todas las rectas que separan las dos clases, la SVM elige la que deja más espacio libre a los lados. Los únicos puntos que deciden son los dos rodeados: puedes mover cualquier otro y la frontera no se entera.

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

ModeloAUCTrainPara qué sirve
Regresión logística0,72140,6658Casi siempre el primero. Se explica y no se rompe
Naive Bayes0,71450,6480Rapidísimo, buen listón, muy usado en texto
Bosque podado0,71150,7040Cuando hay interacciones y no linealidad
Boosting0,68760,7493Suele ganar con más datos y con ajuste
SVM0,68260,7320Pocos datos y muchas columnas
Hist boosting0,67990,9387Como el boosting pero rápido y aguanta nulos
Bosque sin podar0,67701,0000Lo de arriba, mal configurado
Vecinos0,67660,6787Didáctico. En producción casi nunca
Árbol d=40,66430,6658Cuando hay que dibujarlo en una reunión
Árbol sin límite0,55251,0000Nada. 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 k de los vecinos y la tasa del boosting son diales de memorizar contra generalizar.
  • 📐 La columna train ordena 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! 🌸

¿Tienes alguna duda o consulta?