Regresión sobre un panel de tienda × SKU × día, donde el holdout no son otras filas: son los 28 días que vienen después. El pipeline casi no cambia. Cambia todo lo que lo rodea, y ahí es donde se pierden los puntos.
| Northwind — churn | Andes — demanda | |
|---|---|---|
| Qué devuelves | Una probabilidad entre 0 y 1 | Unidades: un número, y no puede ser negativo |
| Modelo | HistGradientBoostingClassifier |
HistGradientBoostingRegressor |
| Última línea | predict_proba(X)[:, 1] |
np.clip(predict(X), 0, None) |
| Métrica | ROC-AUC — más alto, mejor | RMSE — más bajo, mejor |
| La partición | Al azar, estratificada | Por fecha. Nunca al azar |
| El holdout es… | Otras cuentas, del mismo periodo | El futuro: los 28 días siguientes al último de sales.csv |
| Filas | 11 200 en el histórico | 1 680 a predecir (6 tiendas × 10 SKU × 28 días) |
ColumnTransformer con
OneHotEncoder para las categóricas y passthrough para los números,
un Pipeline de dos pasos, fit una vez, predict sobre el
holdout. Si te sabes Northwind, ya te sabes las ocho líneas de aquí.Las tiendas S1–S3 exportan ISO (2026-05-06) y las S4–S6 exportan
DD/MM/YYYY (30/01/2026). En la misma columna. Lo primero que pasa
si no haces nada:
ValueError: time data "30/01/2026" doesn't match format "%Y-%m-%d", at position 2.
Ese error es el amable: te avisa. El caro es el otro, el que no falla:
pd.to_datetime(df["sale_date"], format="mixed")
# rango: 2026-01-02 → 2026-12-06
El archivo llega hasta julio. Ha inventado diciembre. 30/01/2026 no es
ambiguo (no hay mes 30), pero 04/03/2026 sí, y sin dayfirst
pandas lo lee como el 3 de abril. 2 124 filas cambian de fecha y no hay ni un
aviso.
pd.to_datetime(df["sale_date"],
format="mixed", dayfirst=True)
# rango: 2026-01-05 → 2026-07-05
dayfirst sólo decide las ambiguas: una cadena ISO se lee como ISO de
todos modos. Una pasada, los dos formatos.
errors="coerce" aquí tampoco te salva: convierte en NaT lo que no
entiende y te deja creyendo que todo salió bien. Después de parsear fechas, mira siempre el
mínimo y el máximo y pregúntate si tienen sentido.>>> df["unit_price"].head(3).tolist()
['S/ 4.60', 'S/ 51.83', 'S/ 32.26']
df["unit_price"] = pd.to_numeric(
df["unit_price"].astype(str).str.replace(r"[^0-9.\-]", "", regex=True),
errors="coerce")
La expresión regular borra todo lo que no sea dígito, punto o signo: se lleva el
S/, el espacio y la coma de los miles. Quitar sólo el símbolo deja
1,234.50, que to_numeric no sabe leer y te devuelve
NaN en las filas caras — justo las que más pesan.
Y el otro, que es una trampa de lectura, no de sintaxis:
>>> df["promo_flag"].unique()
array([ 1., nan, 0.])
df["promo_flag"] = pd.to_numeric(df["promo_flag"], errors="coerce").fillna(0).astype(int)
NaN no es un dato que falta: el enunciado dice que
en blanco significa «no hubo promoción», o sea cero. Imputarlo con la media te
inventaría promociones a medias. Un hueco sólo es un hueco si el enunciado no dice qué
significa.Tres cosas distintas que la gente mete en el mismo saco:
| Qué | Cómo se ve | Qué se hace |
|---|---|---|
| Líneas re-sincronizadas | El mismo row_id dos veces,
copia idéntica | drop_duplicates(subset="row_id") — es la misma
venta, no dos |
| Devoluciones | units_sold negativo |
Fuera del entrenamiento: una devolución no es demanda. Pero sí se cuentan en el T3, que pregunta cuántas filas son |
| Tienda cerrada | No hay filas. Ninguna. No son ceros | Nada que borrar: son días que no existen en el archivo |
drop_duplicates y el filtro de
negativos van sólo al train.Esto es el T3 y es el que más gente falla, porque busca ceros y no hay ceros: lo que hay es la nada. La forma de encontrar una ausencia es restarla de un calendario completo.
dates = pd.to_datetime(df["sale_date"], format="mixed", dayfirst=True)
calendar = set(pd.date_range(dates.min(), dates.max(), freq="D"))
gaps = {store: len(calendar - set(group))
for store, group in dates.groupby(df["store_id"])}
closed = max(gaps, key=gaps.get) # la tienda con más días ausentes
Fíjate en que closed_days son días de calendario, no filas que faltan:
una tienda cerrada tres días con diez SKU deja treinta filas sin escribir, pero la respuesta
es 3.
El holdout es el futuro. Si validas mezclando días, te estás evaluando en un examen que no es el que te van a poner. Y hay una forma de hacerlo mal que parece bien:
valid = df.iloc[-1306:]
train = df.iloc[:-1306]
El archivo no está ordenado por fecha. Ese bloque final va del
2026-01-05 al 2026-07-05: la historia entera. 1 156 de sus
1 306 filas son anteriores al corte que creías estar haciendo, y están entrenando y
validando a la vez.
Aunque estuviera ordenado seguiría estando mal: los días no tienen el mismo número de filas una vez que una tienda cierra.
dates = pd.to_datetime(df["sale_date"],
format="mixed", dayfirst=True)
boundary = dates.max() - pd.Timedelta(days=days - 1)
train = df[dates < boundary].copy()
valid = df[dates >= boundary].copy()
Un corte en el eje del tiempo: train hasta el 2026-06-14,
valid desde el 2026-06-15. 9 894 filas contra 1 306, cada fila
en exactamente un lado, y el último día de entrenamiento estrictamente antes del primero
de validación.
GroupShuffleSplit). ¿Una clase
rara? Estratifica. Un train_test_split por defecto sólo es correcto cuando las
filas son independientes.El holdout trae fecha, tienda, SKU, precio y promoción — nada más. Así que las features tienen que salir de eso. Cuatro que valen:
out["day_of_week"] = dates.dt.dayofweek # el fin de semana se vende distinto
out["is_weekend"] = (dates.dt.dayofweek >= 5).astype(int)
out["day_index"] = (dates - pd.Timestamp("2026-01-05")).dt.days # tendencia: el tiempo como número
out["price_vs_sku_median"] = out["unit_price"] / out["sku"].map(sku_median)
La última es la que mueve la aguja, y encierra la idea entera del problema: el precio absoluto sólo dice qué SKU es; el precio contra la mediana de ese SKU dice si está de oferta, que es lo que mueve unidades.
sku_median = fitted.groupby("sku")["unit_price"].median() # SÓLO del train
fitted = _features(fitted, sku_median)
scored = _features(scored, sku_median) # la MISMA mediana
Eso es un fit disfrazado de pandas: un número aprendido de los datos. Si lo
recalculas sobre el holdout, cada tabla queda medida con una regla distinta — el mismo error
del get_dummies por separado, con otra cara.CATEGORICAL = ["store_id", "sku"]
NUMERIC = ["unit_price", "promo_flag", "day_of_week", "is_weekend", "day_index",
"price_vs_sku_median"]
def fit_predict(train, holdout):
fitted = clean(parse_dates(train)) # fechas, precios, duplicados, devoluciones
scored = parse_prices(parse_dates(holdout)) # fechas y precios. Ni una fila menos
scored["promo_flag"] = pd.to_numeric(scored["promo_flag"], errors="coerce").fillna(0)
sku_median = fitted.groupby("sku")["unit_price"].median()
fitted, scored = _features(fitted, sku_median), _features(scored, sku_median)
model = Pipeline([
("prepare", ColumnTransformer([
("categorical", OneHotEncoder(handle_unknown="ignore", sparse_output=False),
CATEGORICAL),
("numeric", "passthrough", NUMERIC),
])),
("gbm", HistGradientBoostingRegressor(random_state=0)),
])
model.fit(fitted[CATEGORICAL + NUMERIC], fitted["units_sold"])
return np.clip(model.predict(scored[CATEGORICAL + NUMERIC]), 0, None)
Compáralo con Northwind: cambian dos palabras — Regressor en vez de
Classifier, predict en vez de predict_proba(...)[:, 1]
— y el np.clip, porque la demanda no puede ser negativa y un regresor no lo
sabe.
RMSE: más bajo, mejor — es el error medio en unidades, así que un 5,3 quiere decir «me equivoco en unas cinco unidades por fila». Medido sobre los datos del drill:
| Qué predices | RMSE | Qué demuestra |
|---|---|---|
| Un solo número para todo (la media) | 17,78 | El suelo. El umbral del brief es 14,00 |
| La media histórica de cada tienda × SKU | 11,68 | Sin modelo, sólo memoria. Umbral: 9,00 |
| El pipeline de arriba | 5,29 | Precio y promoción dentro. Umbral: 6,50 ✓ |
Y así se mide, con el mismo corte por fecha del T5:
from sklearn.metrics import mean_squared_error
train_part, valid_part = split_last_days(fitted, 28) # los últimos 28 días: el mismo
model.fit(train_part[FEATURES], train_part["units_sold"]) # tamaño que el holdout real
rmse = np.sqrt(mean_squared_error(valid_part["units_sold"],
model.predict(valid_part[FEATURES])))
Un apunte honesto: en estos datos validar al azar da 5,01 y validar por tiempo 5,19 — la mentira es pequeña. Pero el corte por fecha es el que te piden en el T5 (15 puntos), es el que responde a la pregunta que te hacen, y en series con tendencia o estacionalidad fuerte la diferencia deja de ser pequeña.
pred = np.clip(model.predict(X_score), 0, None)
assert len(pred) == len(holdout) # 1 680, ni una más
assert np.isfinite(pred).all() # ni NaN ni inf
El clip en esta semilla no cambia nada (la predicción más baja
es 3,0) y aun así se pone: cuesta una llamada y evita entregar demanda negativa el día que el
modelo extrapole. Lo mismo con el orden: no reordenes el holdout ni por fecha ni por tienda,
aunque parezca más limpio. Se califica fila por fila contra el archivo.
| Lo que ves | Lo que pasó | El arreglo |
|---|---|---|
| time data "30/01/2026" doesn't match format "%Y-%m-%d" | Dos formatos de fecha en una columna. | format="mixed", dayfirst=True. |
| Fechas hasta diciembre en un archivo que acaba en julio | Te faltó dayfirst=True. No hay error: hay 2 124 filas mal. |
Mira min() y max() después de parsear. Siempre. |
NaN en los precios altos |
Quitaste el S/ pero no la coma de los miles. |
str.replace(r"[^0-9.\-]", "", regex=True). |
| El RMSE de validación es bueno y el del holdout es malo | Validaste mezclando días, o con iloc[-n:] sobre un archivo
desordenado. |
Cortar por fecha con una fecha frontera. |
| len(pred) != len(holdout) | Le aplicaste clean() al holdout y te comió filas. |
Al holdout sólo fechas y precios. Nunca de-duplicar ni filtrar. |
| Predicciones negativas | Un regresor no sabe que las unidades no bajan de cero. | np.clip(pred, 0, None). |
Los datos cambian cada día; las trampas no. Abre la práctica y escribe las seis funciones con el reloj corriendo.