Andes Retail · T6 · 35 de los 100 puntos

El mismo pipeline, otro problema

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.

Qué cambia respecto a Northwind

Northwind — churnAndes — demanda
Qué devuelvesUna probabilidad entre 0 y 1Unidades: un número, y no puede ser negativo
ModeloHistGradientBoostingClassifier HistGradientBoostingRegressor
Última líneapredict_proba(X)[:, 1] np.clip(predict(X), 0, None)
MétricaROC-AUC — más alto, mejorRMSE — más bajo, mejor
La particiónAl azar, estratificadaPor fecha. Nunca al azar
El holdout es…Otras cuentas, del mismo periodo El futuro: los 28 días siguientes al último de sales.csv
Filas11 200 en el histórico1 680 a predecir (6 tiendas × 10 SKU × 28 días)
Todo lo demás es idéntico: 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í.

  1. Las fechas: dos formatos, y el error que no avisa

    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:

    Así no

    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.

    Así sí

    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.

    Por eso el T1 se califica sobre el rango y no sobre el dtype. Un 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.
  2. El dinero es texto y el blanco es un cero

    >>> 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)
    Ese 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.
  3. Filas que no son demanda

    Tres cosas distintas que la gente mete en el mismo saco:

    QuéCómo se veQué se hace
    Líneas re-sincronizadasEl mismo row_id dos veces, copia idénticadrop_duplicates(subset="row_id") — es la misma venta, no dos
    Devolucionesunits_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 cerradaNo hay filas. Ninguna. No son ceros Nada que borrar: son días que no existen en el archivo
    Y otra vez: al holdout, nada de esto. El holdout son 1 680 filas que hay que devolver enteras y en orden. drop_duplicates y el filtro de negativos van sólo al train.
  4. La tienda cerrada se busca por ausencia

    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.

  5. La partición por tiempo — aquí es donde se cae la nota

    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:

    Así no — contar filas

    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.

    Así sí — cortar por fecha

    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.

    La regla general, más allá de este caso: el criterio de partición lo dicta la forma en que se usará el modelo. ¿Predices el futuro? Corta por fecha. ¿Varias filas por paciente? Corta por paciente (GroupShuffleSplit). ¿Una clase rara? Estratifica. Un train_test_split por defecto sólo es correcto cuando las filas son independientes.
  6. Features que existirán el día del scoring

    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.

    Y ojo con dónde se calcula esa mediana:
    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.
  7. Las ocho líneas, otra vez

    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.

  8. Qué número es bueno, y contra qué se compara

    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é predicesRMSEQué 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 × SKU11,68 Sin modelo, sólo memoria. Umbral: 9,00
    El pipeline de arriba5,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.

  9. Antes de devolver

    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.

  10. Los errores de este caso

    Lo que vesLo 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).

Ahora hazlo tú

Los datos cambian cada día; las trampas no. Abre la práctica y escribe las seis funciones con el reloj corriendo.