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
- Práctica 1: exploración y limpieza de un lote sucio con informe de decisiones
- Práctica 2: una característica nueva en el pipeline de devoluciones, comparada con honestidad
- Práctica 3: previsión de demanda con calendario y retardos, lineal frente a gradient boosting
- Práctica 4: segmentación de clientes con k-means y una acción por segmento
- Práctica 5: ajuste de umbral por coste y comprobación de equidad entre zonas
- Errores Comunes y Consejos
- Conclusión
- 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
fillnaantes de dividir; hacerlo en elPipelinees lo que protege de fugas (04-03). - Variantes: añade
dias_entrega_faltantecomo característica binaria y mide en 09-02.2 si aporta; simula un lote con el 30 % deimportenulo y decide de nuevo (con tanto nulo, la imputación por mediana empobrece la variable); escribe el informe como funcióninforme_calidad(df)reutilizable ennovamarket_ia/.
- 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_riesgono cambia nada (0,8365 y los mismos costes): es una combinación lineal de las columnas one-hot decategoriaque ya existen; la logística no gana información con una columna redundante.dias_x_nuevotampoco aporta (la verdad oculta no contiene esa interacción).nuevo_x_importesí 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
importesin imputar y dejar que elSimpleImputerde la rama numérica imponga la mediana de un producto (aquí lo hacemos explícito confillnaen 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ñadedias_entrega_faltantede la práctica 1; repite conn_repeats=10y observa cómo se estrecha la desviación de la diferencia.
- 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_4ymedia_4de 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=1baja 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 conshift(-1)(usar el futuro: fuga). Otro: evaluar conKFoldbarajado: 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); usagenerar_demanda_semanal(208)(4 años) y comprueba si con más datos HGB se acerca; pruebaRidgecon los retardos para estabilizar el primer bloque.
- 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_mediocomo cuarta variable y observa si cambia la partición; pruebaAgglomerativeClusteringy compara; calcula el valor anual que se perdería si el 10 % de los habituales pasara a ocasionales (es el argumento para Diego).
- 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_nuevoactú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
categoriay pormetodo_pago(contrareembolso es un candidato a proxy socioeconómico); escribeparidad_ok(tasas, minimo=0.8)de 02-04 y añádela como test ennovamarket_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.
TimeSeriesSplityshift(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
- Conceptos Fundamentales: Agentes, Entornos y Racionalidad
- Tipos de Inteligencia Artificial
- Los Datos como Materia Prima de la IA
- Ética y Consideraciones en IA
Módulo 3: Algoritmos en IA
- Introducción a los Algoritmos
- Algoritmos de Búsqueda
- Búsqueda con Adversario: Juegos y Minimax
- Algoritmos de Optimización
Módulo 4: Aprendizaje Automático (Machine Learning)
- Conceptos Básicos de Machine Learning
- Tipos de Aprendizaje Automático
- Preparación de Datos y Características
- Algoritmos de Machine Learning
- Evaluación y Validación de Modelos
- Sobreajuste, Regularización y Ajuste de Hiperparámetros
Módulo 5: Redes Neuronales y Deep Learning
- Introducción a las Redes Neuronales
- Arquitectura de Redes Neuronales
- Cómo Aprende una Red: Descenso del Gradiente y Retropropagación
- Deep Learning y sus Aplicaciones
- Transformers, Grandes Modelos de Lenguaje e IA Generativa
Módulo 6: Lógica y Sistemas Expertos
- Lógica en IA
- Sistemas Expertos
- Razonamiento con Incertidumbre: Probabilidad y Redes Bayesianas
- Aplicaciones de Sistemas Expertos
Módulo 7: Herramientas y Lenguajes de Programación en IA
- Lenguajes de Programación para IA
- Python Científico: NumPy, pandas y Matplotlib
- Herramientas y Librerías Populares
- Entornos de Desarrollo
Módulo 8: Proyectos y Casos de Estudio
Módulo 9: Ejercicios y Prácticas
- Ejercicios de Algoritmos
- Prácticas de Machine Learning
- Proyectos de Redes Neuronales
- Proyecto Integrador: de la Idea al Prototipo
