A 09-01 vas tornar als algorismes del mòdul 3 i vas comprovar que la disciplina (representar bé, definir l'objectiu, validar) importa més que l'algorisme concret. Ara toca la mateixa disciplina amb l'aprenentatge automàtic del mòdul 4: cinc pràctiques amb els generadors de NovaMarket que ja coneixes (generar_comandes_ml, embrutar_comandes, preparar_comandes, crear_preparacio, generar_demanda_setmanal, generar_clients_ml), cadascuna amb una línia base, una comparació honesta i una decisió de negoci al final. Les xifres de referència del mòdul 4 (AUC 0,844, cost 2.485 € → 1.610 € amb llindar 0,2, MAE 16,8 de la demanda, tres segments de clients) són la teva vara de mesurar; en diverses pràctiques descobriràs que "millorar" és més difícil del que sembla.
Com treballar la lliçó: cada pràctica té un enunciat amb preguntes concretes; escriu la teva solució abans de llegir la nostra, executa-la, i compara xifres (varien unes centèsimes segons la versió de scikit-learn). Necessites el mòdul novamarket_ml.py de 04-03/04-05 (les funcions citades més amunt) a la mateixa carpeta, i pandas, numpy i scikit-learn. Temps orientatiu: 30-45 minuts per pràctica.
Contingut
- Pràctica 1: exploració i neteja d'un lot brut amb informe de decisions
- Pràctica 2: una característica nova al pipeline de devolucions, comparada amb honestedat
- Pràctica 3: previsió de demanda amb calendari i retards, lineal davant de gradient boosting
- Pràctica 4: segmentació de clients amb k-means i una acció per segment
- Pràctica 5: ajust de llindar per cost i comprovació d'equitat entre zones
- Errors Comuns i Consells
- Conclusió
- Pràctica 1: exploració i neteja d'un lot brut amb informe de decisions
Recordatori (04-03, seccions 2-3 i 8). embrutar_comandes produeix una còpia "realista" de les comandes amb nuls, duplicats, un atípic, dates, text i una fuita (motiu_devolucio, que només existeix si la comanda es va retornar). Abans de modelar cal mesurar cada problema i decidir què fer-ne, sense mirar l'etiqueta més de l'imprescindible.
Enunciat. La Marta rep el lot comandes.csv de setembre-novembre (embrutar_comandes(generar_comandes_ml(3000, 42), 42)). Escriu un script que produeixi un informe de qualitat amb: (a) forma i tipus; (b) nuls per columna, en nombre i percentatge, i una comprovació de si els nuls de dies_lliurament es reparteixen igual entre retornats i no retornats; (c) files duplicades (exactes i per id_comanda); (d) atípics d'import_comanda amb la regla de 3 × IQR i a ull; (e) recompte de categories i rang de dates; (f) les columnes que serien fuita. Després escriu netejar_lot(lot) que retorni el lot net i un diccionari amb el que ha fet, i justifica cada decisió en una taula.
Pistes: isna().sum(), duplicated(keep=False), quantile([0.25, 0.75]), pd.crosstab(lot["motiu_devolucio"], lot["retornat"]).
Solució
import numpy as np, pandas as pd
from novamarket_ml import generar_comandes_ml, embrutar_comandes, preparar_comandes
lot = embrutar_comandes(generar_comandes_ml(3000, 42), 42)
print(lot.shape); print(lot.dtypes.to_dict())
nuls = lot.isna().sum(); nuls = nuls[nuls > 0]
print(pd.DataFrame({"nuls": nuls, "%": (nuls / len(lot) * 100).round(1)}))
print("retornat si dies_lliurament nul:", lot.loc[lot["dies_lliurament"].isna(), "retornat"].mean().round(3),
"| no nul:", lot.loc[lot["dies_lliurament"].notna(), "retornat"].mean().round(3))
print("Duplicats exactes:", lot.duplicated().sum(), "| id repetits:", lot["id_comanda"].duplicated().sum())
print(lot[lot.duplicated(keep=False)].sort_values("id_comanda")[["id_comanda", "id_client", "data_comanda", "import_comanda", "retornat"]])
q1, q3 = lot["import_comanda"].quantile([0.25, 0.75]); sostre = q3 + 3 * (q3 - q1)
print(f"IQR: q1={q1:.1f} q3={q3:.1f} sostre 3xIQR={sostre:.1f} | per sobre: {(lot['import_comanda'] > sostre).sum()}")
print(lot.loc[lot["import_comanda"] > 1000, ["id_comanda", "import_comanda", "num_articles", "categoria", "retornat"]])
print("Maxim sense l'atipic:", lot.loc[lot["import_comanda"] < 5000, "import_comanda"].max())
for c in ["categoria", "codi_postal_zona", "metode_pagament", "tipus_enviament"]:
print(c, lot[c].value_counts(dropna=False).to_dict())
print("Dates:", lot["data_comanda"].min().date(), "->", lot["data_comanda"].max().date())
print(pd.crosstab(lot["motiu_devolucio"], lot["retornat"]))
print("Taxa de devolucio:", lot["retornat"].mean().round(3), "| per zona:", lot.groupby("codi_postal_zona")["retornat"].mean().round(3).to_dict())
def netejar_lot(lot, sostre_import=5000):
informe = {}
net = lot.drop_duplicates(); informe["duplicats_eliminats"] = len(lot) - len(net)
atipic = net["import_comanda"] > sostre_import
informe["atipics_import_eliminats"] = int(atipic.sum()); net = net[~atipic].copy()
informe["nuls_que_imputara_el_pipeline"] = net.isna().sum()[lambda s: s > 0].to_dict()
net = net.drop(columns=["motiu_devolucio"]); informe["fuites_eliminades"] = ["motiu_devolucio"]
return net, informe
net, informe = netejar_lot(lot); print(net.shape, informe)
X, y = preparar_comandes(lot); print("preparar_comandes ->", X.shape, "taxa", y.mean().round(4))(3003, 13)
nuls %
import_comanda 56 1.9
dies_lliurament 153 5.1
metode_pagament 100 3.3
retornat si dies_lliurament nul: 0.209 | no nul: 0.162
Duplicats exactes: 3 | id repetits: 3
IQR: q1=63.8 q3=163.6 sostre 3xIQR=463.1 | per sobre: 13
id_comanda import_comanda num_articles categoria retornat
659 P100017 99999.0 2 electronica 0
Maxim sense l'atipic: 632.61
metode_pagament {'targeta': 1624, 'paypal': 730, 'bizum': 310, 'contrareemborsament': 239, nan: 100}
Dates: 2025-09-01 -> 2025-11-29
retornat 0 1
motiu_devolucio
2509 0
defectuos 0 126
no li agrada 0 131
talla/model 0 113
va arribar tard 0 124
Taxa de devolucio: 0.165 | per zona: {'A': 0.162, 'B': 0.168, 'C': 0.164}
(2999, 12) {'duplicats_eliminats': 3, 'atipics_import_eliminats': 1, 'nuls_que_imputara_el_pipeline': {'import_comanda': 56, 'dies_lliurament': 153, 'metode_pagament': 100}, 'fuites_eliminades': ['motiu_devolucio']}
preparar_comandes -> (2999, 15) taxa 0.1644| Troballa | Decisió | Justificació |
|---|---|---|
3 files duplicades exactes (mateix id_comanda, client, data, import) |
Eliminar | Una comanda no pot aparèixer dues vegades; comptar-la doble esbiaixa l'entrenament i l'avaluació |
import_comanda = 99.999 € a P100017 (2 articles d'electrònica) |
Eliminar la fila | És un error de captura, no una comanda: el màxim següent són 632 €. Els 12 restants per sobre de 3 × IQR (fins a 632 €) són comandes cares legítimes i es conserven |
Nuls: dies_lliurament 5,1 %, metode_pagament 3,3 %, import_comanda 1,9 % |
Imputar dins del Pipeline (mediana / més freqüent) |
Són pocs i no es pot prescindir del 10 % de files; imputar al pipeline evita fuites. La taxa de devolució amb dies_lliurament nul (0,209 davant de 0,162, sobre 153 files) és dins del marge de l'atzar, però és la comprovació que cal fer: si fos gran i persistent, el nul seria informatiu i convindria un indicador dies_lliurament_absent (variant) |
motiu_devolucio només informat si retornat = 1 |
Eliminar la columna | Fuita d'informació: es coneix després de la devolució |
id_comanda, id_client, data_comanda |
No són característiques directes | Es fan servir per derivar (dia de la setmana, historial per client) i després es descarten |
| Categories sense errors tipogràfics; taxa per zona ≈ 16 % a A, B i C | Res | Coherent amb la construcció de les dades (la zona no hi influeix) |
Retroalimentació
- Error típic: eliminar els 13 "atípics" de la regla IQR. Les regles estadístiques assenyalen candidats; la decisió la dona el context: una comanda de 600 € és normal en electrònica. Igual de dolent és imputar el 99.999 amb la mediana en lloc d'eliminar-lo (l'error de captura continuaria a les estadístiques de la resta de columnes d'aquella fila).
- Un altre: imputar amb
fillnaabans de dividir; fer-ho alPipelineés el que protegeix de fuites (04-03). - Variants: afegeix
dies_lliurament_absentcom a característica binària i mesura a 09-02.2 si aporta res; simula un lot amb el 30 % d'import_comandanul i decideix de nou (amb tant de nul, la imputació per mediana empobreix la variable); escriu l'informe com a funcióinforme_qualitat(df)reutilitzable anovamarket_ia/.
- Pràctica 2: una característica nova al pipeline de devolucions, comparada amb honestedat
Recordatori (04-03 secció 9, 04-05 seccions 5 i 8). El pipeline de referència (crear_preparacio() + regressió logística, 21 columnes) obté AUC 0,844 al test de 750 comandes i 0,836 ± 0,016 en validació creuada estratificada de 5 plecs; amb llindar 0,2 el cost és 1.610 € en test. Una comparació honesta fa servir la mateixa divisió i, millor, validació creuada; i decideix amb la mètrica de negoci, no només amb l'AUC.
Enunciat. La logística no pot modelar per si sola la interacció "client nou i comanda cara" que la veritat oculta de les dades conté (recorda 04-01). Afegeix tres candidates: nou_x_import (= client_nou × import_comanda), categoria_risc (1 si electrònica o informàtica) i dies_x_nou (= client_nou × dies_lliurament). Per a cadascuna: (a) amplia la preparació (ColumnTransformer) sense tocar la resta; (b) mesura AUC amb StratifiedKFold(5, shuffle=True, random_state=42) i el cost de negoci amb cross_val_predict als llindars 0,2, 0,3 i 0,5; (c) compara amb la referència en la mateixa validació i també en la divisió de 04-05 (train_test_split(..., test_size=0.25, random_state=42, stratify=y)); (d) decideix si la Marta hauria de canviar el model en producció.
Solució
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_preparacio, NUMERIQUES, BINARIES, NOMINALS, ORDINALS
def cost_negoci(m, cost_fp=5, cost_fn=30):
vn, fp, fn, vp = m.ravel(); return fp * cost_fp + fn * cost_fn
def afegir_caracteristiques(X):
X = X.copy()
X["nou_x_import"] = X["client_nou"] * X["import_comanda"].fillna(X["import_comanda"].median())
X["categoria_risc"] = X["categoria"].isin(["electronica", "informatica"]).astype(int)
X["dies_x_nou"] = X["client_nou"] * X["dies_lliurament"].fillna(X["dies_lliurament"].median())
return X
def crear_preparacio_v2(numeriques_extra=(), binaries_extra=()):
return ColumnTransformer([
("num", Pipeline([("imputar", SimpleImputer(strategy="median")), ("escalar", StandardScaler())]), NUMERIQUES + list(numeriques_extra)),
("bin", "passthrough", BINARIES + list(binaries_extra)),
("nom", Pipeline([("imputar", SimpleImputer(strategy="most_frequent")), ("onehot", OneHotEncoder(handle_unknown="ignore"))]), NOMINALS),
("ord", OrdinalEncoder(categories=[["estandard", "rapid", "urgent"]]), ORDINALS)])
X2 = afegir_caracteristiques(X)
cv = StratifiedKFold(5, shuffle=True, random_state=42)
def avaluar(nom, prep, Xd):
pipe = Pipeline([("prep", prep), ("model", 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]
costos = {u: int(cost_negoci(confusion_matrix(y, (prob >= u).astype(int)))) for u in (0.2, 0.3, 0.5)}
print(f"{nom:22s} AUC CV {aucs.mean():.4f} ± {aucs.std():.3f} cost CV {costos}")
avaluar("referencia", crear_preparacio(), X)
avaluar("+ nou_x_import", crear_preparacio_v2(["nou_x_import"]), X2)
avaluar("+ categoria_risc", crear_preparacio_v2([], ["categoria_risc"]), X2)
avaluar("+ dies_x_nou", crear_preparacio_v2(["dies_x_nou"]), X2)
avaluar("+ les tres", crear_preparacio_v2(["nou_x_import", "dies_x_nou"], ["categoria_risc"]), X2)
# validacio creuada repetida (5 x 4) amb diferencies aparellades per plec
rcv = RepeatedStratifiedKFold(n_splits=5, n_repeats=4, random_state=1)
ref = Pipeline([("prep", crear_preparacio()), ("model", LogisticRegression(max_iter=2000))])
nova = Pipeline([("prep", crear_preparacio_v2(["nou_x_import"])), ("model", LogisticRegression(max_iter=2000))])
a, b = cross_val_score(ref, X, y, cv=rcv, scoring="roc_auc"), cross_val_score(nova, X2, y, cv=rcv, scoring="roc_auc")
print(f"20 plecs: ref {a.mean():.4f} nova {b.mean():.4f} diferencia {np.mean(b - a):+.4f} ± {np.std(b - a):.4f} a favor en {(b > a).sum()}/20")
# la divisio de 04-05
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
for nom, prep, tr, te in [("referencia", crear_preparacio(), Xtr, Xte),
("+ nou_x_import", crear_preparacio_v2(["nou_x_import"]), afegir_caracteristiques(Xtr), afegir_caracteristiques(Xte))]:
p = Pipeline([("prep", prep), ("model", LogisticRegression(max_iter=2000))]).fit(tr, ytr); prob = p.predict_proba(te)[:, 1]
print(f"{nom:22s} AUC test {roc_auc_score(yte, prob):.4f} cost test", {u: int(cost_negoci(confusion_matrix(yte, (prob >= u).astype(int)))) for u in (0.2, 0.5)})
p = nova.fit(X2, y); noms, coef = p["prep"].get_feature_names_out(), p["model"].coef_[0]
print("Coeficients mes grans:", [(n, round(float(c), 2)) for n, c in sorted(zip(noms, coef), key=lambda t: -abs(t[1]))[:5]])referencia AUC CV 0.8365 ± 0.016 cost CV {0.2: 6635, 0.3: 7220, 0.5: 9455}
+ nou_x_import AUC CV 0.8409 ± 0.014 cost CV {0.2: 6725, 0.3: 7410, 0.5: 9325}
+ categoria_risc AUC CV 0.8365 ± 0.016 cost CV {0.2: 6635, 0.3: 7220, 0.5: 9450}
+ dies_x_nou AUC CV 0.8366 ± 0.016 cost CV {0.2: 6630, 0.3: 7200, 0.5: 9490}
+ les tres AUC CV 0.8406 ± 0.014 cost CV {0.2: 6680, 0.3: 7420, 0.5: 9305}
20 plecs: ref 0.8333 nova 0.8379 diferencia +0.0046 ± 0.0060 a favor en 16/20
referencia AUC test 0.8444 cost test {0.2: 1610, 0.5: 2485}
+ nou_x_import AUC test 0.8334 cost test {0.2: 1710, 0.5: 2360}
Coeficients mes grans: [('num__nou_x_import', 0.83), ('num__import_comanda', 0.76), ('num__dies_lliurament', 0.66), ('nom__categoria_electronica', 0.54), ('bin__client_nou', 0.45)]Lectura, en l'ordre en què convé raonar:
categoria_riscno canvia res (0,8365 i els mateixos costos): és una combinació lineal de les columnes one-hot decategoriaque ja existeixen; la logística no guanya informació amb una columna redundant.dies_x_noutampoc no aporta res (la veritat oculta no conté aquesta interacció).nou_x_importsí que millora l'AUC de manera petita però consistent: +0,0046 de mitjana, a favor en 16 de 20 plecs, i es converteix en el coeficient més gran del model (0,83), com calia esperar de la interacció que la veritat oculta conté. Però la millora no es tradueix en euros al llindar de treball: al 0,2 el cost en validació creuada és una mica pitjor (6.725 davant de 6.635 €), i només millora al 0,5 (on ningú no opera).- La divisió única de 04-05 diu el contrari que la validació creuada (0,833 davant de 0,844): amb 750 comandes i 123 devolucions, ±0,01 d'AUC és soroll de mostreig. És la raó per la qual a 04-05 vam insistir en la validació creuada.
- Decisió: no canviar el model en producció. El guany és marginal en la mètrica tècnica i nul o negatiu en la de negoci; a igualtat, guanya el més simple (04-06). La Marta anota la característica com a candidata per al proper reentrenament, quan hi hagi més dades i es pugui avaluar amb el cost real.
Retroalimentació
- Error típic: comparar el nou pipeline amb l'AUC "de memòria" (0,844) obtingut en una altra divisió; la comparació només val en la mateixa validació. Un altre: crear la interacció amb
import_comandasense imputar i deixar que elSimpleImputerde la branca numèrica imposi la mediana d'un producte (aquí ho fem explícit ambfillnaa la funció). - Que l'AUC pugi i el cost no baixi no és una contradicció: l'AUC mesura l'ordenació global de tots els parells; el cost al 0,2 depèn de com es reordenen unes poques comandes al voltant d'aquest llindar.
- Variants: prova
HistGradientBoostingClassifier, que captura interaccions sense que les hi donis; afegeixdies_lliurament_absentde la pràctica 1; repeteix ambn_repeats=10i observa com s'estreny la desviació de la diferència.
- Pràctica 3: previsió de demanda amb calendari i retards, lineal davant de gradient boosting
Recordatori (04-04 secció 2, 04-05 seccions 6 i 8). La demanda setmanal del robot NovaClean (104 setmanes) té tendència (2,5/setmana), estacionalitat anual i un pic de Black Friday. La regressió lineal amb setmana, sen, cos i black_friday dona MAE 16,8 a les setmanes 79-104 i 18,6 de mitjana amb TimeSeriesSplit(4, test_size=13); la línia base "última setmana" dona 39. Els plecs temporals entrenen amb el passat i avaluen el trimestre següent.
Enunciat. (a) Escriu construir_caracteristiques(d, lags=(1, 2, 4), amb_black_friday=True) que afegeixi calendari (sen, cos, black_friday), els retards lag_k (unitats de fa k setmanes) i mitjana_4 (mitjana de les 4 setmanes anteriors), i elimini les files inicials sense retards. (b) Amb TimeSeriesSplit(n_splits=4, test_size=13), calcula el MAE mitjà de: la línia base ingènua (lag_1), la línia base mitjana_4, la lineal només amb calendari, la lineal amb calendari + retards, i HistGradientBoostingRegressor (max_iter=200, learning_rate=0.05, max_depth=3, min_samples_leaf=5) amb calendari + retards. (c) Repeteix lineal i HGB sense black_friday i mira l'error concret a la setmana 100 (el segon Black Friday). (d) Explica els resultats i tria un model per a la Marta.
Solució
from novamarket_ml import generar_demanda_setmanal
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_setmanal(104, 42)
def construir_caracteristiques(d, lags=(1, 2, 4), amb_black_friday=True):
f = d.copy()
f["sen"], f["cos"] = np.sin(2 * np.pi * f["setmana"] / 52), np.cos(2 * np.pi * f["setmana"] / 52)
f["black_friday"] = ((f["setmana"] - 1) % 52 == 47).astype(int)
for k in lags: f[f"lag_{k}"] = f["unitats"].shift(k) # unitats de fa k setmanes
f["mitjana_4"] = f["unitats"].shift(1).rolling(4).mean() # mitjana de les 4 anteriors
calendari = ["setmana", "sen", "cos"] + (["black_friday"] if amb_black_friday else [])
retards = [f"lag_{k}" for k in lags] + ["mitjana_4"]
return f.dropna().reset_index(drop=True), calendari, retards
tscv = TimeSeriesSplit(n_splits=4, test_size=13)
def mae_cv(f, cols, crear_model=None, nom=""):
maes = []
for tr, te in tscv.split(f):
pred = f.loc[te, cols[0]] if crear_model is None else crear_model().fit(f.loc[tr, cols], f.loc[tr, "unitats"]).predict(f.loc[te, cols])
maes.append(mean_absolute_error(f.loc[te, "unitats"], pred))
print(f"{nom:42s} MAE per bloc {np.round(maes, 1)} mitjana {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_caracteristiques(d)
print("Blocs de test:", [(int(f.loc[te, 'setmana'].min()), int(f.loc[te, 'setmana'].max())) for _, te in tscv.split(f)])
mae_cv(f, ["lag_1"], None, "Línia base ingènua (última setmana)")
mae_cv(f, ["mitjana_4"], None, "Línia base mitjana de 4 setmanes")
mae_cv(f, cal, lin, "Lineal només calendari (04-05)")
mae_cv(f, cal + ret, lin, "Lineal calendari + retards")
mae_cv(f, cal + ret, hgb, "HGB calendari + retards")
f0, cal0, ret0 = construir_caracteristiques(d, amb_black_friday=False)
mae_cv(f0, cal0 + ret0, lin, "Lineal SENSE black_friday")
mae_cv(f0, cal0 + ret0, hgb, "HGB SENSE black_friday")
for nom, (ff, cols) in {"lineal amb BF": (f, cal + ret), "lineal sense BF": (f0, cal0 + ret0), "HGB amb BF": (f, cal + ret)}.items():
tr, te = ff["setmana"] <= 91, ff["setmana"] > 91
m = (lin if "lineal" in nom else hgb)().fit(ff.loc[tr, cols], ff.loc[tr, "unitats"])
err = pd.Series(np.abs(ff.loc[te, "unitats"].values - m.predict(ff.loc[te, cols])), index=ff.loc[te, "setmana"])
print(f"{nom:14s} setmanes 92-104: MAE {err.mean():.1f} | error setmana 100 (Black Friday): {err[100]:.1f} | sense la 100: {err.drop(100).mean():.1f}")Blocs de test: [(53, 65), (66, 78), (79, 91), (92, 104)] Línia base ingènua (última setmana) MAE per bloc [31.3 18.7 15.8 52.8] mitjana 29.7 Línia base mitjana de 4 setmanes MAE per bloc [27.4 19.7 12.1 50.8] mitjana 27.5 Lineal només calendari (04-05) MAE per bloc [38.1 19. 13.2 20.1] mitjana 22.6 Lineal calendari + retards MAE per bloc [66.4 19. 13.7 19.8] mitjana 29.7 HGB calendari + retards MAE per bloc [49.8 50.5 12.4 48.9] mitjana 40.4 Lineal SENSE black_friday MAE per bloc [168.4 16.5 14.5 40. ] mitjana 59.9 HGB SENSE black_friday MAE per bloc [49.8 50.5 12.4 48.9] mitjana 40.4 lineal amb BF setmanes 92-104: MAE 19.8 | error setmana 100 (Black Friday): 26.4 | sense la 100: 19.2 lineal sense BF setmanes 92-104: MAE 40.0 | error setmana 100 (Black Friday): 214.5 | sense la 100: 25.5 HGB amb BF setmanes 92-104: MAE 48.9 | error setmana 100 (Black Friday): 129.9 | sense la 100: 42.2
Quatre conclusions que la Marta ha de poder explicar:
- La lineal amb calendari continua guanyant (22,6). Els retards no la milloren (blocs 2-4 iguals) i l'empitjoren al primer bloc (66,4): amb només 48 setmanes d'entrenament i el pic del Black Friday ficat dins de
lag_1,lag_4imitjana_4de les setmanes 49-52, els coeficients dels retards s'estimen malament. I no poden ajudar gaire: el soroll d'aquesta sèrie és independent d'una setmana a l'altra (l'autocorrelació dels residus del model de calendari és 0,09), així que els retards no contenen informació que el calendari no tingui ja. En sèries reals amb ratxes (promocions que s'allarguen, trencaments d'estoc) sí que en contindrien. - Gradient boosting perd amb claredat (40,4): un model d'arbres no extrapola la tendència (prediu com a màxim el màxim vist en entrenament, i la sèrie creix 2,5 unitats/setmana) i amb 48-87 files està afamat de dades. Amb
min_samples_leaf=1baixa a 34,8; amb la correcció clàssica (lineal per a tendència i estacionalitat, HGB només per als residus) a 24,7: continua sense batre la lineal sola. - Black Friday: treure la variable dispara l'error de la lineal a la setmana 100 (214 unitats: prediu ~600 i se'n venen 825) i contamina l'ajust del primer bloc (168), perquè el pic de la setmana 48 s'ajusta com si fos estacionalitat. Per a HGB no canvia res: amb
min_samples_leaf=5, un arbre no pot aïllar una fulla amb una sola setmana de Black Friday, així que ignora la variable encara que l'hi donis. És un exemple de per què les regles de negoci ("la setmana 47 es ven ×1,4") de vegades valen més que el model. - Elecció: la lineal amb calendari, MAE ≈ 20 unitats sobre vendes de 550-800, davant de 27-30 de les línies base. I una regla de manteniment: reentrenar cada trimestre, perquè el bloc de 13 setmanes més recent sempre és fora de l'entrenament.
Retroalimentació
- Error típic: construir els retards i no eliminar les primeres files (queden
NaN), o calcular-los ambshift(-1)(fer servir el futur: fuita). Un altre: avaluar ambKFoldbarrejat: el MAE baixaria a ~17 i en producció no es compliria (04-05). - Fixa't que la línia base ingènua és pitjor als blocs 1 i 4 (31 i 53): són els que contenen el Black Friday, i "repetir l'última setmana" falla dues vegades (no l'anticipa i després l'allarga).
- Variants: afegeix
lag_52(mateixa setmana de l'any anterior) més la tendència (exercici 3 de 04-05); fes servirgenerar_demanda_setmanal(208)(4 anys) i comprova si amb més dades HGB s'hi acosta; provaRidgeamb els retards per estabilitzar el primer bloc.
- Pràctica 4: segmentació de clients amb k-means i una acció per segment
Recordatori (04-02 secció 5, 04-04 secció 8). k-means agrupa per distància a k centroides; exigeix escalar les variables; la inèrcia baixa sempre en pujar k, així que es busca el "colze", i es complementa amb el coeficient de silueta (com més alt, millor separació) i amb la interpretabilitat de negoci. generar_clients_ml(600, 42) té tres tipus ocults (ocasionals, habituals, grans compradors).
Enunciat. (a) Escala despesa_anual, num_comandes i antiguitat_mesos i calcula inèrcia i silueta per a k de 2 a 8. (b) Tria k i justifica-ho. (c) Descriu cada segment (mida, mitjanes, tiquet mitjà = despesa/comandes, percentatge de la despesa total) i proposa una acció de negoci per segment. (d) Comprova què passa amb k = 4 (quin segment es divideix i per què?) i què passa si no escales.
Solució
from novamarket_ml import generar_clients_ml
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
cl = generar_clients_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["segment"] = km.labels_; cl["tiquet_mitja"] = cl["despesa_anual"] / cl["num_comandes"]
perfil = cl.groupby("segment").agg(n=("despesa_anual", "size"), despesa=("despesa_anual", "mean"), comandes=("num_comandes", "mean"),
tiquet=("tiquet_mitja", "mean"), antiguitat=("antiguitat_mesos", "mean")).round(1)
perfil["% despesa total"] = (cl.groupby("segment")["despesa_anual"].sum() / cl["despesa_anual"].sum() * 100).round(1)
print(perfil)
km4 = KMeans(n_clusters=4, n_init=10, random_state=42).fit(Xc)
print(pd.crosstab(cl["segment"], km4.labels_, rownames=["k=3"], colnames=["k=4"]))
print(cl.groupby(km4.labels_)[["despesa_anual", "num_comandes", "antiguitat_mesos"]].mean().round(1))
km_sense = KMeans(n_clusters=3, n_init=10, random_state=42).fit(cl[["despesa_anual", "num_comandes", "antiguitat_mesos"]])
print(pd.crosstab(cl["segment"], km_sense.labels_, rownames=["escalat"], colnames=["sense escalar"]))| k | inèrcia | 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 despesa comandes tiquet antiguitat % despesa total segment 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 despesa_anual num_comandes antiguitat_mesos 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 sense escalar 0 1 2 escalat 0 27 0 324 1 3 49 0 2 187 1 9
La inèrcia (1.800 amb k = 1) cau a 796 amb 2 grups i a 400 amb 3, i després baixa a poc a poc (299, 254, 219): el colze és a 3, i la silueta també és màxima a 3 (0,572). Amb una altra llavor del generador (generar_clients_ml(600, 7)) la silueta torna a ser màxima a 3 (0,583): la conclusió és estable. Els segments i les seves accions:
| Segment | Perfil | Acció proposada |
|---|---|---|
| 0 · Ocasionals (351 clients, 58 %) | 2-3 comandes/any, 186 € de despesa, 9 mesos d'antiguitat; 20 % de la despesa | Activació: cupó de segona compra i recomanacions (cas 1); mesurar la conversió a "habitual" al cap de 6 mesos |
| 2 · Habituals (197, 33 %) | 9 comandes/any, 700 €, tiquet 88 €, 28 mesos; 43 % de la despesa | Fidelització: enviament gratis per subscripció, avisos de reposició (càpsules NovaBrew, filtres NovaClean); és el segment on més val la pena evitar la fuga |
| 1 · Grans compradors (52, 9 %) | 20 comandes/any, 2.200 €, tiquet 117 €, 39 mesos; 36 % de la despesa | Gestió personal: accés anticipat a llançaments (NovaBook, NovaView), atenció prioritària en incidències; vigilar senyals d'abandonament un a un |
Amb k = 4 no apareix cap tipus nou: es parteix el segment habitual per antiguitat (17 davant de 36 mesos), una divisió que pot ser útil per a màrqueting però que la silueta no avala. Sense escalar, la variable de més variància (despesa_anual, desviació 599 davant de 6 i 13) domina la distància i 36 clients canvien de grup: els grups passen a ser "trams de despesa" i num_comandes i l'antiguitat gairebé no compten.
Retroalimentació
- Error típic: triar k només per la inèrcia mínima (sempre seria el màxim k) o presentar els centroides en l'escala estandarditzada (
esc.inverse_transform(km.cluster_centers_)els retorna a euros i comandes). - Un altre: interpretar els números de segment (0, 1, 2) com un ordre; són etiquetes arbitràries que canvien amb la llavor. Anomena'ls pel seu perfil.
- Variants: afegeix
tiquet_mitjacom a quarta variable i observa si canvia la partició; provaAgglomerativeClusteringi compara; calcula el valor anual que es perdria si el 10 % dels habituals passés a ocasionals (és l'argument per al Diego).
- Pràctica 5: ajust de llindar per cost i comprovació d'equitat entre zones
Recordatori (04-05 secció 5, 02-04 seccions 9-10). El llindar no és 0,5 per decret: es tria recorrent llindars amb cost_negoci (5 € per falsa alarma, 30 € per devolució no anticipada) i afegint una banda de revisió humana. I tota decisió sobre persones es comprova per grups: la paritat de taxes de marcatge amb la regla dels quatre cinquens (ratio del grup menys marcat sobre el més marcat ≥ 0,8). codi_postal_zona no influeix en l'etiqueta per construcció, així que un model ben fet hauria de sortir paritari.
Enunciat. Amb el pipeline de referència entrenat en la divisió de 04-05: (a) calcula la taula de FP, FN, marcats i cost per a llindars de 0,05 a 0,70 amb tres estructures de cost: (5 €, 30 €), (8 €, 20 €) i (5 €, 50 €); en quin llindar és el mínim de cadascuna? (b) Amb el llindar 0,2, calcula la taxa de marcatge per zona al test, el ratio d'impacte i un interval per bootstrap del ratio; repeteix amb les prediccions de validació creuada sobre les 2.999 comandes. (c) Mira els coeficients de la zona al model, i les taxes de falsos negatius i falsos positius per zona. (d) Simula un escenari en què la zona C tingués molts més clients nous i observa què passa amb el ratio: com ho detectaries i què faries si el resultat no fos paritari?
Solució
X, y = preparar_comandes(embrutar_comandes(generar_comandes_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_preparacio()), ("model", LogisticRegression(max_iter=1000))]).fit(Xtr, ytr)
prob = pipe.predict_proba(Xte)[:, 1]
files = []
for u in np.round(np.arange(0.05, 0.71, 0.05), 2):
m = confusion_matrix(yte, (prob >= u).astype(int))
files.append({"llindar": u, "FP": m[0, 1], "FN": m[1, 0], "marcats": int((prob >= u).sum()),
"cost 5/30": cost_negoci(m), "cost 8/20": cost_negoci(m, 8, 20), "cost 5/50": cost_negoci(m, 5, 50)})
taula = pd.DataFrame(files); print(taula.to_string(index=False))
for c in ["cost 5/30", "cost 8/20", "cost 5/50"]:
print(c, "-> minim", taula[c].min(), "€ en llindar", taula.loc[taula[c].idxmin(), "llindar"])
def taxes_marcatge(zones, marcat):
t = pd.Series(marcat).groupby(np.asarray(zones)).mean()
return t.round(3).to_dict(), round(float(t.min() / t.max()), 3)
te = Xte.assign(prob=prob, y=yte.values, marcat=(prob >= 0.2).astype(int))
print("Test 750, llindar 0,2:", taxes_marcatge(te["codi_postal_zona"], te["marcat"]), "| n per zona:", te.groupby("codi_postal_zona").size().to_dict())
print("Taxa real de devolucio per zona (test):", te.groupby("codi_postal_zona")["y"].mean().round(3).to_dict())
rng = np.random.default_rng(0)
ratios = [taxes_marcatge(*te.iloc[rng.integers(0, len(te), len(te))][["codi_postal_zona", "marcat"]].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_preparacio()), ("model", LogisticRegression(max_iter=1000))]), X, y, cv=cv, method="predict_proba")[:, 1]
print("CV 2999, llindar 0,2:", taxes_marcatge(X["codi_postal_zona"], (prob_cv >= 0.2).astype(int)))
print("FNR per zona:", te[te.y == 1].groupby("codi_postal_zona")["marcat"].apply(lambda s: round(1 - s.mean(), 3)).to_dict(),
"| FPR per zona:", te[te.y == 0].groupby("codi_postal_zona")["marcat"].mean().round(3).to_dict())
noms, coef = pipe["prep"].get_feature_names_out(), pipe["model"].coef_[0]
print("Coeficients de zona:", {n.split("_")[-1]: round(float(c), 3) for n, c in zip(noms, coef) if "zona" in n})
X_sim = Xte.copy(); en_c = X_sim["codi_postal_zona"] == "C" # escenari: el 60 % de C passa a client nou
X_sim.loc[en_c, "client_nou"] = np.where(rng.random(en_c.sum()) < 0.6, 1, X_sim.loc[en_c, "client_nou"])
print("Escenari proxy (mes nous a C):", taxes_marcatge(X_sim["codi_postal_zona"], (pipe.predict_proba(X_sim)[:, 1] >= 0.2).astype(int))) llindar FP FN marcats cost 5/30 cost 8/20 cost 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
cost 5/30 -> minim 1495 € en llindar 0.15
cost 8/20 -> minim 1596 € en llindar 0.2
cost 5/50 -> minim 1990 € en llindar 0.1
Test 750, llindar 0,2: ({'A': 0.235, 'B': 0.288, 'C': 0.29}, 0.813) | n per zona: {'A': 310, 'B': 257, 'C': 183}
Taxa real de devolucio per zona (test): {'A': 0.129, 'B': 0.175, 'C': 0.208}
Ratio bootstrap 2,5-97,5 %: [0.579 0.93 ]
CV 2999, llindar 0,2: ({'A': 0.269, 'B': 0.268, 'C': 0.269}, 0.994)
FNR per zona: {'A': 0.225, 'B': 0.311, 'C': 0.316} | FPR per zona: {'A': 0.156, 'B': 0.203, 'C': 0.186}
Coeficients de zona: {'A': 0.017, 'B': 0.073, 'C': -0.094}
Escenari proxy (mes nous a C): ({'A': 0.235, 'B': 0.288, 'C': 0.475}, 0.495)Lectura:
- Llindar. Amb pas 0,05 el mínim de (5, 30) és a 0,15 (1.495 €), no a 0,2 (la taula de 04-05 només mirava dècimes): 247 marcats de 750, un terç. Amb (8, 20) el mínim es desplaça a 0,2 (les falses alarmes costen més i les devolucions menys, així que es marca menys), i amb (5, 50) baixa a 0,1 (marcar és barat i fallar caríssim). El llindar és una decisió de negoci parametritzada pels costos; el model no canvia. Amb la capacitat de revisió del Diego (≤ 300/dia sobre 3.000 comandes, un 10 %), 0,15 marcaria massa i la banda 0,2-0,5 de 08-01 continua sent l'operativa.
- Paritat al test: 0,235 / 0,288 / 0,290 → ratio 0,813, just per sobre de 0,8, i amb un interval bootstrap de 0,58 a 0,93: 750 comandes no basten per afirmar ni paritat ni disparitat. Amb les 2.999 comandes en validació creuada les taxes són 0,269 / 0,268 / 0,269 (ratio 0,994): el model és paritari, com havia de ser per construcció, i la desviació del test és soroll de mostreig (en aquell test, la zona C va tenir per atzar més devolucions reals, 0,208 davant de 0,129 a A). Els coeficients de la zona són ≈ 0 (0,017 / 0,073 / −0,094): el model no fa servir la zona.
- Com es detectaria si no ho fos, en ordre d'urgència: (1) ratio de taxes de marcatge < 0,8 de manera persistent en mostres grans (per això 08-01 el mesura cada mes amb dades acumulades, no a cada lot); (2) diferències de FNR/FPR entre zones (una zona a la qual se li escapen més devolucions o se la molesta més), encara que les taxes de marcatge coincideixin; (3) coeficients o importàncies no nuls de la zona, o dels seus proxies. L'escenari simulat mostra aquest últim punt: si a la zona C hi hagués molts més clients nous, la taxa de marcatge pujaria a 0,475 i el ratio cauria a 0,50 encara que el model continuï sense mirar la zona, perquè
client_nouactua com a proxy. Aleshores la pregunta ja no és tècnica sinó de 02-04: és legítim que els clients nous es revisin més (sí: retornen de debò més, 33 % davant de 9 %) i estem segurs que la concentració a C no respon a una altra cosa (renda, origen)? Les respostes possibles: banda de revisió humana més ampla per al grup afectat, llindars per grup (amb assessoria legal), treure el proxy si el seu efecte no es justifica, i sempre documentar-ho a la model card.
Retroalimentació
- Error típic: concloure "hi ha biaix" (o "no n'hi ha") amb una sola mostra petita; el ratio d'impacte té variància i necessita interval i mida. Un altre: comparar taxes de marcatge sense mirar les taxes reals de devolució per grup (paritat de marcatge i paritat d'errors no són el mateix; quina s'exigeix depèn del cas).
- Que la zona tingui coeficient ≈ 0 no garanteix paritat si hi ha proxies; que la paritat es compleixi no garanteix que les decisions siguin justes una a una. Les mètriques de grup són necessàries, no suficients.
- Variants: calcula el cost amb una banda de revisió (3 € per revisió i encert de la revisora del 100 %) per a bandes 0,15-0,5 i 0,2-0,5; repeteix l'anàlisi per
categoriai permetode_pagament(contrareemborsament és un candidat a proxy socioeconòmic); escriuparitat_ok(taxes, minim=0.8)de 02-04 i afegeix-la com a test anovamarket_ia/tests/.
Errors Comuns i Consells
- Comparar xifres de validacions diferents. Tot el que es compara s'ha de mesurar en la mateixa partició o la mateixa validació creuada; i les diferències petites es comparen plec a plec (aparellades), no per mitjanes soltes.
- Confondre mètrica tècnica i de negoci. L'AUC pot pujar sense que el cost baixi (pràctica 2) i el llindar òptim canvia amb els costos (pràctica 5). Reporta sempre totes dues.
- Sèries temporals barrejades i retards amb futur.
TimeSeriesSplitishift(k)amb k > 0; comprova a mà que la fila de la setmana t només conté dades de setmanes < t. - Escalar (o no) sense pensar. k-means i la logística necessiten escalat; els arbres no. I l'escalador s'ajusta només amb entrenament (dins del
Pipeline). - Mostres petites i conclusions grans. 750 comandes són poques per mesurar paritat; 48 setmanes són poques per ajustar retards; una divisió és poc per triar característiques. Quan la conclusió importa, demana més dades o més repeticions.
- Consell: cada pràctica hauria d'acabar en una frase per al Diego: "el model actual es queda; la característica nova no estalvia euros", "la lineal amb calendari, MAE 20, reentrenar cada trimestre", "tres segments i una acció per segment", "llindar 0,2 amb banda, paritari entre zones amb n = 2.999". Si no la pots escriure, la pràctica no ha acabat.
Conclusió
Cinc pràctiques i cinc decisions: un lot de 3.003 files es queda en 2.999 després de treure 3 duplicats i un import de 99.999 €, amb els nuls imputats al pipeline i la fuita fora; una característica d'interacció puja l'AUC de 0,8365 a 0,8409 de manera consistent però no estalvia euros al llindar 0,2, així que el model de producció no canvia; a la demanda, la lineal amb calendari (MAE 22,6 en quatre trimestres) bat les línies base (27-30), els retards (que no aporten res amb soroll independent i desestabilitzen el primer bloc) i el gradient boosting (40,4, que no extrapola la tendència ni aïlla el Black Friday), i treure la variable de Black Friday costa 214 unitats d'error en una sola setmana; k-means troba tres segments (silueta 0,572) amb una acció de negoci per a cadascun; i el llindar òptim depèn dels costos (0,15 amb 5/30, 0,2 amb 8/20, 0,1 amb 5/50) mentre que la paritat entre zones, ambigua amb 750 comandes (ratio 0,81, interval 0,58-0,93), és clara amb 2.999 (0,99), encara que un proxy la podria trencar sense que el model miri la zona.
A 09-03, Projectes de Xarxes Neuronals, pujaràs un esglaó de complexitat amb PyTorch: l'MLP de devolucions amb cerca d'arquitectura i taxa d'aprenentatge, la CNN de fotos amb una tercera classe i augment de dades, una GRU sobre la demanda que compararàs amb la lineal d'aquesta lliçó, i un classificador de ressenyes amb embeddings entrenats des de zero davant de la bossa de paraules. La pregunta de fons serà la de 05-03: quan compensa la xarxa davant del model clàssic ben fet?
Fonaments d'Intel·ligència Artificial (IA)
Mòdul 1: Introducció a la Intel·ligència Artificial
Mòdul 2: Principis Bàsics de la IA
- Conceptes Fonamentals: Agents, Entorns i Racionalitat
- Tipus d'Intel·ligència Artificial
- Les Dades com a Matèria Primera de la IA
- Ètica i Consideracions en IA
Mòdul 3: Algorismes en IA
- Introducció als Algorismes
- Algorismes de Cerca
- Cerca amb Adversari: Jocs i Minimax
- Algorismes d'Optimització
Mòdul 4: Aprenentatge Automàtic (Machine Learning)
- Conceptes Bàsics de Machine Learning
- Tipus d'Aprenentatge Automàtic
- Preparació de Dades i Característiques
- Algorismes de Machine Learning
- Avaluació i Validació de Models
- Sobreajust, Regularització i Ajust d'Hiperparàmetres
Mòdul 5: Xarxes Neuronals i Deep Learning
- Introducció a les Xarxes Neuronals
- Arquitectura de Xarxes Neuronals
- Com Aprèn una Xarxa: Descens del Gradient i Retropropagació
- Deep Learning i les seves Aplicacions
- Transformers, Grans Models de Llenguatge i IA Generativa
Mòdul 6: Lògica i Sistemes Experts
- Lògica en IA
- Sistemes Experts
- Raonament amb Incertesa: Probabilitat i Xarxes Bayesianes
- Aplicacions dels Sistemes Experts
Mòdul 7: Eines i Llenguatges de Programació en IA
- Llenguatges de Programació per a IA
- Python Científic: NumPy, pandas i Matplotlib
- Eines i Llibreries Populars
- Entorns de Desenvolupament
Mòdul 8: Projectes i Casos d'Estudi
Mòdul 9: Exercicis i Pràctiques
- Exercicis d'Algorismes
- Pràctiques de Machine Learning
- Projectes de Xarxes Neuronals
- Projecte Integrador: de la Idea al Prototip
