Fuga de datos
En resumen: Hay fuga de datos cuando un modelo ve durante el entrenamiento información que no tendrá en la predicción real, así que la puntuación de la prueba parece mejor de lo que será el modelo en el uso real.
Problema
99,7 % de exactitud
Un colega ha construido el modelo de la lección sobre clasificación: qué clientes no pedirán después del 1 de julio. Solo que toma la tabla de clientes directamente del sistema, tal como está hoy, 1 de noviembre: número de pedidos, días desde el último pedido y pedido medio. Y te enseña:
Hay 1279 clientes. La regla «todos vuelven» acierta con el 72,5 % de los clientes de prueba. El modelo acierta con el 99,7 %: de 320 clientes en los datos de prueba falla uno. En la lección sobre clasificación, con las mismas tres características, el modelo tenía un 72,2 %, por debajo de la regla. ¿Qué cambió? La tabla.
La idea
La respuesta está en las características
Mira cuántos días desde el último pedido tienen los clientes que vuelven (lost es 0) y los perdidos (lost es 1):
Todo cliente que vuelve tiene de 0 a 122 días, y todo perdido de 123 a 363. Un solo número los separa sin un error. ¿Por qué? days_since se calcula hasta hoy. Quien pidió después del 1 de julio tiene un último pedido posterior al 1 de julio, así que está a menos de 123 días del 1 de noviembre. La característica contiene la etiqueta. Lo mismo vale para orders: cuenta también los pedidos posteriores al 1 de julio.
Esto se llama fuga de datos (data leakage): información que el modelo no tendrá cuando prediga se cuela en el entrenamiento. El 1 de julio no sabes quién pedirá en agosto. La regla es una: cada característica se calcula solo con lo que se conoce en el momento de la predicción.
Más precisamente, para curiosos
La fuga de datos se esconde en columnas que el sistema actualiza después del hecho: «pedidos totales», «última compra», «estado», «artículo devuelto». De cada columna pregunta cuándo se escribe su valor. Si es después del día de la predicción, la columna no sirve como característica, por útil que parezca. Las características más fuertes de un modelo son también las primeras que compruebas.
La idea
El número honesto
Las mismas características dos veces. Una con todos los pedidos hasta hoy, otra solo con los pedidos anteriores al 1 de julio y hasta el 1 de julio:
Con el futuro en las características la exactitud es del 99,7 %. Con honestidad es del 72,2 %, y la línea base es 72,5 %. Con estas tres características el modelo no gana nada en exactitud, igual que en la lección sobre clasificación. Desagradable, pero cierto. Mejor saberlo ahora que después de repartir los cupones.
features(rows, now) es una función para las dos tablas: la única diferencia es qué pedidos le das y hasta qué día cuenta. .loc[honest.index] toma los mismos 1279 clientes en el mismo orden, así que las dos divisiones son idénticas: 959 clientes para el entrenamiento y 320 para la prueba.
Míralo
Cómo se cuela el futuro en la característica
El mismo código, despacio. Los puntos son los pedidos de cuatro clientes por mes, y el muro es el 1 de julio. La etiqueta sale de los puntos a su derecha. La característica del colega los cuenta también.
import pandas as pdfrom sklearn.model_selection import train_test_splitfrom sklearn.linear_model import LogisticRegression
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])items = pd.read_csv('year_items.csv')items['value'] = items['quantity'] * items['unit_price']orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
cutoff = pd.Timestamp('2026-07-01')before = orders[orders['ordered_at'] < cutoff]after = orders[orders['ordered_at'] >= cutoff]
def features(rows, now): people = rows.groupby('customer_id').agg( orders=('id', 'size'), last=('ordered_at', 'max'), basket=('basket', 'mean')) people['days_since'] = (now - people['last']).dt.days return people[['orders', 'days_since', 'basket']]
honest = features(before, cutoff)leaky = features(orders, pd.Timestamp('2026-11-01')).loc[honest.index]y = (~honest.index.isin(after['customer_id'])).astype(int)
for name, X in [('con el futuro', leaky), ('honesto', honest)]: X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y) model = LogisticRegression(max_iter=1000).fit(X_train, y_train) print(name, round(model.score(X_test, y_test), 3))print('clientes:', len(honest), 'entrenamiento:', len(X_train), 'prueba:', len(X_test))print('línea base:', round(1 - y_test.mean(), 3))Paso a paso, en palabras
- orders son todos los pedidos del año, hasta el 31 de octubre de 2026. Los puntos son los pedidos de cuatro clientes por mes.
- cutoff es el 1 de julio. A la izquierda del muro está before: lo que sabes. A la derecha está after: lo que predices.
- lost es 1 si el cliente no tiene ningún pedido a la derecha. La etiqueta se hace con los puntos posteriores al 1 de julio.
- La tabla del colega cuenta hasta hoy. orders cuenta también los puntos de la derecha, y days_since se mide desde hoy: los mismos puntos con los que se hace la etiqueta.
- La división: 959 clientes para entrenar y 320 para la prueba, de 1279.
- Con el futuro en las características, la exactitud es 99,7%. La línea base «todos vuelven» es 72,5%.
- De forma honesta: las características terminan el 1 de julio. Los puntos de la derecha no se cuentan, y days_since se mide desde el muro.
- La exactitud honesta es 72,2%, por debajo de la línea base. 99,7% fue una fuga de datos, no un modelo.
Los clientes de la escena son un boceto: los números de los pies de imagen son reales, desde el número de clientes hasta las dos exactitudes.
Pruébalo
Una característica más
El colega quiere una característica más: recent, el número de pedidos del cliente en los últimos 30 días. Calcúlala con honestidad, a fecha del 1 de julio: guarda en month los pedidos de los 30 días anteriores a cutoff, en people['recent'] su número para cada cliente (0 para los que no tienen pedido) y en score la exactitud del modelo con las cuatro características.
Solución
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['value'] = items['quantity'] * items['unit_price']
orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
people = before.groupby('customer_id').agg(
orders=('id', 'size'), last=('ordered_at', 'max'), basket=('basket', 'mean'))
people['days_since'] = (cutoff - people['last']).dt.days
people['lost'] = (~people.index.isin(after['customer_id'])).astype(int)
month = before[before['ordered_at'] >= cutoff - pd.Timedelta(days=30)]
people['recent'] = month.groupby('customer_id').size()
people['recent'] = people['recent'].fillna(0)
X = people[['orders', 'days_since', 'basket', 'recent']]
y = people['lost']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
score = model.score(X_test, y_test)
print(round(people['recent'].mean(), 3), round(score, 3))De media un cliente tiene 0,699 pedidos en junio, y la exactitud se queda en el 72,2 %: recent no ayuda. Si cuentas los últimos 30 días hasta hoy, es decir, octubre, la exactitud pasa a 75,6 % y parece una mejora sobre la línea base. No lo es: los pedidos de octubre son el futuro que predices. La fuga de datos no siempre da un 99 %. A veces da justo lo suficiente para que te lo creas.
La idea
Preparación antes de la división
La regresión logística funciona mejor cuando las características están en una escala parecida: days_since llega a 240, y orders rara vez pasa de 10. El StandardScaler resta a cada característica su media y divide entre la desviación estándar. Así cada característica queda centrada en 0, con una dispersión de 1. El escalador aprende las medias con fit, de los datos que le das. El colega le da todo:
El escalador aprendió de los 1279 clientes: las medias son 5,88 pedidos, 67,42 días y 10,69 €. Los datos de entrenamiento solos tienen 5,96, 67,84 y 10,68. La diferencia viene de los 320 clientes de prueba: influyeron en la preparación antes de que el modelo los viera. Los datos de prueba ya no son del todo desconocidos.
Aquí la exactitud no cambia: es otra vez 72,2 %, porque las medias están cerca. No siempre es así. La fuga crece con pocos datos y con una preparación que mira la etiqueta, por ejemplo cuando eliges las características según cómo se relacionan con lost en todas las filas. Rellenar valores que faltan con la media (fillna) es el mismo caso: la media se calcula solo con los datos de entrenamiento.
La idea
Pipeline: la preparación aprende solo de los datos de entrenamiento
Un Pipeline une la preparación y el modelo en un objeto. make_pipeline(StandardScaler(), LogisticRegression()) hace una cadena de dos pasos:
pipe.fit(X_train, y_train)entrena el escalador y luego el modelo, solo con los datos de entrenamiento;pipe.score(X_test, y_test)ypipe.predictescalan los datos de prueba con las medias aprendidas y le preguntan al modelo.
Las medias del escalador en pipe son 5,96, 67,84 y 10,68: exactamente las de los datos de entrenamiento. La exactitud es del 72,2 %. Ahora el orden no puede fallar: la preparación es parte del modelo. Un Pipeline ayuda también en la próxima lección: en la validación cruzada cada división aprende el escalador de nuevo.
Más precisamente, para curiosos
pipe[0] es el primer paso, y pipe[-1] es el modelo. Una cadena puede tener más pasos: SimpleImputer rellena los valores que faltan, OneHotEncoder hace columnas 0/1 a partir de categorías, y ColumnTransformer aplica preparaciones distintas a columnas distintas. Todos aprenden con un solo fit, solo con los datos de entrenamiento.
La idea
Un cliente en el entrenamiento y en la prueba
La tabla se pegó a partir de dos exportaciones, y cada cliente está dos veces. En el código lo hace pd.concat: pone las dos tablas una debajo de la otra. Parece inofensivo: los mismos datos, solo que más. Pruebas un árbol de decisión (DecisionTreeClassifier) que crece sin límite de profundidad:
Hay 2558 filas, y 1279 de ellas son repeticiones. Con los duplicados el árbol acierta el 89,4 % de los datos de prueba, por encima de la línea base. Sin ellos, el 58,4 %. Con una división al azar la mayoría de las filas de prueba tienen un gemelo en el entrenamiento, y un árbol sin límite se memoriza cada fila. No predice, recuerda. Un cliente nuevo no tiene gemelo: el 58,4 % es el número real, por debajo de la línea base, porque el árbol está sobreajustado. De eso trata la próxima lección.
La regla: quitas los duplicados (drop_duplicates) antes de la división. Aquí reset_index() convierte customer_id en una columna, para que se quiten las repeticiones del mismo cliente y no dos clientes distintos con los mismos números.
Más precisamente, para curiosos
A veces un cliente tiene varias filas reales, por ejemplo una por pedido. No hay duplicados, pero si sus filas están tanto en el entrenamiento como en la prueba, el modelo vuelve a aprender al cliente, no la regla. Entonces divides por cliente: GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=42).split(X, y, groups=rows['customer_id']) pone todas las filas de un cliente en el mismo lado.
La idea
Días al azar de una serie temporal
En la lección sobre regresión dividiste por tiempo. Aquí ves por qué una división al azar miente. La predicción es para los pedidos por día, a partir del número del día y el día de la semana. El modelo es un bosque aleatorio (RandomForestRegressor): muchos árboles, cada uno entrenado con filas algo distintas, y la predicción es su promedio.
Con días al azar el bosque se equivoca en 4,88 pedidos al día de media, y la media (DummyRegressor) en 5,76: el bosque parece mejor. Entrenado hasta el 31 de julio y probado con agosto a octubre, el bosque se equivoca en 7,96, y la media en 5,44. El bosque es peor que la media.
Con una división al azar, para acertar el 15 de septiembre el bosque ha visto el 14 y el 16 de septiembre. En la vida real, el 31 de julio no tienes ni un solo día de septiembre. Además, los árboles no continúan una tendencia: un día posterior al último conocido es para ellos como los últimos días conocidos. Y de agosto a octubre hay también una sorpresa: septiembre es más flojo. Por qué, lo descubriremos en el proyecto. Un modelo entrenado hasta julio no puede saberlo, y tú tampoco el 31 de julio. Por eso 7,96 es el número honesto.
Ejercicio
Arregla el modelo del colega
Arregla el código del colega. Las características salen solo de los pedidos anteriores a cutoff, y days_since se cuenta hasta cutoff. El escalador y la regresión logística están en pipe, entrenado solo con los datos de entrenamiento. Guarda en score la exactitud de pipe en los datos de prueba.
Solución
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['value'] = items['quantity'] * items['unit_price']
orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
people = before.groupby('customer_id').agg(
orders=('id', 'size'), last=('ordered_at', 'max'), basket=('basket', 'mean'))
people['days_since'] = (cutoff - people['last']).dt.days
people['lost'] = (~people.index.isin(after['customer_id'])).astype(int)
X = people[['orders', 'days_since', 'basket']]
y = people['lost']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
pipe = make_pipeline(StandardScaler(), LogisticRegression())
pipe.fit(X_train, y_train)
score = pipe.score(X_test, y_test)
print(round(score, 3))La exactitud honesta es 72,2 % frente al 99,7 % con el futuro. Hay dos arreglos. Primero, las características terminan el 1 de julio: before en lugar de orders, y cutoff en lugar de today. Segundo, el escalador aprende de los 959 clientes de los datos de entrenamiento, no de los 1279: pipe.fit(X_train, y_train), no pipe.fit(X, y).
El colega también tiene un modelo para los ingresos por día, sin los pedidos cancelados. Pruébalo con honestidad. Entrena el bosque aleatorio (n_estimators=50, random_state=42) y un DummyRegressor con los días hasta el 31 de julio (late es la máscara de los días desde el 1 de agosto). Luego guarda en mae el error (MAE) del bosque en agosto a octubre, y en base_mae el error de la media.
Solución
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['value'] = items['quantity'] * items['unit_price']
orders['basket'] = orders['id'].map(items.groupby('order_id')['value'].sum())
orders = orders[orders['status'] != 'cancelled']
daily = orders.set_index('ordered_at').resample('D')['basket'].sum().rename('revenue').to_frame()
daily['day'] = range(len(daily))
daily['weekday'] = daily.index.dayofweek
X = daily[['day', 'weekday']]
y = daily['revenue']
late = daily.index >= '2026-08-01'
forest = RandomForestRegressor(n_estimators=50, random_state=42).fit(X[~late], y[~late])
base = DummyRegressor().fit(X[~late], y[~late])
mae = mean_absolute_error(y[late], forest.predict(X[late]))
base_mae = mean_absolute_error(y[late], base.predict(X[late]))
print(round(mae, 2), round(base_mae, 2))El bosque se equivoca en 128,81 € al día de media, y la media de los días hasta julio en 65,24 €: el bosque se equivoca casi el doble que la media. Con una división al azar el mismo bosque da 65,12 € y parece bueno. Si lo entrenas con todos los días, el error baja a 23,96 €, porque el bosque ha visto los datos de prueba. La conclusión honesta: con el número del día y el día de la semana, el bosque no sirve para predecir hacia adelante.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Describir los datos · Modelos: aprender de ejemplos · Regresión lineal · Clasificación
- Continúa en: Validación cruzada
- Ejercicios sobre el tema: Modelos con scikit-learn