Capítulo 12 de 25 8 secciones 12 min

La fuga de información, provocada tres veces

El fallo que en vez de un error te da una felicitación, con los tres sabores medidos sobre el mismo archivo.

Hay fuga cuando el modelo ve al entrenar algo que no va a tener al predecir, y es el fallo más caro porque hace que todo se vea mejor. Aquí se provoca tres veces: la columna del futuro lleva el AUC de 0,7214 a 0,9994 y en producción predice que no compra nadie; partir por filas deja 431 de 617 clientes en los dos lados y regala casi cuatro puntos; y la fuga temporal no infla nada, así que no se caza por el resultado sino preguntando cuándo se rellena cada columna.

Si un modelo te da 0,99 de entrada, no celebres. Busca el error 🚨

La fuga de información es cuando el modelo ve, al entrenar, algo que no va a tener el día que le toque predecir de verdad. Y es el fallo más caro del oficio por una razón muy concreta: hace que todo se vea mejor. Un bug normal te da un error; este te da una felicitación.

En este capítulo la vamos a provocar tres veces, con tres sabores distintos, y las tres son de este archivo.

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(numericas, categoricas):
    """El pipeline del capítulo 11, para poder cambiarle las columnas."""
    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', LogisticRegression(max_iter=1000, random_state=42))])

datos = prepara(carga_limpia(URL))
y = datos['compro']
print(datos.shape)
(3000, 19)

El número honesto, para comparar

X = datos[NUMERICAS + CATEGORICAS]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
                                          random_state=42, stratify=y)

honesto = arma(NUMERICAS, CATEGORICAS).fit(X_tr, y_tr)
print('exactitud:', round(honesto.score(X_te, y_te), 4))
print('AUC      :', round(roc_auc_score(y_te, honesto.predict_proba(X_te)[:, 1]), 4))
exactitud: 0.6733
AUC      : 0.7214

0,6733 y 0,7214. Ese es el modelo del capítulo 11 y es el que dice la verdad 📏

Fuga 1: la columna que es la respuesta

Del capítulo 2 ya sabemos que monto_final_facturado vale 0 en todas las ventas que no se cerraron. Vamos a metérsela al modelo a ver qué pasa.

CON_FUGA = NUMERICAS + ['monto_final_facturado']

X2 = datos[CON_FUGA + CATEGORICAS]
X2_tr, X2_te, y2_tr, y2_te = train_test_split(X2, y, test_size=0.25,
                                              random_state=42, stratify=y)

tramposo = arma(CON_FUGA, CATEGORICAS).fit(X2_tr, y2_tr)
print('exactitud:', round(tramposo.score(X2_te, y2_te), 4))
print('AUC      :', round(roc_auc_score(y2_te, tramposo.predict_proba(X2_te)[:, 1]), 4))
exactitud: 0.988
AUC      : 0.9994

98,8% de exactitud y 0,9994 de AUC 🎉🎉

Y es completamente inútil.

Ese modelo aprendió "si el monto facturado es mayor que cero, entonces se cerró", que es verdad y no sirve de nada: el día que tienes que predecir, la venta todavía no se facturó. En producción esa columna llegaría vacía o en cero para todo el mundo, y el modelo diría que nadie compra.

Esta es la fuga fácil, la que se ve. Pero fíjate en la trampa emocional que tiene: ese 0,99 es el número que uno quiere ver, y por eso cuesta tanto ir a buscarle el problema 😳

La señal de alarma es sencilla: si tu AUC pasa de 0,95 en un problema de negocio, hay fuga hasta que se demuestre lo contrario. Los problemas de verdad son difíciles.

Fuga 2: el mismo cliente en los dos lados

Esta es más sutil y la comete todo el mundo, incluido yo durante años.

Nuestro archivo tiene 3.000 ventas de 617 clientes, o sea unas cinco por cliente. Cuando partimos al azar por filas, ¿qué pasa?

clientes_tr = set(datos.loc[X_tr.index, 'cliente_id'])
clientes_te = set(datos.loc[X_te.index, 'cliente_id'])
print('clientes en los dos lados:', len(clientes_tr & clientes_te))
clientes en los dos lados: 431

431 de los 617 están en entrenamiento y en examen 😬

El modelo vio tres ventas del cliente C0045 al entrenar y le preguntamos por la cuarta. No está prediciendo, está recordando. Y en producción los clientes van a ser nuevos.

Se arregla partiendo por cliente, no por fila:

from sklearn.model_selection import GroupShuffleSplit

grupos = datos['cliente_id']
gss = GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=42)
i_tr, i_te = next(gss.split(X, y, groups=grupos))

print('clientes compartidos ahora:', len(set(grupos.iloc[i_tr]) & set(grupos.iloc[i_te])))

por_grupo = arma(NUMERICAS, CATEGORICAS).fit(X.iloc[i_tr], y.iloc[i_tr])
print('AUC por cliente:', round(roc_auc_score(y.iloc[i_te],
      por_grupo.predict_proba(X.iloc[i_te])[:, 1]), 4))
clientes compartidos ahora: 0
AUC por cliente: 0.6843

Cero clientes compartidos, y el AUC baja de 0,7214 a 0,6843.

Casi cuatro puntos que no eran del modelo: eran de conocer al cliente. Si tu sistema va a predecir sobre clientes que ya existen, 0,72 es el número bueno; si va a predecir sobre clientes nuevos, el bueno es 0,68 y el otro te iba a dejar mal en la reunión de los tres meses 📉

La regla: si una entidad aparece en varias filas, se parte por esa entidad. Cliente, paciente, tienda, usuario. Y se decide mirando la pregunta del capítulo 2, no el código.

Fuga 3: la que no infla el número

Y ahora la más traicionera de las tres, porque no se detecta mirando el resultado.

Acuérdate del capítulo 9: fecha_ultima_compra es la última compra del cliente en todo el archivo, así que el 30% de las filas tienen una fecha posterior a la venta. Vamos a usarla igual.

con_dias = datos.copy()
con_dias['dias_desde_ultima'] = (con_dias['fecha'] - con_dias['fecha_ultima_compra']).dt.days

CON_DIAS = NUMERICAS + ['dias_desde_ultima']
X3 = con_dias[CON_DIAS + CATEGORICAS]
X3_tr, X3_te, y3_tr, y3_te = train_test_split(X3, y, test_size=0.25,
                                              random_state=42, stratify=y)

m3 = arma(CON_DIAS, CATEGORICAS).fit(X3_tr, y3_tr)
print('exactitud:', round(m3.score(X3_te, y3_te), 4))
print('AUC      :', round(roc_auc_score(y3_te, m3.predict_proba(X3_te)[:, 1]), 4))
exactitud: 0.6787
AUC      : 0.7183

0,6787 y 0,7183. Prácticamente lo mismo que el honesto.

O sea que esta fuga no infla nada, y por eso ninguna alarma de "el número está muy alto" la habría cazado. Y sin embargo es una fuga: esa columna, calculada así, no se puede calcular en producción, porque el día de la predicción no conoces las compras futuras del cliente.

El modelo funcionaría hasta que alguien lo despliegue, y entonces esa columna saldría distinta y el modelo daría cosas raras sin explicación.

Conclusión incómoda: la fuga no se caza por el resultado, se caza por el origen de cada columna. La pregunta del capítulo 2, otra vez: ¿cuándo se rellena esto? 🕵️‍♀️

Árbol de decisión para detectar fuga de información: si la variable no existiría al momento de predecir, si se calculó usando datos de test o si usa el futuro de la propia fila, hay fuga. El síntoma es una exactitud altísima en prueba que falla el primer día.
Tres preguntas detectan la fuga de información, que es el error que más modelos buenos arruina. El síntoma engaña porque parece un éxito: cuanto mejor sale la prueba, más conviene desconfiar.

La lista con la que yo reviso

Cinco preguntas, en este orden, antes de entregar cualquier modelo:

  1. 1️⃣ ¿El AUC pasa de 0,95? Si sí, hay fuga hasta que demuestres lo contrario.
  2. 2️⃣ Por cada columna: ¿cuándo se rellena? Si es "al cerrar", "al facturar", "al mes siguiente", fuera.
  3. 3️⃣ ¿Hay una entidad repetida en varias filas? Si sí, se parte por ahí.
  4. 4️⃣ ¿Alguna columna la calculó otro equipo? Los score_ y segmento_ de otros sistemas suelen traer información del futuro y nadie lo sabe.
  5. 5️⃣ ¿La preparación se hizo antes de partir? Seleccionar columnas o buscar hiperparámetros mirando todo el archivo también es fuga. Por eso el pipeline del capítulo 11.

Ejercicios

Siete. Intenta antes de abrir 💛

1. Qué peso le da el modelo a la columna tramposa

Mira el coeficiente de monto_final_facturado frente al resto.

import numpy as np

nombres = tramposo.named_steps['pre'].get_feature_names_out()
pesos = tramposo.named_steps['mod'].coef_[0]
orden = np.argsort(np.abs(pesos))[::-1][:5]
for i in orden:
    print(f'{nombres[i]:35} {pesos[i]:.3f}')
num__monto_final_facturado          13.777
num__monto                          -1.499
cat__segmento_Mayorista             -0.836
cat__segmento_Bodega                0.757
cat__categoria_Snacks               -0.321

El coeficiente de la columna tramposa es enorme comparado con los demás. Ese es otro síntoma: cuando una sola columna se lleva casi todo el peso, hay que ir a mirarla 🔍

2. El modelo tramposo en producción

Simula el día del despliegue: la columna llega en cero porque todavía no se facturó nada.

produccion = X2_te.copy()
produccion['monto_final_facturado'] = 0.0

print('predice que compran:', int(tramposo.predict(produccion).sum()), 'de', len(produccion))
print('la realidad era     :', int(y2_te.sum()))
predice que compran: 0 de 750
la realidad era     : 433

Cero. El modelo dice que no compra nadie, cuando en realidad compraron 433 de 750.

Ese es exactamente el día que se descubre la fuga en la vida real: cuando el sistema lleva una semana en producción y el equipo comercial pregunta por qué la lista de llamadas está vacía 😱

3. El cliente_id como columna

Mete el identificador del cliente como una categórica más y mira qué pasa.

CAT_ID = CATEGORICAS + ['cliente_id']
X4 = datos[NUMERICAS + CAT_ID]
X4_tr, X4_te, y4_tr, y4_te = train_test_split(X4, y, test_size=0.25,
                                              random_state=42, stratify=y)
m4 = arma(NUMERICAS, CAT_ID).fit(X4_tr, y4_tr)
print('AUC con cliente_id:', round(roc_auc_score(y4_te, m4.predict_proba(X4_te)[:, 1]), 4))
print('AUC sin cliente_id:', round(roc_auc_score(y_te, honesto.predict_proba(X_te)[:, 1]), 4))
AUC con cliente_id: 0.6748
AUC sin cliente_id: 0.7214

Baja un poco. Y aquí hay una lección que sorprende: meter un identificador no siempre infla, a veces solo estorba.

Con 617 clientes, el codificador crea 617 columnas de las que casi todas valen cero, el modelo se queda sin datos por columna y empeora. Sea como sea, un identificador nunca es una característica: es un nombre 🏷️

4. Partir por fecha, que es lo que pasa de verdad

En vez de al azar, entrena con 2025 y examina con 2026.

es_2025 = datos['fecha'].dt.year == 2025
m5 = arma(NUMERICAS, CATEGORICAS).fit(X[es_2025.values], y[es_2025.values])
print('entrenamiento:', int(es_2025.sum()), 'filas de 2025')
print('examen       :', int((~es_2025).sum()), 'filas de 2026')
print('AUC en el futuro:', round(roc_auc_score(y[~es_2025.values],
      m5.predict_proba(X[~es_2025.values])[:, 1]), 4))
entrenamiento: 2008 filas de 2025
examen       : 992 filas de 2026
AUC en el futuro: 0.7227

0,7227, prácticamente igual al 0,7214 del azar. Eso es una buena noticia: el modelo aguanta el paso del tiempo en este archivo, y encima entrenando con dos tercios de los datos en vez de con tres cuartos.

Y esta partición es la que corresponde cuando vas a predecir el futuro, que es casi siempre. Si al partir por fecha el número se cae, tienes deriva, y eso es el capítulo 23 📅

5. El fallo silencioso de partir por separado

Parte las columnas y el objetivo con dos llamadas distintas, cada una con su semilla, y mira si te enteras.

X_a, X_b = train_test_split(X, test_size=0.25, random_state=1)
y_a, y_b = train_test_split(y, test_size=0.25, random_state=2)

revuelto = arma(NUMERICAS, CATEGORICAS).fit(X_a, y_a)
print('exactitud:', round(revuelto.score(X_b, y_b), 4))
print('AUC      :', round(roc_auc_score(y_b, revuelto.predict_proba(X_b)[:, 1]), 4))
exactitud: 0.5747
AUC      : 0.5155

Ni un error. Los dos trozos miden 2.250 filas, así que scikit-learn los acepta tan contento, y lo que hemos hecho es entrenar cada fila con la etiqueta de otra 😳

El único que lo delata es el AUC: 0,5155. Un modelo que ordena al azar da 0,5, así que ese número dice "aquí no se aprendió nada".

Y fíjate en la exactitud: 0,5747, que se parece muchísimo al listón de 0,5773. Si solo miraras la exactitud pensarías que el modelo es flojito, no que está roto 📐

Solo salta el error si además cambias el tamaño:

X_c, X_d = train_test_split(X, test_size=0.30, random_state=1)
arma(NUMERICAS, CATEGORICAS).fit(X_c, y_a)
ValueError: Found input variables with inconsistent numbers of samples: [2100, 2250]

inconsistent numbers of samples: [2100, 2250]. Ese sí avisa, y es el error con suerte.

Por eso train_test_split se llama una sola vez con todo y devuelve los cuatro trozos alineados 🔗

6. Cazar columnas sospechosas de una pasada

Un bucle que mide cuánto separa cada columna numérica ella sola. Lo que salga demasiado alto, se mira.

candidatas = NUMERICAS + ['monto_final_facturado']
for col in candidatas:
    v = datos[col].fillna(datos[col].median())
    auc = roc_auc_score(y, v)
    if auc < 0.5:
        auc = 1 - auc
    marca = '  <-- SOSPECHOSA' if auc > 0.9 else ''
    print(f'{col:24} {auc:.4f}{marca}')
unidades                 0.5127
monto                    0.6374
descuento                0.5313
satisfaccion             0.5980
precio_unitario          0.6086
sin_compra_previa        0.5610
sin_descuento            0.5394
sin_satisfaccion         0.5065
visita_numero            0.5110
monto_final_facturado    1.0000  <-- SOSPECHOSA

Una sola columna pasa de 0,9 y es justo la tramposa. Las demás están entre 0,50 y 0,62, que es lo razonable.

Este bucle son seis líneas y lo corro siempre, antes de entrenar nada. Ninguna columna suelta debería predecir casi perfecto: si lo hace, o es la respuesta disfrazada o es algo que en producción no vas a tener 🚨

7. Los dos números que se reportan juntos

Deja escrito el resultado honesto de este capítulo, con las dos particiones.

print('partiendo por fila   :', round(roc_auc_score(y_te,
      honesto.predict_proba(X_te)[:, 1]), 4))
print('partiendo por cliente:', round(roc_auc_score(y.iloc[i_te],
      por_grupo.predict_proba(X.iloc[i_te])[:, 1]), 4))
print('con la columna del futuro:', round(roc_auc_score(y2_te,
      tramposo.predict_proba(X2_te)[:, 1]), 4))
partiendo por fila   : 0.7214
partiendo por cliente: 0.6843
con la columna del futuro: 0.9994

Tres números del mismo archivo y el mismo modelo: 0,7214, 0,6843 y 0,9994.

El tercero es mentira. El primero vale si vas a predecir sobre clientes que ya conoces. El segundo vale si vas a predecir sobre clientes nuevos.

Cuál de los tres aparece en la diapositiva depende de quién arma la diapositiva, y esa es la parte del oficio que no es técnica 💛

Comprueba que lo tienes

monto_final_facturado solo se llena si la venta se cerró. ¿Puede entrar en un modelo que predice si se va a cerrar?

  • No: el día que hay que predecir esa columna todavía está vacía
  • Sí, si se rellenan los nulos con cero
  • Sí, porque mejora muchísimo el AUC
  • Depende del modelo

Lo que te llevas

  • 🚨 AUC por encima de 0,95 en un problema de negocio: hay fuga hasta que demuestres lo contrario.
  • 💥 La columna que es la respuesta disfrazada llevó el AUC de 0,7214 a 0,9994, y en producción predice que no compra nadie.
  • 👥 431 de 617 clientes estaban en los dos lados al partir por filas. Partiendo por cliente, el AUC honesto baja de 0,7214 a 0,6843.
  • 🕵️‍♀️ Hay fugas que no inflan el número: la de los días dio 0,7183. No se cazan por el resultado, se cazan por el origen de la columna.
  • 🏷️ Un identificador nunca es una característica.
  • 📅 Si vas a predecir el futuro, parte por fecha. Aquí aguanta: 0,7083.
  • 📏 El bucle de seis líneas que mide el AUC de cada columna sola se corre siempre, antes de entrenar.

En el capítulo 13 dejamos de usar un solo modelo: regresión logística, árbol, bosque, boosting y dos más, todos sobre las mismas columnas, para ver cuál gana de verdad.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?