El dataset de MercaFresh ja està net i sense forats, però "correcte" no és el mateix que "útil". La despesa total dels clients té una cua llarguíssima cap a la dreta (recorda els histogrames del mòdul 2), les dates d'alta són només etiquetes de calendari que cap model no sap interpretar, i la informació més rica —les comandes individuals— és a nivell de comanda quan el model de churn necessita una fila per client. En aquesta lliçó aprendràs a remodelar variables: transformacions matemàtiques que corregeixen l'asimetria, discretització en trams, descomposició de dates en components amb significat i agregacions que converteixen milers de comandes en un perfil per client. Tancarem amb els pipelines de scikit-learn, l'eina que ordena tots aquests passos en una cadena reproduïble.
Contingut
- Per què transformar: quan la forma de les dades fa nosa
- Transformacions matemàtiques: log, arrel i potències
- Box-Cox i Yeo-Johnson: la transformació que s'ajusta sola
- Binning: discretitzar variables contínues
- Dates: de text mort a components útils
- Agregacions per entitat: de comandes a clients
- Pipelines: encadenar transformacions amb ordre
Per què transformar: quan la forma de les dades fa nosa
Molts models rendeixen millor —o directament assumeixen— que les variables tenen distribucions raonablement simètriques i relacions aproximadament lineals. Però les variables de negoci rarament neixen així:
- Imports i despeses: la majoria de clients de MercaFresh gasta poc i uns quants gasten moltíssim. És l'asimetria positiva que vas veure a 02-01 (mitjana > mediana) i una distribució semblant a l'exponencial de 02-02.
- Recomptes: nombre de comandes, de visites, d'incidències — cues llargues a la dreta.
- Dates: un
datetimeno diu res per si mateix; el que prediu és allò que la data implica (cap de setmana?, quant fa?). - Granularitat equivocada: tenim 80.000 comandes, però el churn es prediu per client.
Transformar és canviar la representació de les dades sense canviar-ne el significat, perquè els patrons quedin més a la vista del model.
Transformacions matemàtiques: log, arrel i potències
El logaritme, la reina de les transformacions
El logaritme comprimeix els valors grans molt més que els petits, així que "recull" la cua dreta d'una distribució asimètrica:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(42)
# Despesa anual simulada de 1000 clients: majoria modesta, cua de grans comptes
despesa = rng.lognormal(mean=6.5, sigma=0.9, size=1000)
fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))
axes[0].hist(despesa, bins=40)
axes[0].set_title("Despesa anual (original): asimetria forta")
axes[1].hist(np.log1p(despesa), bins=40)
axes[1].set_title("log(1 + despesa): gairebe simetric")
plt.tight_layout()
plt.show()
print(f"Asimetria original: {pd.Series(despesa).skew():.2f}") # ~3 (molt esbiaixada)
print(f"Asimetria despres del log: {pd.Series(np.log1p(despesa)).skew():.2f}") # ~0 (simetrica)Detalls que importen:
- Fem servir
np.log1p(x)(que calculalog(1 + x)) en lloc denp.log(x)perquè el logaritme de 0 és menys infinit, i a MercaFresh hi ha clients amb despesa 0.log1pgestiona el 0 amb elegància (log1p(0) = 0). skew()mesura l'asimetria numèricament: ~0 és simètrica, > 1 és biaix fort a la dreta. És la traducció a nombre del que l'histograma mostra a ull.- Després del logaritme, la distribució s'assembla a la normal de 02-02, i les eines que assumeixen normalitat (z-scores inclosos) tornen a funcionar bé.
L'escala de transformacions
El logaritme no és l'única opció; hi ha una "escala" ordenada per agressivitat:
| Transformació | Fórmula | Força | Restricció | Ús típic |
|---|---|---|---|---|
| Arrel quadrada | np.sqrt(x) |
Suau | x ≥ 0 | Recomptes (nre. de comandes) |
| Logaritme | np.log1p(x) |
Mitjana | x > -1 | Imports, ingressos |
| Inversa | 1 / x |
Forta | x ≠ 0 | Cues extremes (rar) |
Regla pràctica: prova de menys a més agressiva i comprova amb skew() i l'histograma quina deixa la distribució més simètrica sense retorçar-la.
Box-Cox i Yeo-Johnson: la transformació que s'ajusta sola
En lloc de triar a mà entre arrel i logaritme, Box-Cox busca automàticament l'exponent òptim (anomenat λ, lambda) que fa la variable tan semblant com sigui possible a una normal. λ = 0 equival al logaritme; λ = 0,5, a l'arrel; λ = 1, a no fer res.
from scipy import stats
# Box-Cox exigeix valors estrictament positius
despesa_bc, lambda_optim = stats.boxcox(despesa)
print(f"Lambda optim: {lambda_optim:.3f}") # ~0.05: gairebe un logaritme purLimitació: Box-Cox només admet valors positius. Per a variables amb zeros o negatius (un saldo de punts que pot ser negatiu, una variació de despesa), hi ha Yeo-Johnson, la seva generalització. A scikit-learn totes dues viuen a PowerTransformer, amb l'avantatge del patró fit/transform que ja coneixes de la lliçó anterior (aprèn λ a train, l'aplica a dades noves):
from sklearn.preprocessing import PowerTransformer
pt = PowerTransformer(method="yeo-johnson") # o method="box-cox"
despesa_transformada = pt.fit_transform(despesa.reshape(-1, 1))
print(pt.lambdas_) # la lambda apresa, guardada per reutilitzar(El reshape(-1, 1) converteix el vector en una matriu d'una columna: els transformadors de scikit-learn sempre esperen dades en 2D, files × columnes.)
Una advertència de llegibilitat: després de transformar, els valors ja no són en euros. Un coeficient sobre log(despesa) s'interpreta en termes multiplicatius, no additius. Guanyem rendiment del model a canvi d'una interpretació menys directa; convé guardar sempre la variable original al costat.
Binning: discretitzar variables contínues
Discretitzar (o fer binning) és convertir una variable contínua en trams: l'edat exacta en franges d'edat, la despesa en nivells baix/mitjà/alt. Per a què?
- Capturar relacions no lineals senzilles ("els menors de 30 i els majors de 65 compren diferent").
- Robustesa davant d'outliers: el client de 15.400 € cau al tram "alt" i deixa de distorsionar.
- Comunicació amb negoci: "clients de despesa alta" s'entén millor que un coeficient.
El preu: es perd informació (tots els del tram es tornen indistingibles). És un intercanvi, no un truc gratuït.
cut: trams per valor
edats = pd.Series([22, 34, 45, 29, 61, 38, 70, 27, 52, 41])
franges = pd.cut(edats,
bins=[18, 30, 45, 65, 100],
labels=["jove", "adult", "senior", "gran"])
print(franges.value_counts())cut talla on tu dius: els límits tenen significat de negoci (majoria d'edat, jubilació). Cada interval inclou el seu límit dret per defecte: (18, 30], (30, 45], etc.
qcut: trams per quantils
despesa_s = pd.Series(despesa)
nivells = pd.qcut(despesa_s, q=4, labels=["Q1", "Q2", "Q3", "Q4"])
print(nivells.value_counts()) # 250 clients a cada tram, garantitqcut talla per percentils (els de 02-01): cada tram té el mateix nombre d'observacions, encara que els seus límits en euros surtin "lletjos". És la tria natural per a variables asimètriques: amb cut a intervals iguals, el 90 % dels clients cauria al primer tram.
KBinsDiscretizer: binning dins de scikit-learn
from sklearn.preprocessing import KBinsDiscretizer
kb = KBinsDiscretizer(n_bins=4, encode="ordinal", strategy="quantile")
despesa_binned = kb.fit_transform(despesa_s.to_frame())
print(kb.bin_edges_[0].round(0)) # els limits apresos al fitstrategy accepta "uniform" (amplades iguals, com un cut automàtic), "quantile" (com qcut) i "kmeans". El seu valor afegit és el de sempre: els límits s'aprenen a fit i es reapliquen idèntics a dades noves, evitant que cada mes els trams es moguin.
| Eina | Talls | Quan fer-la servir |
|---|---|---|
pd.cut |
On tu decideixes | Límits amb significat de negoci |
pd.qcut |
Per quantils | Variables asimètriques, trams equilibrats |
KBinsDiscretizer |
Apresos a fit |
Dins d'un flux de ML reproduïble |
Dates: de text mort a components útils
A 03-01 vam convertir les dates de text a datetime. Ara les espremem: un model no pot fer servir "2024-06-05", però sí els senyals que aquella data amaga.
comandes = pd.DataFrame({
"id_comanda": range(1, 8),
"id_client": [101, 101, 105, 107, 105, 110, 101],
"data": pd.to_datetime(["2026-05-02", "2026-05-16", "2026-05-17",
"2026-06-01", "2026-06-14", "2026-07-25",
"2026-08-10"]),
"import_comanda": [45.2, 38.9, 120.5, 15400.0, 95.3, 22.1, 51.7],
})
# L'accessor .dt dona acces als components de la data
comandes["any"] = comandes["data"].dt.year
comandes["mes"] = comandes["data"].dt.month
comandes["dia_setmana"] = comandes["data"].dt.dayofweek # 0 = dilluns ... 6 = diumenge
comandes["es_cap_setmana"] = (comandes["dia_setmana"] >= 5).astype(int)
# Festius: amb una llista de negoci (o la llibreria 'holidays' per al calendari oficial)
festius = pd.to_datetime(["2026-06-01", "2026-08-15"])
comandes["es_festiu"] = comandes["data"].isin(festius).astype(int)
# Recencia: dies transcorreguts des de la data fins a una data de referencia
avui = pd.Timestamp("2026-08-24")
comandes["dies_des_comanda"] = (avui - comandes["data"]).dt.daysPer què funciona? Perquè per predir demanda o churn a MercaFresh, el rellevant no és el 5 de juny en si, sinó que era dilluns, que era festiu o que va ser fa 80 dies. Cada component extret converteix coneixement del calendari en una columna que el model pot fer servir.
Dos matisos:
dayofweekimonthsón numèrics però cíclics: el diumenge (6) és "al costat" del dilluns (0), i el desembre al costat del gener. Molts models els tracten bé com a categories (lliçó 03-04); les codificacions trigonomètriques sinus/cosinus existeixen per a models sensibles a l'ordre, i n'hi ha prou de saber que aquest matís hi és.- La "data de referència" de la recència ha de ser una data fixa de l'anàlisi (la de tall del dataset), no
Timestamp.now(): si no, el mateix codi dona resultats diferents cada dia.
Agregacions per entitat: de comandes a clients
El model de churn necessita una fila per client, però la informació viva és a la taula de comandes. La solució és agregar: resumir l'historial de cada client en estadístics.
perfil = comandes.groupby("id_client").agg(
num_comandes=("id_comanda", "count"),
despesa_mitjana=("import_comanda", "mean"),
despesa_total=("import_comanda", "sum"),
despesa_maxima=("import_comanda", "max"),
primera_comanda=("data", "min"),
ultima_comanda=("data", "max"),
)
# Frequencia i recencia derivades de les dates agregades
perfil["dies_com_a_client"] = (avui - perfil["primera_comanda"]).dt.days
perfil["dies_sense_comprar"] = (avui - perfil["ultima_comanda"]).dt.days
perfil["comandes_per_mes"] = perfil["num_comandes"] / (perfil["dies_com_a_client"] / 30)
print(perfil.round(2))La sintaxi agg(nom_nou=("columna", "funcio")) (agregació amb nom) produeix columnes amb noms nets en un sol pas. Observa què hem aconseguit: el client 101, amb tres comandes repartides entre maig i agost, queda descrit per la seva freqüència, el seu tiquet mitjà i —or pur per al churn— quants dies porta sense comprar.
flowchart LR
A["Taula de comandes<br/>(1 fila = 1 comanda)"] -->|"groupby('id_client').agg(...)"| B["Perfil de client<br/>(1 fila = 1 client)"]
B --> C["Es combina amb la taula de clients<br/>(merge per id_client)"]
L'últim pas és unir aquest perfil a la taula de clients neta de les lliçons anteriors:
# clients: la taula neta de 03-01/03-02
# perfil: les agregacions que acabem de construir
dataset = clients.merge(perfil, on="id_client", how="left")Amb how="left" conservem també els clients sense comandes (els seus agregats quedaran com a NaN... que ja saps tractar de la lliçó anterior: aquí un 0 a num_comandes i un indicador sense_comandes serien imputacions amb ple sentit de negoci). Aquestes agregacions són l'avantsala de l'anàlisi RFM que completarem a 03-06.
Pipelines: encadenar transformacions amb ordre
Portem ja uns quants passos encadenats: imputar, transformar amb logaritmes, discretitzar... i cadascun amb el seu fit sobre train i el seu transform sobre la resta. Fer-ho a mà, en ordre, sense oblidar-ne cap i sense fuites, és fràgil. scikit-learn ofereix dues peces per assemblar-ho:
Pipeline: passos en sèrie
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import PowerTransformer
pipe_numeric = Pipeline(steps=[
("imputar", SimpleImputer(strategy="median")),
("desasimetritzar", PowerTransformer(method="yeo-johnson")),
])
resultat = pipe_numeric.fit_transform(perfil[["despesa_mitjana", "despesa_total"]])Un Pipeline és una llista de passos amb nom que s'executen en ordre: la sortida de cadascun alimenta el següent. En cridar fit_transform, cada pas fa el seu fit i el seu transform en cadena; en cridar després transform sobre dades noves, tots apliquen allò après sense reaprendre res. Un sol objecte encapsula tot el preprocessament.
ColumnTransformer: passos en paral·lel per columnes
No totes les columnes necessiten el mateix: les numèriques volen imputació i logaritmes; les categòriques, altres tècniques (les de la propera lliçó). ColumnTransformer aplica a cada grup de columnes el seu propi tractament i ajunta els resultats:
from sklearn.compose import ColumnTransformer
preprocessador = ColumnTransformer(transformers=[
("numeriques", pipe_numeric, ["despesa_mitjana", "despesa_total", "num_comandes"]),
("dates_ja_derivades", "passthrough", ["dies_sense_comprar"]),
# ("categoriques", ..., ["ciutat", "pla"]) <- ho omplirem a 03-04
])
X = preprocessador.fit_transform(dataset_exemple)Cada tupla és (nom, transformador, llista_de_columnes); "passthrough" deixa passar les columnes tal qual. De moment queda't amb la idea introductòria: el preprocessament complet pot viure en un únic objecte que s'ajusta amb fit sobre entrenament i s'aplica amb transform a qualsevol dada futura. A les properes lliçons anirem omplint les branques que falten, i al mòdul 6 el pipeline demostrarà el seu valor definitiu en combinar-se amb la validació.
Errors Comuns i Consells
- Aplicar
np.loga valors amb zeros. Produeix-infsilenciosos que fan petar el model més tard. Fes servirnp.log1p, i verifica abans que no hi ha negatius. - Transformar i oblidar la des-transformació. Si el model prediu
log(despesa), la predicció en euros ésnp.expm1(prediccio). Documenta quines variables estan transformades. - Triar trams de
cutque deixen grups buits. Comprova semprevalue_counts()després de discretitzar; un tram amb 3 observacions no aporta res. Amb asimetria,qcutsol ser millor punt de partida. - Fer servir
Timestamp.now()com a referència de recència. El dataset ha de tenir una data de tall fixa i documentada, o els resultats canvien cada dia que executes el codi. - Agregar amb
meanen columnes amb outliers. La despesa mitjana del client 107 està dominada per la seva comanda de 15.400 €; considera afegir també la mediana per client. - Ajustar transformadors amb tot el dataset.
PowerTransformeriKBinsDiscretizeraprenen paràmetres: el mateix risc de fuita que la imputació (03-02). Dins d'unPipeline, aquest error es torna gairebé impossible de cometre — una altra raó per fer-los servir. - Consell: abans i després de cada transformació, dibuixa l'histograma i mira
skew(). Dues línies de codi que eviten transformar a cegues.
Exercicis
Exercici 1
La variable num_incidencies de MercaFresh (nombre d'incidències de lliurament per client) pren els valors [0, 0, 1, 0, 2, 0, 1, 9, 0, 3]. Calcula la seva asimetria amb skew(), aplica la transformació de l'escala que consideris adequada (justifica quina i per què el logaritme simple donaria problemes) i recalcula l'asimetria.
Exercici 2
Divideix els imports de comanda [12, 18, 22, 25, 31, 38, 45, 60, 85, 240] en 4 nivells amb qcut i en 4 trams d'igual amplada amb cut. Compara els value_counts() de tots dos i explica quin és més útil aquí i per què.
Exercici 3
A partir del DataFrame comandes de la lliçó, construeix amb una sola agregació el perfil per client amb: nombre de comandes, mediana d'import i dies des de l'última comanda (referència: 2026-08-24). Anomena les columnes n_comandes, mediana_import i recencia_dies.
Solucions
Exercici 1
s = pd.Series([0, 0, 1, 0, 2, 0, 1, 9, 0, 3])
print(s.skew()) # ~2.6: asimetria forta a la dreta
# Es un recompte amb zeros: np.log(0) = -inf, aixi que el log simple falla.
# Opcions valides: np.sqrt (suau, admet 0) o np.log1p (mitjana, admet 0).
t = np.log1p(s)
print(t.skew()) # ~1.1: molt mes contingudaAmb recomptes petits i zeros abundants, sqrt o log1p són les tries naturals; el logaritme pur queda descartat pels zeros.
Exercici 2
imp = pd.Series([12, 18, 22, 25, 31, 38, 45, 60, 85, 240])
per_quantils = pd.qcut(imp, q=4)
per_amplada = pd.cut(imp, bins=4)
print(per_quantils.value_counts()) # 3-2-2-3: trams equilibrats
print(per_amplada.value_counts()) # 9-0-0-1: gairebe tot al primer tram!Amb cut, l'outlier de 240 estira el rang i deixa dos trams buits: inútil. qcut reparteix les observacions per percentils i produeix nivells equilibrats: és l'opció adequada per a aquesta distribució asimètrica.
Exercici 3
avui = pd.Timestamp("2026-08-24")
perfil = comandes.groupby("id_client").agg(
n_comandes=("id_comanda", "count"),
mediana_import=("import_comanda", "median"),
ultima=("data", "max"),
)
perfil["recencia_dies"] = (avui - perfil["ultima"]).dt.days
perfil = perfil.drop(columns=["ultima"])
print(perfil)La mediana d'import protegeix el perfil del client 107 de la seva comanda gegant, i recencia_dies —els dies sense comprar— serà una de les variables estrella del model de churn.
Conclusió
Has après a remodelar les dades perquè mostrin els seus patrons: corregir asimetries amb logaritmes, arrels i les transformacions automàtiques Box-Cox/Yeo-Johnson; discretitzar amb cut, qcut i KBinsDiscretizer sabent què es guanya i què es perd; convertir dates en components amb poder predictiu (dia de la setmana, festiu, recència); resumir la taula de comandes en perfils per client amb groupby().agg(); i encadenar-ho tot en Pipeline i ColumnTransformer perquè el preprocessament sigui reproduïble i a prova de fuites.
Hauràs notat que el ColumnTransformer va deixar una branca buida: les columnes categòriques. La ciutat del client, la categoria de producte o el pla de subscripció de MercaFresh continuen sent text, i cap model no sap multiplicar per "Barcelona". A la propera lliçó resolem exactament això: la codificació de variables categòriques.
Curs de Machine Learning
Mòdul 1: Introducció al Machine Learning
- Què és el Machine Learning?
- Història i evolució del Machine Learning
- Tipus de Machine Learning
- Aplicacions del Machine Learning
- El flux de treball d'un projecte de Machine Learning
Mòdul 2: Fonaments d'Estadística i Probabilitat
- Conceptes bàsics d'estadística
- Distribucions de probabilitat
- Correlació i covariància
- Inferència estadística
- Teorema de Bayes
Mòdul 3: Preprocessament de Dades
- Neteja de dades
- Gestió de dades mancants
- Transformació de dades
- Codificació de variables categòriques
- Normalització i estandardització
- Enginyeria de característiques
Mòdul 4: Algorismes de Machine Learning Supervisat
- Regressió lineal
- Regressió logística
- Arbres de decisió
- Màquines de suport vectorial (SVM)
- K veïns més propers (K-NN)
- Naive Bayes
- Xarxes neuronals
Mòdul 5: Algorismes de Machine Learning No Supervisat
- Clustering: K-means
- Clustering jeràrquic
- Anàlisi de components principals (PCA)
- Anàlisi d'agrupament DBSCAN
- Visualització de dades amb t-SNE i UMAP
Mòdul 6: Avaluació i Validació de Models
- Divisió de dades: entrenament, validació i prova
- Mètriques d'avaluació
- Validació creuada
- Corba ROC i AUC
- Overfitting i underfitting
Mòdul 7: Tècniques Avançades i Optimització
- Regularització: Ridge, Lasso i Elastic Net
- Ensemble Learning
- Gradient Boosting
- Xarxes neuronals profundes (Deep Learning)
- Optimització d'hiperparàmetres
Mòdul 8: Implementació i Desplegament de Models
- Frameworks i biblioteques populars
- Implementació de models en producció
- Manteniment i monitoratge de models
- Consideracions ètiques i de privadesa
Mòdul 9: Projectes Pràctics
- Projecte 1: Predicció de preus d'habitatges
- Projecte 2: Classificació d'imatges
- Projecte 3: Anàlisi de sentiments a les xarxes socials
- Projecte 4: Detecció de fraus
- Projecte 5: Segmentació de clients
