Validación cruzada
En resumen: La validación cruzada divide los datos en varios pliegues y entrena y comprueba el modelo muchas veces; da una puntuación media con su dispersión en lugar de un único número con suerte.
Problema
«¿El árbol o la recta?»
La tienda quiere saber cuántos pedidos hará cada cliente en los próximos 4 meses. Con eso decide a quién mandar una tarjeta de descuento y cuánta mercancía pedir. Las características salen de los pedidos anteriores al 1 de julio de 2026, como en la lección sobre clasificación: número de pedidos, días desde el último, cuánto tiempo lleva comprando y el pedido medio. El objetivo es el número de pedidos del 1 de julio al 31 de octubre.
Tú dices «regresión lineal», y tu colega dice «un árbol de 3 niveles». Los ejecutas en 6 divisiones distintas en entrenamiento y prueba (random_state de 0 a 5) y mides el MAE en pedidos por cliente:
Hay 1279 clientes, y hacen de media 2,70 pedidos en los 4 meses. Con las divisiones 0, 1, 2 y 4 el árbol se equivoca menos, y con la 3 y la 5 la recta. Si hubieras ejecutado solo la división 2, tu colega habría ganado con 1,25 frente a 1,31. Con la división 5 ganas tú. ¿Quién tiene razón?
La idea
Una comprobación es una muestra
Los datos de prueba son 256 clientes, elegidos al azar. Como la media de 30 pedidos en la lección sobre muestras, el error en 256 clientes también tiene ruido: otra muestra da otro número. El error de la recta salta de 1,31 a 1,43 solo por qué clientes cayeron en la prueba.
La diferencia entre los dos modelos son unas centésimas. El ruido de la división está en torno a diez centésimas. Con una comprobación no puedes decir cuál es mejor, igual que no puedes saber el pedido medio con 30 pedidos.
La idea
Validación cruzada
La solución es comprobar muchas veces, con cada cliente exactamente una vez en la prueba. Divides los clientes en 5 pliegues (folds). Entrenas con 4 y mides en el quinto, y luego cambias cuál es la prueba. Obtienes 5 errores para cada modelo. Esto se llama validación cruzada.
KFold divide en pliegues, y cross_val_score entrena y mide el modelo en cada uno. Con shuffle=True los clientes se mezclan antes de dividir:
Cuatro veces aprendes con 1023 clientes y compruebas con 256, una vez con 1024 y 255. scikit-learn siempre busca el número más grande, por eso el error viene con un menos (neg_mean_absolute_error), y el menos de delante lo devuelve a su sitio.
La línea base (la media, DummyRegressor) se equivoca en 2,70 ± 0,09. La recta se equivoca en 1,34 ± 0,06, y el árbol en 1,38 ± 0,09. El número después del ± es la dispersión de los cinco errores (std). Los dos modelos superan con claridad a la línea base, y la diferencia entre ellos (0,04) es menor que la dispersión. La conclusión honesta es «la recta es un poco mejor, pero no con claridad». Cuando dos modelos están tan cerca, te quedas con el más simple.
Más precisamente, para curiosos
Los dos modelos se comparan con los mismos pliegues: por eso kf es un solo objeto, con un random_state. cv=5 también divide en 5, pero sin mezclar: el primer pliegue son los primeros 256 clientes en orden. Si la tabla está ordenada (por número, por la fecha del primer pedido), los pliegues se diferencian entre sí y la puntuación se sesga.
Pruébalo
¿Es mejor el bosque?
Tu colega propone un bosque aleatorio (RandomForestRegressor con 50 árboles, max_depth=5). Compruébalo con validación cruzada sobre los mismos pliegues kf. Guarda en forest_mae y line_mae los cinco errores (MAE, positivos) del bosque y de la recta, en gap la diferencia del error medio (bosque menos recta) y en spread la dispersión de los errores del bosque.
Solución
from sklearn.ensemble import RandomForestRegressor
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
customers = before.groupby('customer_id').agg(
orders=('id', 'size'),
last=('ordered_at', 'max'),
first=('ordered_at', 'min'),
avg_value=('value', 'mean'),
)
customers['days_since'] = (cutoff - customers['last']).dt.days
customers['tenure'] = (cutoff - customers['first']).dt.days
customers['next'] = after.groupby('customer_id').size().reindex(customers.index, fill_value=0)
X = customers[['orders', 'days_since', 'tenure', 'avg_value']]
y = customers['next']
kf = KFold(n_splits=5, shuffle=True, random_state=42)
forest = RandomForestRegressor(n_estimators=50, max_depth=5, random_state=0)
forest_mae = -cross_val_score(forest, X, y, cv=kf, scoring='neg_mean_absolute_error')
line_mae = -cross_val_score(LinearRegression(), X, y, cv=kf, scoring='neg_mean_absolute_error')
gap = forest_mae.mean() - line_mae.mean()
spread = forest_mae.std()
print(forest_mae.round(2), line_mae.round(2))
print(round(gap, 2), round(spread, 2))El bosque se equivoca en 1,36 pedidos de media, la recta en 1,34: gap es 0,02 a favor de la recta. La dispersión del bosque es 0,10, mucho más que la diferencia. El bosque no es mejor, y es más lento y más difícil de explicar. Sin el menos delante de cross_val_score los errores salen negativos, y con cv=5 en lugar de kf los dos modelos se miden con otros pliegues que en el ejemplo.
La idea
Sobreajuste
¿Por qué el árbol tiene 3 niveles y no 20? Cuanto más profundo, más fino divide a los clientes. Aquí está el error en los clientes de entrenamiento y en los de prueba para profundidades de 1 a 12:
En los clientes de entrenamiento el error baja cada vez: de 1,87 a 0,41. En los clientes de prueba baja hasta la profundidad 3 (1,50) y luego crece hasta 1,91. Un árbol sin límite llega a 23 niveles y se equivoca en 0,001 con los clientes de entrenamiento, casi cero, y en 2,00 con los de prueba.
El árbol profundo no ha aprendido más sobre los clientes. Se ha aprendido de memoria a cada cliente de entrenamiento junto con su ruido: un cliente pidió por casualidad más, otro menos. Esto se llama sobreajuste. La señal es una gran diferencia entre el error de entrenamiento y el error de prueba. Por eso el error en los datos con los que aprendió el modelo no dice nada sobre clientes nuevos.
Pruébalo
Elige la profundidad
Elige la profundidad del árbol con validación cruzada. Guarda en cv_mae (una Series con la profundidad de 1 a 10 como índice) el MAE medio de cross_val_score con los pliegues kf, y en best_depth la profundidad con el menor error.
Solución
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
customers = before.groupby('customer_id').agg(
orders=('id', 'size'),
last=('ordered_at', 'max'),
first=('ordered_at', 'min'),
avg_value=('value', 'mean'),
)
customers['days_since'] = (cutoff - customers['last']).dt.days
customers['tenure'] = (cutoff - customers['first']).dt.days
customers['next'] = after.groupby('customer_id').size().reindex(customers.index, fill_value=0)
X = customers[['orders', 'days_since', 'tenure', 'avg_value']]
y = customers['next']
kf = KFold(n_splits=5, shuffle=True, random_state=42)
cv_mae = pd.Series(dtype=float)
for depth in range(1, 11):
tree = DecisionTreeRegressor(max_depth=depth, random_state=0)
cv_mae[depth] = -cross_val_score(tree, X, y, cv=kf, scoring='neg_mean_absolute_error').mean()
best_depth = cv_mae.idxmin()
print(cv_mae.round(2))
print(best_depth)La validación cruzada da 1,89 en la profundidad 1, el menor, 1,38, en la 3, y luego el error crece hasta 1,70 en la 10. best_depth es 3. Si mides el error en los mismos clientes con los que aprendió el árbol, solo baja, y el «mejor» sale el árbol más profundo: justo el sobreajustado.
La idea
Cuando el orden es el tiempo
A los clientes se les puede mezclar: un cliente no sabe nada de otro. A los días no. Si predices los pedidos de mañana, el modelo solo tendrá el pasado. Los pliegues mezclados también lo entrenarían con días posteriores a los de prueba, y eso es la fuga de datos de la lección anterior.
TimeSeriesSplit divide por tiempo: aprende con el comienzo y comprueba con los días siguientes, luego aprende con más y comprueba con otros aún posteriores. Un ejemplo: los pedidos de un día a partir del día de la semana, los pedidos de hace 7 días y la media de los 7 días anteriores:
Los primeros 7 días no tienen «hace 7 días» y se descartan, quedan 358. Cada pliegue de prueba son 59 días después de la parte de entrenamiento, y la parte de entrenamiento crece: 2 meses al principio, 10 al final. La recta se equivoca de media en 4,51 ± 0,18 pedidos por día.
Pruébalo
¿Supera a la línea base?
Compara la recta con la línea base (DummyRegressor, la media de los días de entrenamiento) con TimeSeriesSplit de 5 pliegues. Guarda en line_mae y base_mae los cinco errores (MAE, positivos) de cada una.
Solución
import numpy as np
import pandas as pd
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
from sklearn.linear_model import LinearRegression
from sklearn.dummy import DummyRegressor
orders = pd.read_csv('year_orders.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
daily = orders.set_index('ordered_at').resample('D').size().rename('orders').to_frame()
daily['weekday'] = daily.index.dayofweek
daily['last_week'] = daily['orders'].shift(7)
daily['week_mean'] = daily['orders'].shift(1).rolling(7).mean()
daily = daily.dropna()
X = pd.get_dummies(daily['weekday'], prefix='day', dtype=int).join(daily[['last_week', 'week_mean']])
y = daily['orders']
ts = TimeSeriesSplit(n_splits=5)
line_mae = -cross_val_score(LinearRegression(), X, y, cv=ts, scoring='neg_mean_absolute_error')
base_mae = -cross_val_score(DummyRegressor(), X, y, cv=ts, scoring='neg_mean_absolute_error')
print(line_mae.round(2), base_mae.round(2))
print(f'{line_mae.mean():.2f} ± {line_mae.std():.2f}', f'{base_mae.mean():.2f} ± {base_mae.std():.2f}')La recta se equivoca en 4,51 ± 0,18 pedidos por día, y la línea base en 5,69 ± 1,92. El primer pliegue de la línea base es 9,29: aprende con noviembre y diciembre, el pico del año, y predice enero y febrero, los meses más débiles. La dispersión muestra que la media del pasado es una mala predicción cuando cambia la estación. Con pliegues mezclados (KFold) los números son otros: el modelo ha aprendido con días posteriores a los de prueba.
La idea
La prueba se toca una sola vez
Si pruebas 10 modelos con los datos de prueba y te quedas con el mejor, la prueba participó en la elección. Uno de los diez tendrá suerte justo con estos clientes, y su error parecerá mejor de lo que es.
Por eso el orden es:
- Apartas una parte de prueba al comienzo y no la miras.
- Eliges el modelo y sus ajustes (profundidad, características) con validación cruzada solo sobre la parte de entrenamiento.
- Entrenas el modelo elegido con toda la parte de entrenamiento y lo mides una vez en la prueba. Ese es el número que le dices a la jefa.
Con los 1023 clientes de entrenamiento la línea base se equivoca en 2,74 ± 0,17, la recta en 1,32 ± 0,06, y el árbol en 1,36 ± 0,07. La recta tiene el menor error y es más simple: la eliges. Los 256 clientes de prueba siguen intactos.
Más precisamente, para curiosos
Si después de la prueba cambias el modelo y mides otra vez en la prueba, la prueba pasa a ser parte de la elección. Entonces necesitas una nueva parte de prueba: datos nuevos, por ejemplo el mes siguiente. Por eso, para un modelo de pedidos, la comprobación más honesta es el tiempo posterior al entrenamiento, como en TimeSeriesSplit.
La idea
Cuándo no hace falta un modelo
Un modelo cuesta tiempo: datos, entrenamiento, vigilar que no se estropee. A veces no vale la pena:
- Pocos datos. Con unas pocas decenas de clientes el error de la validación cruzada salta tanto que no puedes decir si el modelo supera a la media.
- Una regla sin modelo hace lo mismo. «El cliente comprará al mismo ritmo» es una división. Si el modelo no la supera con claridad, la regla es mejor: todos la entienden y nada puede romperse.
- El coste de un error. Si un error es caro (rechazas un crédito, bloqueas una tarjeta), el modelo tiene que estar muy seguro, o una persona tiene que revisar.
- Nadie actúa según la predicción. Si del número no sale ninguna decisión (quién recibe un cupón, cuánta mercancía), el modelo es solo un ejercicio.
La recta tiene un coeficiente de 0,469 para el número de pedidos hasta ahora, y las demás características apenas participan. Así que es casi «la mitad de los pedidos hasta ahora»: 8 meses antes, 4 meses después. Compruébalo en el ejercicio.
Ejercicio
El veredicto
La validación cruzada sobre la parte de entrenamiento eligió la recta. Entrénala con X_train y compruébala una sola vez en la prueba: guarda en model el modelo entrenado, en test_mae su error (MAE) en los clientes de prueba y en base_mae el error de la línea base en la prueba.
Solución
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
customers = before.groupby('customer_id').agg(
orders=('id', 'size'),
last=('ordered_at', 'max'),
first=('ordered_at', 'min'),
avg_value=('value', 'mean'),
)
customers['days_since'] = (cutoff - customers['last']).dt.days
customers['tenure'] = (cutoff - customers['first']).dt.days
customers['next'] = after.groupby('customer_id').size().reindex(customers.index, fill_value=0)
X = customers[['orders', 'days_since', 'tenure', 'avg_value']]
y = customers['next']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = LinearRegression().fit(X_train, y_train)
test_mae = mean_absolute_error(y_test, model.predict(X_test))
base = DummyRegressor().fit(X_train, y_train)
base_mae = mean_absolute_error(y_test, base.predict(X_test))
print(round(test_mae, 2), round(base_mae, 2))La recta se equivoca en 1,38 pedidos en los 256 clientes de prueba, y la línea base en 2,58. Ese es el número para la jefa: «la predicción a 4 meses se equivoca de media en 1,4 pedidos». En la validación cruzada la recta dio 1,32 ± 0,06: la prueba es un poco peor, pero dentro del ruido. Si entrenas con todos los clientes, el modelo ha visto también los de prueba, y 1,38 ya no es honesto. El árbol de tu colega se equivoca más en la prueba, pero no eliges en su contra por la prueba: la elección se hizo antes.
¿La recta supera a una regla sin modelo? La regla es «el cliente comprará al mismo ritmo»: para 4 meses, la mitad de sus pedidos de los 8 meses anteriores. Guarda en rule la predicción de la regla para cada cliente de prueba, y en rule_mae su error en la prueba.
Solución
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error
orders = pd.read_csv('year_orders.csv')
items = pd.read_csv('year_items.csv')
orders['ordered_at'] = pd.to_datetime(orders['ordered_at'])
items['revenue'] = items['quantity'] * items['unit_price']
orders['value'] = orders['id'].map(items.groupby('order_id')['revenue'].sum())
cutoff = pd.Timestamp('2026-07-01')
before = orders[orders['ordered_at'] < cutoff]
after = orders[orders['ordered_at'] >= cutoff]
customers = before.groupby('customer_id').agg(
orders=('id', 'size'),
last=('ordered_at', 'max'),
first=('ordered_at', 'min'),
avg_value=('value', 'mean'),
)
customers['days_since'] = (cutoff - customers['last']).dt.days
customers['tenure'] = (cutoff - customers['first']).dt.days
customers['next'] = after.groupby('customer_id').size().reindex(customers.index, fill_value=0)
X = customers[['orders', 'days_since', 'tenure', 'avg_value']]
y = customers['next']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = LinearRegression().fit(X_train, y_train)
model_mae = mean_absolute_error(y_test, model.predict(X_test))
rule = X_test['orders'] / 2
rule_mae = mean_absolute_error(y_test, rule)
print(round(model_mae, 2), round(rule_mae, 2))
print(dict(zip(X.columns, model.coef_.round(3).tolist())))La regla se equivoca en 1,41, la recta en 1,38: una diferencia de 0,03 pedidos, por debajo del ruido de una sola comprobación. El coeficiente de la recta para el número de pedidos es 0,469, casi la mitad, así que las dos dan casi lo mismo. Si tomas todos los pedidos hasta ahora sin dividir entre 2, predices 8 meses en lugar de 4. Aquí la regla es la elección sensata: una división que todos entienden, frente a un modelo que hay que entrenar y vigilar.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Modelos: aprender de ejemplos · Regresión lineal · Clasificación · Fuga de datos
- Ejercicios sobre el tema: Modelos con scikit-learn