En 09-01 volviste a los algoritmos del módulo 3 y comprobaste que la disciplina (representar bien, definir el objetivo, validar) importa más que el algoritmo concreto. Ahora toca la misma disciplina con el aprendizaje automático del módulo 4: cinco prácticas con los generadores de NovaMarket que ya conoces (generar_pedidos_ml, ensuciar_pedidos, preparar_pedidos, crear_preparacion, generar_demanda_semanal, generar_clientes_ml), cada una con una línea base, una comparación honesta y una decisión de negocio al final. Las cifras de referencia del módulo 4 (AUC 0,844, coste 2.485 € → 1.610 € con umbral 0,2, MAE 16,8 de la demanda, tres segmentos de clientes) son tu vara de medir; en varias prácticas descubrirás que "mejorar" es más difícil de lo que parece.

Cómo trabajar la lección: cada práctica tiene un enunciado con preguntas concretas; escribe tu solución antes de leer la nuestra, ejecútala, y compara cifras (varían unas centésimas según la versión de scikit-learn). Necesitas el módulo novamarket_ml.py de 04-03/04-05 (las funciones citadas arriba) en la misma carpeta, y pandas, numpy y scikit-learn. Tiempo orientativo: 30-45 minutos por práctica.

Contenido

  1. Práctica 1: exploración y limpieza de un lote sucio con informe de decisiones
  2. Práctica 2: una característica nueva en el pipeline de devoluciones, comparada con honestidad
  3. Práctica 3: previsión de demanda con calendario y retardos, lineal frente a gradient boosting
  4. Práctica 4: segmentación de clientes con k-means y una acción por segmento
  5. Práctica 5: ajuste de umbral por coste y comprobación de equidad entre zonas
  6. Errores Comunes y Consejos
  7. Conclusión

  1. Práctica 1: exploración y limpieza de un lote sucio con informe de decisiones

Recordatorio (04-03, secciones 2-3 y 8). ensuciar_pedidos produce una copia "realista" de los pedidos con nulos, duplicados, un atípico, fechas, texto y una fuga (motivo_devolucion, que solo existe si el pedido se devolvió). Antes de modelar hay que medir cada problema y decidir qué hacer con él, sin mirar la etiqueta más de lo imprescindible.

Enunciado. Marta recibe el lote pedidos.csv de septiembre-noviembre (ensuciar_pedidos(generar_pedidos_ml(3000, 42), 42)). Escribe un script que produzca un informe de calidad con: (a) forma y tipos; (b) nulos por columna, en número y porcentaje, y una comprobación de si los nulos de dias_entrega se reparten igual entre devueltos y no devueltos; (c) filas duplicadas (exactas y por id_pedido); (d) atípicos de importe con la regla de 3 × IQR y a ojo; (e) recuento de categorías y rango de fechas; (f) las columnas que serían fuga. Después escribe limpiar_lote(lote) que devuelva el lote limpio y un diccionario con lo que ha hecho, y justifica cada decisión en una tabla.

Pistas: isna().sum(), duplicated(keep=False), quantile([0.25, 0.75]), pd.crosstab(lote["motivo_devolucion"], lote["devuelto"]).

Solución

import numpy as np, pandas as pd
from novamarket_ml import generar_pedidos_ml, ensuciar_pedidos, preparar_pedidos

lote = ensuciar_pedidos(generar_pedidos_ml(3000, 42), 42)
print(lote.shape); print(lote.dtypes.to_dict())
nulos = lote.isna().sum(); nulos = nulos[nulos > 0]
print(pd.DataFrame({"nulos": nulos, "%": (nulos / len(lote) * 100).round(1)}))
print("devuelto si dias_entrega nulo:", lote.loc[lote["dias_entrega"].isna(), "devuelto"].mean().round(3),
      "| no nulo:", lote.loc[lote["dias_entrega"].notna(), "devuelto"].mean().round(3))
print("Duplicados exactos:", lote.duplicated().sum(), "| id repetidos:", lote["id_pedido"].duplicated().sum())
print(lote[lote.duplicated(keep=False)].sort_values("id_pedido")[["id_pedido", "id_cliente", "fecha_pedido", "importe", "devuelto"]])
q1, q3 = lote["importe"].quantile([0.25, 0.75]); techo = q3 + 3 * (q3 - q1)
print(f"IQR: q1={q1:.1f} q3={q3:.1f} techo 3xIQR={techo:.1f} | por encima: {(lote['importe'] > techo).sum()}")
print(lote.loc[lote["importe"] > 1000, ["id_pedido", "importe", "num_articulos", "categoria", "devuelto"]])
print("Máximo sin el atípico:", lote.loc[lote["importe"] < 5000, "importe"].max())
for c in ["categoria", "codigo_postal_zona", "metodo_pago", "tipo_envio"]:
    print(c, lote[c].value_counts(dropna=False).to_dict())
print("Fechas:", lote["fecha_pedido"].min().date(), "->", lote["fecha_pedido"].max().date())
print(pd.crosstab(lote["motivo_devolucion"], lote["devuelto"]))
print("Tasa de devolución:", lote["devuelto"].mean().round(3), "| por zona:", lote.groupby("codigo_postal_zona")["devuelto"].mean().round(3).to_dict())

def limpiar_lote(lote, techo_importe=5000):
    informe = {}
    limpio = lote.drop_duplicates(); informe["duplicados_eliminados"] = len(lote) - len(limpio)
    atipico = limpio["importe"] > techo_importe
    informe["atipicos_importe_eliminados"] = int(atipico.sum()); limpio = limpio[~atipico].copy()
    informe["nulos_que_imputara_el_pipeline"] = limpio.isna().sum()[lambda s: s > 0].to_dict()
    limpio = limpio.drop(columns=["motivo_devolucion"]); informe["fugas_eliminadas"] = ["motivo_devolucion"]
    return limpio, informe

limpio, informe = limpiar_lote(lote); print(limpio.shape, informe)
X, y = preparar_pedidos(lote); print("preparar_pedidos ->", X.shape, "tasa", y.mean().round(4))
(3003, 13)
              nulos    %
importe          56  1.9
dias_entrega    153  5.1
metodo_pago     100  3.3
devuelto si dias_entrega nulo: 0.209 | no nulo: 0.162
Duplicados exactos: 3 | id repetidos: 3
IQR: q1=63.8 q3=163.6 techo 3xIQR=463.1 | por encima: 13
    id_pedido  importe  num_articulos    categoria  devuelto
659   P100017  99999.0              2  electronica        0
Máximo sin el atípico: 632.61
metodo_pago {'tarjeta': 1624, 'paypal': 730, 'bizum': 310, 'contrareembolso': 239, nan: 100}
Fechas: 2025-09-01 -> 2025-11-29
devuelto              0    1
motivo_devolucion
                   2509    0
defectuoso            0  126
llego tarde           0  124
no le gusta           0  131
talla/modelo          0  113
Tasa de devolución: 0.165 | por zona: {'A': 0.162, 'B': 0.168, 'C': 0.164}
(2999, 12) {'duplicados_eliminados': 3, 'atipicos_importe_eliminados': 1, 'nulos_que_imputara_el_pipeline': {'importe': 56, 'dias_entrega': 153, 'metodo_pago': 100}, 'fugas_eliminadas': ['motivo_devolucion']}
preparar_pedidos -> (2999, 15) tasa 0.1644
Hallazgo Decisión Justificación
3 filas duplicadas exactas (mismo id_pedido, cliente, fecha, importe) Eliminar Un pedido no puede aparecer dos veces; contarlo doble sesga el entrenamiento y la evaluación
importe = 99.999 € en P100017 (2 artículos de electrónica) Eliminar la fila Es un error de captura, no un pedido: el siguiente máximo son 632 €. Los 12 restantes por encima de 3 × IQR (hasta 632 €) son pedidos caros legítimos y se conservan
Nulos: dias_entrega 5,1 %, metodo_pago 3,3 %, importe 1,9 % Imputar dentro del Pipeline (mediana / más frecuente) Son pocos y no se puede prescindir del 10 % de filas; imputar en el pipeline evita fugas. La tasa de devolución con dias_entrega nulo (0,209 frente a 0,162, sobre 153 filas) está dentro del margen del azar, pero es la comprobación que hay que hacer: si fuera grande y persistente, el nulo sería informativo y convendría un indicador dias_entrega_faltante (variante)
motivo_devolucion solo informado si devuelto = 1 Eliminar la columna Fuga de información: se conoce después de la devolución
id_pedido, id_cliente, fecha_pedido No son características directas Se usan para derivar (día de la semana, historial por cliente) y luego se descartan
Categorías sin errores tipográficos; tasa por zona ≈ 16 % en A, B y C Nada Coherente con la construcción de los datos (la zona no influye)

Retroalimentación

  • Error típico: eliminar los 13 "atípicos" de la regla IQR. Las reglas estadísticas señalan candidatos; la decisión la da el contexto: un pedido de 600 € es normal en electrónica. Igual de malo es imputar el 99.999 con la mediana en lugar de eliminarlo (el error de captura seguiría en las estadísticas del resto de columnas de esa fila).
  • Otro: imputar con fillna antes de dividir; hacerlo en el Pipeline es lo que protege de fugas (04-03).
  • Variantes: añade dias_entrega_faltante como característica binaria y mide en 09-02.2 si aporta; simula un lote con el 30 % de importe nulo y decide de nuevo (con tanto nulo, la imputación por mediana empobrece la variable); escribe el informe como función informe_calidad(df) reutilizable en novamarket_ia/.

  1. Práctica 2: una característica nueva en el pipeline de devoluciones, comparada con honestidad

Recordatorio (04-03 sección 9, 04-05 secciones 5 y 8). El pipeline de referencia (crear_preparacion() + regresión logística, 21 columnas) obtiene AUC 0,844 en el test de 750 pedidos y 0,836 ± 0,016 en validación cruzada estratificada de 5 pliegues; con umbral 0,2 el coste es 1.610 € en test. Una comparación honesta usa la misma división y, mejor, validación cruzada; y decide con la métrica de negocio, no solo con el AUC.

Enunciado. La logística no puede modelar por sí sola la interacción "cliente nuevo y pedido caro" que la verdad oculta de los datos contiene (recuerda 04-01). Añade tres candidatas: nuevo_x_importe (= cliente_nuevo × importe), categoria_riesgo (1 si electrónica o informática) y dias_x_nuevo (= cliente_nuevo × dias_entrega). Para cada una: (a) amplía la preparación (ColumnTransformer) sin tocar el resto; (b) mide AUC con StratifiedKFold(5, shuffle=True, random_state=42) y el coste de negocio con cross_val_predict a los umbrales 0,2, 0,3 y 0,5; (c) compara con la referencia en la misma validación y también en la división de 04-05 (train_test_split(..., test_size=0.25, random_state=42, stratify=y)); (d) decide si Marta debería cambiar el modelo en producción.

Solución

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, RepeatedStratifiedKFold, cross_val_score, cross_val_predict, train_test_split
from sklearn.metrics import roc_auc_score, confusion_matrix
from novamarket_ml import crear_preparacion, NUMERICAS, BINARIAS, NOMINALES, ORDINALES

def coste_negocio(m, coste_fp=5, coste_fn=30):
    vn, fp, fn, vp = m.ravel(); return fp * coste_fp + fn * coste_fn

def anadir_caracteristicas(X):
    X = X.copy()
    X["nuevo_x_importe"] = X["cliente_nuevo"] * X["importe"].fillna(X["importe"].median())
    X["categoria_riesgo"] = X["categoria"].isin(["electronica", "informatica"]).astype(int)
    X["dias_x_nuevo"] = X["cliente_nuevo"] * X["dias_entrega"].fillna(X["dias_entrega"].median())
    return X

def crear_preparacion_v2(numericas_extra=(), binarias_extra=()):
    return ColumnTransformer([
        ("num", Pipeline([("imputar", SimpleImputer(strategy="median")), ("escalar", StandardScaler())]), NUMERICAS + list(numericas_extra)),
        ("bin", "passthrough", BINARIAS + list(binarias_extra)),
        ("nom", Pipeline([("imputar", SimpleImputer(strategy="most_frequent")), ("onehot", OneHotEncoder(handle_unknown="ignore"))]), NOMINALES),
        ("ord", OrdinalEncoder(categories=[["estandar", "rapido", "urgente"]]), ORDINALES)])

X2 = anadir_caracteristicas(X)
cv = StratifiedKFold(5, shuffle=True, random_state=42)
def evaluar(nombre, prep, Xd):
    pipe = Pipeline([("prep", prep), ("modelo", LogisticRegression(max_iter=2000))])
    aucs = cross_val_score(pipe, Xd, y, cv=cv, scoring="roc_auc")
    prob = cross_val_predict(pipe, Xd, y, cv=cv, method="predict_proba")[:, 1]
    costes = {u: int(coste_negocio(confusion_matrix(y, (prob >= u).astype(int)))) for u in (0.2, 0.3, 0.5)}
    print(f"{nombre:22s} AUC CV {aucs.mean():.4f} ± {aucs.std():.3f}  coste CV {costes}")

evaluar("referencia", crear_preparacion(), X)
evaluar("+ nuevo_x_importe", crear_preparacion_v2(["nuevo_x_importe"]), X2)
evaluar("+ categoria_riesgo", crear_preparacion_v2([], ["categoria_riesgo"]), X2)
evaluar("+ dias_x_nuevo", crear_preparacion_v2(["dias_x_nuevo"]), X2)
evaluar("+ las tres", crear_preparacion_v2(["nuevo_x_importe", "dias_x_nuevo"], ["categoria_riesgo"]), X2)

# validación cruzada repetida (5 x 4) con diferencias emparejadas por pliegue
rcv = RepeatedStratifiedKFold(n_splits=5, n_repeats=4, random_state=1)
ref = Pipeline([("prep", crear_preparacion()), ("modelo", LogisticRegression(max_iter=2000))])
nue = Pipeline([("prep", crear_preparacion_v2(["nuevo_x_importe"])), ("modelo", LogisticRegression(max_iter=2000))])
a, b = cross_val_score(ref, X, y, cv=rcv, scoring="roc_auc"), cross_val_score(nue, X2, y, cv=rcv, scoring="roc_auc")
print(f"20 pliegues: ref {a.mean():.4f}  nueva {b.mean():.4f}  diferencia {np.mean(b - a):+.4f} ± {np.std(b - a):.4f}  a favor en {(b > a).sum()}/20")

# la división de 04-05
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
for nombre, prep, tr, te in [("referencia", crear_preparacion(), Xtr, Xte),
                             ("+ nuevo_x_importe", crear_preparacion_v2(["nuevo_x_importe"]), anadir_caracteristicas(Xtr), anadir_caracteristicas(Xte))]:
    p = Pipeline([("prep", prep), ("modelo", LogisticRegression(max_iter=2000))]).fit(tr, ytr); prob = p.predict_proba(te)[:, 1]
    print(f"{nombre:22s} AUC test {roc_auc_score(yte, prob):.4f}  coste test", {u: int(coste_negocio(confusion_matrix(yte, (prob >= u).astype(int)))) for u in (0.2, 0.5)})
p = nue.fit(X2, y); nombres, coef = p["prep"].get_feature_names_out(), p["modelo"].coef_[0]
print("Coeficientes mayores:", [(n, round(float(c), 2)) for n, c in sorted(zip(nombres, coef), key=lambda t: -abs(t[1]))[:5]])
referencia             AUC CV 0.8365 ± 0.016  coste CV {0.2: 6635, 0.3: 7220, 0.5: 9455}
+ nuevo_x_importe      AUC CV 0.8409 ± 0.014  coste CV {0.2: 6725, 0.3: 7410, 0.5: 9325}
+ categoria_riesgo     AUC CV 0.8365 ± 0.016  coste CV {0.2: 6635, 0.3: 7220, 0.5: 9450}
+ dias_x_nuevo         AUC CV 0.8366 ± 0.016  coste CV {0.2: 6630, 0.3: 7200, 0.5: 9490}
+ las tres             AUC CV 0.8406 ± 0.014  coste CV {0.2: 6680, 0.3: 7420, 0.5: 9305}
20 pliegues: ref 0.8333  nueva 0.8379  diferencia +0.0046 ± 0.0060  a favor en 16/20
referencia             AUC test 0.8444  coste test {0.2: 1610, 0.5: 2485}
+ nuevo_x_importe      AUC test 0.8334  coste test {0.2: 1710, 0.5: 2360}
Coeficientes mayores: [('num__nuevo_x_importe', 0.83), ('num__importe', 0.76), ('num__dias_entrega', 0.66), ('nom__categoria_electronica', 0.54), ('bin__cliente_nuevo', 0.45)]

Lectura, en el orden en que conviene razonar:

  • categoria_riesgo no cambia nada (0,8365 y los mismos costes): es una combinación lineal de las columnas one-hot de categoria que ya existen; la logística no gana información con una columna redundante. dias_x_nuevo tampoco aporta (la verdad oculta no contiene esa interacción).
  • nuevo_x_importe sí mejora el AUC de forma pequeña pero consistente: +0,0046 de media, a favor en 16 de 20 pliegues, y se convierte en el coeficiente más grande del modelo (0,83), como cabía esperar de la interacción que la verdad oculta contiene. Pero la mejora no se traduce en euros al umbral de trabajo: al 0,2 el coste en validación cruzada es algo peor (6.725 frente a 6.635 €), y solo mejora al 0,5 (donde nadie opera).
  • La división única de 04-05 dice lo contrario que la validación cruzada (0,833 frente a 0,844): con 750 pedidos y 123 devoluciones, ±0,01 de AUC es ruido de muestreo. Es la razón por la que en 04-05 insistimos en la validación cruzada.
  • Decisión: no cambiar el modelo en producción. La ganancia es marginal en la métrica técnica y nula o negativa en la de negocio; a igualdad, gana el más simple (04-06). Marta anota la característica como candidata para el próximo reentrenamiento, cuando haya más datos y se pueda evaluar con el coste real.

Retroalimentación

  • Error típico: comparar el nuevo pipeline con el AUC "de memoria" (0,844) obtenido en otra división; la comparación solo vale en la misma validación. Otro: crear la interacción con importe sin imputar y dejar que el SimpleImputer de la rama numérica imponga la mediana de un producto (aquí lo hacemos explícito con fillna en la función).
  • Que el AUC suba y el coste no baje no es una contradicción: el AUC mide la ordenación global de todos los pares; el coste al 0,2 depende de cómo se reordenan unos pocos pedidos alrededor de ese umbral.
  • Variantes: prueba HistGradientBoostingClassifier, que captura interacciones sin que se las des; añade dias_entrega_faltante de la práctica 1; repite con n_repeats=10 y observa cómo se estrecha la desviación de la diferencia.

  1. Práctica 3: previsión de demanda con calendario y retardos, lineal frente a gradient boosting

Recordatorio (04-04 sección 2, 04-05 secciones 6 y 8). La demanda semanal del robot NovaClean (104 semanas) tiene tendencia (2,5/semana), estacionalidad anual y un pico de Black Friday. La regresión lineal con semana, sen, cos y black_friday da MAE 16,8 en las semanas 79-104 y 18,6 de media con TimeSeriesSplit(4, test_size=13); la línea base "última semana" da 39. Los pliegues temporales entrenan con el pasado y evalúan el trimestre siguiente.

Enunciado. (a) Escribe construir_caracteristicas(d, lags=(1, 2, 4), con_black_friday=True) que añada calendario (sen, cos, black_friday), los retardos lag_k (unidades de hace k semanas) y media_4 (media de las 4 semanas anteriores), y elimine las filas iniciales sin retardos. (b) Con TimeSeriesSplit(n_splits=4, test_size=13), calcula el MAE medio de: la línea base ingenua (lag_1), la línea base media_4, la lineal solo con calendario, la lineal con calendario + retardos, y HistGradientBoostingRegressor (max_iter=200, learning_rate=0.05, max_depth=3, min_samples_leaf=5) con calendario + retardos. (c) Repite lineal y HGB sin black_friday y mira el error concreto en la semana 100 (el segundo Black Friday). (d) Explica los resultados y elige un modelo para Marta.

Solución

from novamarket_ml import generar_demanda_semanal
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import mean_absolute_error

d = generar_demanda_semanal(104, 42)
def construir_caracteristicas(d, lags=(1, 2, 4), con_black_friday=True):
    f = d.copy()
    f["sen"], f["cos"] = np.sin(2 * np.pi * f["semana"] / 52), np.cos(2 * np.pi * f["semana"] / 52)
    f["black_friday"] = ((f["semana"] - 1) % 52 == 47).astype(int)
    for k in lags: f[f"lag_{k}"] = f["unidades"].shift(k)                 # unidades de hace k semanas
    f["media_4"] = f["unidades"].shift(1).rolling(4).mean()               # media de las 4 anteriores
    calendario = ["semana", "sen", "cos"] + (["black_friday"] if con_black_friday else [])
    retardos = [f"lag_{k}" for k in lags] + ["media_4"]
    return f.dropna().reset_index(drop=True), calendario, retardos

tscv = TimeSeriesSplit(n_splits=4, test_size=13)
def mae_cv(f, cols, crear_modelo=None, nombre=""):
    maes = []
    for tr, te in tscv.split(f):
        pred = f.loc[te, cols[0]] if crear_modelo is None else crear_modelo().fit(f.loc[tr, cols], f.loc[tr, "unidades"]).predict(f.loc[te, cols])
        maes.append(mean_absolute_error(f.loc[te, "unidades"], pred))
    print(f"{nombre:42s} MAE por bloque {np.round(maes, 1)}  media {np.mean(maes):.1f}")

lin = LinearRegression
hgb = lambda: HistGradientBoostingRegressor(max_iter=200, learning_rate=0.05, max_depth=3, min_samples_leaf=5, random_state=0)
f, cal, ret = construir_caracteristicas(d)
print("Bloques de test:", [(int(f.loc[te, 'semana'].min()), int(f.loc[te, 'semana'].max())) for _, te in tscv.split(f)])
mae_cv(f, ["lag_1"], None, "Línea base ingenua (última semana)")
mae_cv(f, ["media_4"], None, "Línea base media de 4 semanas")
mae_cv(f, cal, lin, "Lineal solo calendario (04-05)")
mae_cv(f, cal + ret, lin, "Lineal calendario + retardos")
mae_cv(f, cal + ret, hgb, "HGB calendario + retardos")
f0, cal0, ret0 = construir_caracteristicas(d, con_black_friday=False)
mae_cv(f0, cal0 + ret0, lin, "Lineal SIN black_friday")
mae_cv(f0, cal0 + ret0, hgb, "HGB SIN black_friday")
for nombre, (ff, cols) in {"lineal con BF": (f, cal + ret), "lineal sin BF": (f0, cal0 + ret0), "HGB con BF": (f, cal + ret)}.items():
    tr, te = ff["semana"] <= 91, ff["semana"] > 91
    m = (lin if "lineal" in nombre else hgb)().fit(ff.loc[tr, cols], ff.loc[tr, "unidades"])
    err = pd.Series(np.abs(ff.loc[te, "unidades"].values - m.predict(ff.loc[te, cols])), index=ff.loc[te, "semana"])
    print(f"{nombre:14s} semanas 92-104: MAE {err.mean():.1f} | error semana 100 (Black Friday): {err[100]:.1f} | sin la 100: {err.drop(100).mean():.1f}")
Bloques de test: [(53, 65), (66, 78), (79, 91), (92, 104)]
Línea base ingenua (última semana)         MAE por bloque [31.3 18.7 15.8 52.8]  media 29.7
Línea base media de 4 semanas              MAE por bloque [27.4 19.7 12.1 50.8]  media 27.5
Lineal solo calendario (04-05)             MAE por bloque [38.1 19.  13.2 20.1]  media 22.6
Lineal calendario + retardos               MAE por bloque [66.4 19.  13.7 19.8]  media 29.7
HGB calendario + retardos                  MAE por bloque [49.8 50.5 12.4 48.9]  media 40.4
Lineal SIN black_friday                    MAE por bloque [168.4  16.5  14.5  40. ]  media 59.9
HGB SIN black_friday                       MAE por bloque [49.8 50.5 12.4 48.9]  media 40.4
lineal con BF  semanas 92-104: MAE 19.8 | error semana 100 (Black Friday): 26.4 | sin la 100: 19.2
lineal sin BF  semanas 92-104: MAE 40.0 | error semana 100 (Black Friday): 214.5 | sin la 100: 25.5
HGB con BF     semanas 92-104: MAE 48.9 | error semana 100 (Black Friday): 129.9 | sin la 100: 42.2

Cuatro conclusiones que Marta debe poder explicar:

  • La lineal con calendario sigue ganando (22,6). Los retardos no la mejoran (bloques 2-4 iguales) y la empeoran en el primer bloque (66,4): con solo 48 semanas de entrenamiento y el pico del Black Friday metido dentro de lag_1, lag_4 y media_4 de las semanas 49-52, los coeficientes de los retardos se estiman mal. Y no pueden ayudar mucho: el ruido de esta serie es independiente de una semana a otra (la autocorrelación de los residuos del modelo de calendario es 0,09), así que los retardos no contienen información que el calendario no tenga ya. En series reales con rachas (promociones que se prolongan, roturas de stock) sí la contendrían.
  • Gradient boosting pierde con claridad (40,4): un modelo de árboles no extrapola la tendencia (predice como mucho el máximo visto en entrenamiento, y la serie crece 2,5 unidades/semana) y con 48-87 filas está hambriento de datos. Con min_samples_leaf=1 baja a 34,8; con la corrección clásica (lineal para tendencia y estacionalidad, HGB solo para los residuos) a 24,7: sigue sin batir a la lineal sola.
  • Black Friday: quitar la variable dispara el error de la lineal en la semana 100 (214 unidades: predice ~600 y se venden 825) y contamina el ajuste del primer bloque (168), porque el pico de la semana 48 se ajusta como si fuera estacionalidad. Para HGB no cambia nada: con min_samples_leaf=5, un árbol no puede aislar una hoja con una sola semana de Black Friday, así que ignora la variable aunque se la des. Es un ejemplo de por qué las reglas de negocio ("la semana 47 se vende ×1,4") a veces valen más que el modelo.
  • Elección: la lineal con calendario, MAE ≈ 20 unidades sobre ventas de 550-800, frente a 27-30 de las líneas base. Y una regla de mantenimiento: reentrenar cada trimestre, porque el bloque de 13 semanas más reciente siempre está fuera del entrenamiento.

Retroalimentación

  • Error típico: construir los retardos y no eliminar las primeras filas (quedan NaN), o calcularlos con shift(-1) (usar el futuro: fuga). Otro: evaluar con KFold barajado: el MAE bajaría a ~17 y en producción no se cumpliría (04-05).
  • Fíjate en que la línea base ingenua es peor en los bloques 1 y 4 (31 y 53): son los que contienen el Black Friday, y "repetir la última semana" falla dos veces (no lo anticipa y luego lo prolonga).
  • Variantes: añade lag_52 (misma semana del año anterior) más la tendencia (ejercicio 3 de 04-05); usa generar_demanda_semanal(208) (4 años) y comprueba si con más datos HGB se acerca; prueba Ridge con los retardos para estabilizar el primer bloque.

  1. Práctica 4: segmentación de clientes con k-means y una acción por segmento

Recordatorio (04-02 sección 5, 04-04 sección 8). k-means agrupa por distancia a k centroides; exige escalar las variables; la inercia baja siempre al subir k, así que se busca el "codo", y se complementa con el coeficiente de silueta (más alto, mejor separación) y con la interpretabilidad de negocio. generar_clientes_ml(600, 42) tiene tres tipos ocultos (ocasionales, habituales, grandes compradores).

Enunciado. (a) Escala gasto_anual, num_pedidos y antiguedad_meses y calcula inercia y silueta para k de 2 a 8. (b) Elige k y justifícalo. (c) Describe cada segmento (tamaño, medias, ticket medio = gasto/pedidos, porcentaje del gasto total) y propón una acción de negocio por segmento. (d) Comprueba qué pasa con k = 4 (¿qué segmento se divide y por qué?) y qué pasa si no escalas.

Solución

from novamarket_ml import generar_clientes_ml
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

cl = generar_clientes_ml(600, 42)
esc = StandardScaler(); Xc = esc.fit_transform(cl)
print("| k | inercia | silueta |")
for k in range(2, 9):
    km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(Xc)
    print(f"| {k} | {km.inertia_:.0f} | {silhouette_score(Xc, km.labels_):.3f} |")
km = KMeans(n_clusters=3, n_init=10, random_state=42).fit(Xc)
cl["segmento"] = km.labels_; cl["ticket_medio"] = cl["gasto_anual"] / cl["num_pedidos"]
perfil = cl.groupby("segmento").agg(n=("gasto_anual", "size"), gasto=("gasto_anual", "mean"), pedidos=("num_pedidos", "mean"),
                                    ticket=("ticket_medio", "mean"), antiguedad=("antiguedad_meses", "mean")).round(1)
perfil["% gasto total"] = (cl.groupby("segmento")["gasto_anual"].sum() / cl["gasto_anual"].sum() * 100).round(1)
print(perfil)
km4 = KMeans(n_clusters=4, n_init=10, random_state=42).fit(Xc)
print(pd.crosstab(cl["segmento"], km4.labels_, rownames=["k=3"], colnames=["k=4"]))
print(cl.groupby(km4.labels_)[["gasto_anual", "num_pedidos", "antiguedad_meses"]].mean().round(1))
km_sin = KMeans(n_clusters=3, n_init=10, random_state=42).fit(cl[["gasto_anual", "num_pedidos", "antiguedad_meses"]])
print(pd.crosstab(cl["segmento"], km_sin.labels_, rownames=["escalado"], colnames=["sin escalar"]))
k inercia silueta
2 796 0,554
3 400 0,572
4 299 0,534
5 254 0,524
6 219 0,416
8 174 0,398
          n   gasto  pedidos  ticket  antiguedad  % gasto total
segmento
0       351   185.8      2.5    95.0         9.5           20.5
1        52  2206.7     20.0   117.4        38.9           36.1
2       197   699.9      9.2    88.0        28.5           43.4
k=4   0    1   2    3
k=3
0    23  328   0    0
1     0    0  51    1
2    85    0   0  112
   gasto_anual  num_pedidos  antiguedad_meses
0        694.2          9.1              16.7
1        152.9          2.1               9.5
2       2229.8         20.1              38.6
3        699.1          9.0              36.2
sin escalar    0   1    2
escalado
0             27   0  324
1              3  49    0
2            187   1    9

La inercia (1.800 con k = 1) cae a 796 con 2 grupos y a 400 con 3, y después baja despacio (299, 254, 219): el codo está en 3, y la silueta también es máxima en 3 (0,572). Con otra semilla del generador (generar_clientes_ml(600, 7)) la silueta vuelve a ser máxima en 3 (0,583): la conclusión es estable. Los segmentos y sus acciones:

Segmento Perfil Acción propuesta
0 · Ocasionales (351 clientes, 58 %) 2-3 pedidos/año, 186 € de gasto, 9 meses de antigüedad; 20 % del gasto Activación: cupón de segunda compra y recomendaciones (caso 1); medir la conversión a "habitual" a los 6 meses
2 · Habituales (197, 33 %) 9 pedidos/año, 700 €, ticket 88 €, 28 meses; 43 % del gasto Fidelización: envío gratis por suscripción, avisos de reposición (cápsulas NovaBrew, filtros NovaClean); es el segmento donde más vale evitar la fuga
1 · Grandes compradores (52, 9 %) 20 pedidos/año, 2.200 €, ticket 117 €, 39 meses; 36 % del gasto Gestión personal: acceso anticipado a lanzamientos (NovaBook, NovaView), atención prioritaria en incidencias; vigilar señales de abandono uno a uno

Con k = 4 no aparece un tipo nuevo: se parte el segmento habitual por antigüedad (17 frente a 36 meses), una división que puede ser útil para marketing pero que la silueta no respalda. Sin escalar, la variable de mayor varianza (gasto_anual, desviación 599 frente a 6 y 13) domina la distancia y 36 clientes cambian de grupo: los grupos pasan a ser "tramos de gasto" y num_pedidos y la antigüedad casi no cuentan.

Retroalimentación

  • Error típico: elegir k solo por la inercia mínima (siempre sería el máximo k) o presentar los centroides en la escala estandarizada (esc.inverse_transform(km.cluster_centers_) los devuelve a euros y pedidos).
  • Otro: interpretar los números de segmento (0, 1, 2) como un orden; son etiquetas arbitrarias que cambian con la semilla. Nómbralos por su perfil.
  • Variantes: añade ticket_medio como cuarta variable y observa si cambia la partición; prueba AgglomerativeClustering y compara; calcula el valor anual que se perdería si el 10 % de los habituales pasara a ocasionales (es el argumento para Diego).

  1. Práctica 5: ajuste de umbral por coste y comprobación de equidad entre zonas

Recordatorio (04-05 sección 5, 02-04 secciones 9-10). El umbral no es 0,5 por decreto: se elige recorriendo umbrales con coste_negocio (5 € por falsa alarma, 30 € por devolución no anticipada) y añadiendo una banda de revisión humana. Y toda decisión sobre personas se comprueba por grupos: la paridad de tasas de marcado con la regla de los cuatro quintos (ratio del grupo menos marcado sobre el más marcado ≥ 0,8). codigo_postal_zona no influye en la etiqueta por construcción, así que un modelo bien hecho debería salir paritario.

Enunciado. Con el pipeline de referencia entrenado en la división de 04-05: (a) calcula la tabla de FP, FN, marcados y coste para umbrales de 0,05 a 0,70 con tres estructuras de coste: (5 €, 30 €), (8 €, 20 €) y (5 €, 50 €); ¿en qué umbral está el mínimo de cada una? (b) Con el umbral 0,2, calcula la tasa de marcado por zona en el test, el ratio de impacto y un intervalo por bootstrap del ratio; repite con las predicciones de validación cruzada sobre los 2.999 pedidos. (c) Mira los coeficientes de la zona en el modelo, y las tasas de falsos negativos y falsos positivos por zona. (d) Simula un escenario en el que la zona C tuviera muchos más clientes nuevos y observa qué pasa con el ratio: ¿cómo detectarías y qué harías si el resultado no fuera paritario?

Solución

X, y = preparar_pedidos(ensuciar_pedidos(generar_pedidos_ml(3000, 42), 42))
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
pipe = Pipeline([("prep", crear_preparacion()), ("modelo", LogisticRegression(max_iter=1000))]).fit(Xtr, ytr)
prob = pipe.predict_proba(Xte)[:, 1]
filas = []
for u in np.round(np.arange(0.05, 0.71, 0.05), 2):
    m = confusion_matrix(yte, (prob >= u).astype(int))
    filas.append({"umbral": u, "FP": m[0, 1], "FN": m[1, 0], "marcados": int((prob >= u).sum()),
                  "coste 5/30": coste_negocio(m), "coste 8/20": coste_negocio(m, 8, 20), "coste 5/50": coste_negocio(m, 5, 50)})
tabla = pd.DataFrame(filas); print(tabla.to_string(index=False))
for c in ["coste 5/30", "coste 8/20", "coste 5/50"]:
    print(c, "-> mínimo", tabla[c].min(), "€ en umbral", tabla.loc[tabla[c].idxmin(), "umbral"])

def tasas_marcado(zonas, marcado):
    t = pd.Series(marcado).groupby(np.asarray(zonas)).mean()
    return t.round(3).to_dict(), round(float(t.min() / t.max()), 3)
te = Xte.assign(prob=prob, y=yte.values, marcado=(prob >= 0.2).astype(int))
print("Test 750, umbral 0,2:", tasas_marcado(te["codigo_postal_zona"], te["marcado"]), "| n por zona:", te.groupby("codigo_postal_zona").size().to_dict())
print("Tasa real de devolución por zona (test):", te.groupby("codigo_postal_zona")["y"].mean().round(3).to_dict())
rng = np.random.default_rng(0)
ratios = [tasas_marcado(*te.iloc[rng.integers(0, len(te), len(te))][["codigo_postal_zona", "marcado"]].T.values)[1] for _ in range(300)]
print("Ratio bootstrap 2,5-97,5 %:", np.percentile(ratios, [2.5, 97.5]).round(3))
prob_cv = cross_val_predict(Pipeline([("prep", crear_preparacion()), ("modelo", LogisticRegression(max_iter=1000))]), X, y, cv=cv, method="predict_proba")[:, 1]
print("CV 2999, umbral 0,2:", tasas_marcado(X["codigo_postal_zona"], (prob_cv >= 0.2).astype(int)))
print("FNR por zona:", te[te.y == 1].groupby("codigo_postal_zona")["marcado"].apply(lambda s: round(1 - s.mean(), 3)).to_dict(),
      "| FPR por zona:", te[te.y == 0].groupby("codigo_postal_zona")["marcado"].mean().round(3).to_dict())
nombres, coef = pipe["prep"].get_feature_names_out(), pipe["modelo"].coef_[0]
print("Coeficientes de zona:", {n.split("_")[-1]: round(float(c), 3) for n, c in zip(nombres, coef) if "zona" in n})
X_sim = Xte.copy(); en_c = X_sim["codigo_postal_zona"] == "C"          # escenario: el 60 % de C pasa a cliente nuevo
X_sim.loc[en_c, "cliente_nuevo"] = np.where(rng.random(en_c.sum()) < 0.6, 1, X_sim.loc[en_c, "cliente_nuevo"])
print("Escenario proxy (más nuevos en C):", tasas_marcado(X_sim["codigo_postal_zona"], (pipe.predict_proba(X_sim)[:, 1] >= 0.2).astype(int)))
 umbral   FP   FN  marcados  coste 5/30  coste 8/20  coste 5/50
   0.05  347    8       462        1975        2936        2135
   0.10  218   18       323        1630        2104        1990
   0.15  149   25       247        1495        1692        1995
   0.20  112   35       200        1610        1596        2310
   0.30   60   56       127        1980        1600        3100
   0.50   17   80        60        2485        1736        4085
   0.70    3  107        19        3225        2164        5365
coste 5/30 -> mínimo 1495 € en umbral 0.15
coste 8/20 -> mínimo 1596 € en umbral 0.2
coste 5/50 -> mínimo 1990 € en umbral 0.1
Test 750, umbral 0,2: ({'A': 0.235, 'B': 0.288, 'C': 0.29}, 0.813) | n por zona: {'A': 310, 'B': 257, 'C': 183}
Tasa real de devolución por zona (test): {'A': 0.129, 'B': 0.175, 'C': 0.208}
Ratio bootstrap 2,5-97,5 %: [0.579 0.93 ]
CV 2999, umbral 0,2: ({'A': 0.269, 'B': 0.268, 'C': 0.269}, 0.994)
FNR por zona: {'A': 0.225, 'B': 0.311, 'C': 0.316} | FPR por zona: {'A': 0.156, 'B': 0.203, 'C': 0.186}
Coeficientes de zona: {'A': 0.017, 'B': 0.073, 'C': -0.094}
Escenario proxy (más nuevos en C): ({'A': 0.235, 'B': 0.288, 'C': 0.475}, 0.495)

Lectura:

  • Umbral. Con paso 0,05 el mínimo de (5, 30) está en 0,15 (1.495 €), no en 0,2 (la tabla de 04-05 solo miraba décimas): 247 marcados de 750, un tercio. Con (8, 20) el mínimo se desplaza a 0,2 (las falsas alarmas cuestan más y las devoluciones menos, así que se marca menos), y con (5, 50) baja a 0,1 (marcar es barato y fallar carísimo). El umbral es una decisión de negocio parametrizada por los costes; el modelo no cambia. Con la capacidad de revisión de Diego (≤ 300/día sobre 3.000 pedidos, un 10 %), 0,15 marcaría demasiado y la banda 0,2-0,5 de 08-01 sigue siendo la operativa.
  • Paridad en el test: 0,235 / 0,288 / 0,290 → ratio 0,813, justo por encima de 0,8, y con un intervalo bootstrap de 0,58 a 0,93: 750 pedidos no bastan para afirmar ni paridad ni disparidad. Con los 2.999 pedidos en validación cruzada las tasas son 0,269 / 0,268 / 0,269 (ratio 0,994): el modelo es paritario, como debía por construcción, y la desviación del test es ruido de muestreo (en ese test, la zona C tuvo por azar más devoluciones reales, 0,208 frente a 0,129 en A). Los coeficientes de la zona son ≈ 0 (0,017 / 0,073 / −0,094): el modelo no usa la zona.
  • Cómo se detectaría si no lo fuera, en orden de urgencia: (1) ratio de tasas de marcado < 0,8 de forma persistente en muestras grandes (por eso 08-01 lo mide cada mes con datos acumulados, no en cada lote); (2) diferencias de FNR/FPR entre zonas (una zona a la que se le escapan más devoluciones o se le molesta más), aunque las tasas de marcado coincidan; (3) coeficientes o importancias no nulos de la zona, o de sus proxies. El escenario simulado muestra ese último punto: si en la zona C hubiera muchos más clientes nuevos, la tasa de marcado subiría a 0,475 y el ratio caería a 0,50 aunque el modelo siga sin mirar la zona, porque cliente_nuevo actúa como proxy. Entonces la pregunta ya no es técnica sino de 02-04: ¿es legítimo que los clientes nuevos se revisen más (sí: devuelven de verdad más, 33 % frente a 9 %) y estamos seguros de que la concentración en C no responde a otra cosa (renta, origen)? Las respuestas posibles: banda de revisión humana más ancha para el grupo afectado, umbrales por grupo (con asesoría legal), quitar el proxy si su efecto no se justifica, y siempre documentarlo en la model card.

Retroalimentación

  • Error típico: concluir "hay sesgo" (o "no lo hay") con una sola muestra pequeña; el ratio de impacto tiene varianza y necesita intervalo y tamaño. Otro: comparar tasas de marcado sin mirar las tasas reales de devolución por grupo (paridad de marcado y paridad de errores no son lo mismo; cuál se exige depende del caso).
  • Que la zona tenga coeficiente ≈ 0 no garantiza paridad si hay proxies; que la paridad se cumpla no garantiza que las decisiones sean justas una a una. Las métricas de grupo son necesarias, no suficientes.
  • Variantes: calcula el coste con una banda de revisión (3 € por revisión y acierto de la revisora del 100 %) para bandas 0,15-0,5 y 0,2-0,5; repite el análisis por categoria y por metodo_pago (contrareembolso es un candidato a proxy socioeconómico); escribe paridad_ok(tasas, minimo=0.8) de 02-04 y añádela como test en novamarket_ia/tests/.

Errores Comunes y Consejos

  • Comparar cifras de validaciones distintas. Todo lo que se compara debe medirse en la misma partición o la misma validación cruzada; y las diferencias pequeñas se comparan pliegue a pliegue (emparejadas), no por medias sueltas.
  • Confundir métrica técnica y de negocio. El AUC puede subir sin que el coste baje (práctica 2) y el umbral óptimo cambia con los costes (práctica 5). Reporta siempre las dos.
  • Series temporales barajadas y retardos con futuro. TimeSeriesSplit y shift(k) con k > 0; comprueba a mano que la fila de la semana t solo contiene datos de semanas < t.
  • Escalar (o no) sin pensar. k-means y la logística necesitan escalado; los árboles no. Y el escalador se ajusta solo con entrenamiento (dentro del Pipeline).
  • Muestras pequeñas y conclusiones grandes. 750 pedidos son pocos para medir paridad; 48 semanas son pocas para ajustar retardos; una división es poco para elegir características. Cuando la conclusión importa, pide más datos o más repeticiones.
  • Consejo: cada práctica debería terminar en una frase para Diego: "el modelo actual se queda; la característica nueva no ahorra euros", "la lineal con calendario, MAE 20, reentrenar cada trimestre", "tres segmentos y una acción por segmento", "umbral 0,2 con banda, paritario entre zonas con n = 2.999". Si no puedes escribirla, la práctica no ha terminado.

Conclusión

Cinco prácticas y cinco decisiones: un lote de 3.003 filas se queda en 2.999 tras quitar 3 duplicados y un importe de 99.999 €, con los nulos imputados en el pipeline y la fuga fuera; una característica de interacción sube el AUC de 0,8365 a 0,8409 de forma consistente pero no ahorra euros al umbral 0,2, así que el modelo de producción no cambia; en la demanda, la lineal con calendario (MAE 22,6 en cuatro trimestres) bate a las líneas base (27-30), a los retardos (que no aportan con ruido independiente y desestabilizan el primer bloque) y al gradient boosting (40,4, que no extrapola la tendencia ni aísla el Black Friday), y quitar la variable de Black Friday cuesta 214 unidades de error en una sola semana; k-means encuentra tres segmentos (silueta 0,572) con una acción de negocio para cada uno; y el umbral óptimo depende de los costes (0,15 con 5/30, 0,2 con 8/20, 0,1 con 5/50) mientras que la paridad entre zonas, ambigua con 750 pedidos (ratio 0,81, intervalo 0,58-0,93), es clara con 2.999 (0,99), aunque un proxy podría romperla sin que el modelo mire la zona.

En 09-03, Proyectos de Redes Neuronales, subirás un escalón de complejidad con PyTorch: el MLP de devoluciones con búsqueda de arquitectura y tasa de aprendizaje, la CNN de fotos con una tercera clase y aumento de datos, una GRU sobre la demanda que compararás con la lineal de esta lección, y un clasificador de reseñas con embeddings entrenados desde cero frente a la bolsa de palabras. La pregunta de fondo será la de 05-03: ¿cuándo compensa la red frente al modelo clásico bien hecho?

Fundamentos de Inteligencia Artificial (IA)

Módulo 1: Introducción a la Inteligencia Artificial

Módulo 2: Principios Básicos de la IA

Módulo 3: Algoritmos en IA

Módulo 4: Aprendizaje Automático (Machine Learning)

Módulo 5: Redes Neuronales y Deep Learning

Módulo 6: Lógica y Sistemas Expertos

Módulo 7: Herramientas y Lenguajes de Programación en IA

Módulo 8: Proyectos y Casos de Estudio

Módulo 9: Ejercicios y Prácticas

Módulo 10: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados