Introduzione: Perché il Feature Engineering È la Chiave del Machine Learning
Puoi scegliere l'algoritmo più sofisticato sul mercato, ma se le feature che gli dai in pasto sono scadenti, il risultato sarà altrettanto scadente. Punto. Questa è una di quelle lezioni che si imparano solo lavorando con i dati reali: la qualità delle feature conta più della complessità del modello.
Il feature engineering — cioè quel processo di trasformazione dei dati grezzi in variabili utili per un modello predittivo — è ciò che davvero separa un progetto di ML amatoriale da uno professionale. Non è un caso che, secondo numerosi sondaggi, i data scientist dedicano oltre il 60% del loro tempo a preparare e trasformare i dati. Non è tempo sprecato: è l'investimento con il maggiore ritorno in termini di accuratezza.
In questa guida percorreremo l'intero workflow di feature engineering in Python, partendo dalle trasformazioni numeriche fino alla codifica delle variabili categoriche, dall'estrazione di feature temporali alla costruzione di pipeline professionali con scikit-learn 1.8. Vedremo anche Feature-engine, una libreria specializzata che semplifica enormemente il processo, e chiuderemo con un progetto pratico completo su un dataset reale.
Se hai già letto la nostra Guida Pratica al Machine Learning con scikit-learn, considera questo articolo il suo prequel naturale: prima di addestrare e validare un modello, devi preparare i dati nel modo giusto.
Trasformazione delle Variabili Numeriche
Le variabili numeriche raramente sono pronte per essere usate così come sono. Differenze di scala, distribuzioni asimmetriche, outlier: tutti problemi che possono compromettere drasticamente le prestazioni di molti algoritmi.
Allora, da dove si parte? Dalle tecniche fondamentali.
Standardizzazione e Normalizzazione
La standardizzazione (Z-score) trasforma i dati in modo che abbiano media zero e deviazione standard unitaria. La normalizzazione (Min-Max) ridimensiona i valori nell'intervallo [0, 1]. Entrambe sono essenziali per algoritmi sensibili alla scala come SVM, k-NN e le reti neurali.
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler, MinMaxScaler
# Dataset di esempio
np.random.seed(42)
df = pd.DataFrame({
"eta": np.random.randint(18, 70, 500),
"reddito_annuo": np.random.uniform(15000, 120000, 500),
"punteggio_credito": np.random.randint(300, 850, 500),
})
# Standardizzazione (Z-score)
scaler_std = StandardScaler()
df_std = pd.DataFrame(
scaler_std.fit_transform(df),
columns=df.columns
)
print("Media dopo StandardScaler:")
print(df_std.mean().round(6))
# Tutte le medie sono ~0, tutte le std ~1
# Normalizzazione (Min-Max)
scaler_mm = MinMaxScaler()
df_norm = pd.DataFrame(
scaler_mm.fit_transform(df),
columns=df.columns
)
print("\nRange dopo MinMaxScaler:")
print(df_norm.describe().loc[["min", "max"]])
# Tutti i valori tra 0 e 1
Trasformazioni di Potenza: PowerTransformer e Box-Cox
Molti modelli funzionano meglio con dati distribuiti in modo approssimativamente normale. Il PowerTransformer di scikit-learn applica trasformazioni di tipo Box-Cox o Yeo-Johnson per ridurre l'asimmetria (skewness) delle distribuzioni. Onestamente, è uno di quegli strumenti che fa una differenza enorme con pochissimo sforzo.
from sklearn.preprocessing import PowerTransformer
from scipy import stats
# Creiamo una variabile fortemente asimmetrica
df["spesa_mensile"] = np.random.exponential(scale=500, size=500)
print(f"Skewness originale: {df['spesa_mensile'].skew():.2f}")
# Yeo-Johnson (gestisce anche valori <= 0)
pt = PowerTransformer(method="yeo-johnson", standardize=True)
df["spesa_yj"] = pt.fit_transform(df[["spesa_mensile"]])
print(f"Skewness dopo Yeo-Johnson: {df['spesa_yj'].skew():.2f}")
# Box-Cox (solo per valori strettamente positivi)
pt_bc = PowerTransformer(method="box-cox", standardize=True)
df["spesa_bc"] = pt_bc.fit_transform(df[["spesa_mensile"]])
print(f"Skewness dopo Box-Cox: {df['spesa_bc'].skew():.2f}")
# Trasformazione logaritmica manuale (alternativa semplice)
df["spesa_log"] = np.log1p(df["spesa_mensile"])
print(f"Skewness dopo log1p: {df['spesa_log'].skew():.2f}")
Discretizzazione con KBinsDiscretizer
A volte conviene trasformare variabili continue in categorie discrete. Questo è particolarmente utile per gli alberi decisionali e per aggiungere non-linearità a modelli lineari. KBinsDiscretizer di scikit-learn offre tre strategie: uniform (intervalli uguali), quantile (stessa frequenza per bin) e kmeans (basata su clustering).
from sklearn.preprocessing import KBinsDiscretizer
# Discretizzazione dell'età in 5 fasce
kbd = KBinsDiscretizer(
n_bins=5,
encode="ordinal",
strategy="quantile",
subsample=None
)
df["fascia_eta"] = kbd.fit_transform(df[["eta"]])
print("\nDistribuzione fasce d'età:")
print(df["fascia_eta"].value_counts().sort_index())
# Con encode="onehot-dense" per ottenere colonne binarie
kbd_oh = KBinsDiscretizer(
n_bins=4,
encode="onehot-dense",
strategy="uniform"
)
eta_bins = kbd_oh.fit_transform(df[["eta"]])
print(f"\nShape one-hot: {eta_bins.shape}")
# (500, 4) — una colonna per ogni bin
Creazione di Feature di Interazione e Polinomiali
Combinare feature esistenti può catturare relazioni non lineari che il modello non riesce a scoprire da solo. PolynomialFeatures genera automaticamente tutte le combinazioni polinomiali fino a un grado specificato. Va usato con giudizio (le colonne crescono velocemente), ma nei casi giusti è davvero potente.
from sklearn.preprocessing import PolynomialFeatures
# Feature polinomiali di grado 2
poly = PolynomialFeatures(
degree=2,
interaction_only=False,
include_bias=False
)
X_num = df[["eta", "reddito_annuo", "punteggio_credito"]]
X_poly = poly.fit_transform(X_num)
print(f"Feature originali: {X_num.shape[1]}")
print(f"Feature polinomiali: {X_poly.shape[1]}")
print(f"Nomi: {poly.get_feature_names_out()}")
# Solo interazioni (senza quadrati)
poly_int = PolynomialFeatures(
degree=2,
interaction_only=True,
include_bias=False
)
X_int = poly_int.fit_transform(X_num)
print(f"\nSolo interazioni: {X_int.shape[1]} feature")
print(f"Nomi: {poly_int.get_feature_names_out()}")
Codifica delle Variabili Categoriche
La maggior parte degli algoritmi di machine learning lavora solo con numeri. Le variabili categoriche — colore, città, professione — devono quindi essere convertite in formato numerico.
Ma attenzione: la scelta della tecnica di encoding influisce direttamente sulle prestazioni del modello. Non è un dettaglio da poco.
OrdinalEncoder: quando l'ordine conta
Usa l'OrdinalEncoder quando le categorie hanno un ordine naturale e logico: taglia della maglietta (S, M, L, XL), livello di istruzione, grado di soddisfazione. Il punto chiave è definire esplicitamente l'ordine, altrimenti scikit-learn ne assegnerà uno arbitrario (e questo non va bene).
from sklearn.preprocessing import OrdinalEncoder
df_cat = pd.DataFrame({
"taglia": ["S", "M", "L", "XL", "M", "S", "L", "XL"],
"istruzione": ["media", "laurea", "superiore", "media",
"laurea", "elementare", "superiore", "laurea"],
})
# Definiamo esplicitamente l'ordine
oe = OrdinalEncoder(
categories=[
["S", "M", "L", "XL"],
["elementare", "media", "superiore", "laurea"]
],
handle_unknown="use_encoded_value",
unknown_value=-1
)
df_cat_enc = pd.DataFrame(
oe.fit_transform(df_cat),
columns=["taglia_ord", "istruzione_ord"]
)
print(df_cat_enc)
OneHotEncoder: la scelta sicura per le nominali
Per variabili senza un ordine naturale — come il colore o la città — il one-hot encoding è lo standard. Crea una colonna binaria per ogni categoria. In scikit-learn 1.8, l'integrazione con pandas è fluida grazie a set_output.
from sklearn.preprocessing import OneHotEncoder
df_nom = pd.DataFrame({
"colore": ["rosso", "blu", "verde", "rosso", "blu", "verde"],
"citta": ["Roma", "Milano", "Napoli", "Roma", "Torino", "Milano"],
})
ohe = OneHotEncoder(
sparse_output=False,
drop="first", # evita multicollinearità
handle_unknown="ignore", # gestisce categorie mai viste
min_frequency=2 # accorpa categorie rare
)
ohe.set_output(transform="pandas")
df_ohe = ohe.fit_transform(df_nom)
print(df_ohe)
print(f"\nFeature names: {ohe.get_feature_names_out()}")
TargetEncoder: il meglio per l'alta cardinalità
Il TargetEncoder, disponibile nativamente in scikit-learn dalla versione 1.3, codifica ogni categoria usando la media della variabile target condizionata su quella categoria. È perfetto per variabili con molte categorie (alta cardinalità) dove il one-hot encoding creerebbe troppe colonne.
La sua implementazione include un meccanismo di cross-fitting interno per prevenire il data leakage — ed è proprio questa la sua forza principale.
from sklearn.preprocessing import TargetEncoder
# Simuliamo un dataset con alta cardinalità
np.random.seed(42)
n = 1000
df_te = pd.DataFrame({
"codice_postale": np.random.choice(
[f"{i:05d}" for i in range(10000, 10200)], n
),
"marca_auto": np.random.choice(
["Fiat", "BMW", "Toyota", "Mercedes", "Renault",
"Volkswagen", "Audi", "Peugeot", "Hyundai", "Kia",
"Ford", "Opel", "Citroen", "Seat", "Skoda"], n
),
})
# Target numerico
y = np.random.uniform(10000, 50000, n)
te = TargetEncoder(
smooth="auto", # regolarizzazione automatica
cv=5, # folds per il cross-fitting
target_type="continuous"
)
te.set_output(transform="pandas")
# IMPORTANTE: usare fit_transform sul training set
df_te_encoded = te.fit_transform(df_te, y)
print(df_te_encoded.head())
print(f"\nColonne: {df_te_encoded.shape[1]} (vs {df_te.nunique().sum()} categorie uniche)")
Attenzione: con TargetEncoder, fit_transform(X, y) produce risultati diversi da fit(X, y).transform(X). Il primo usa il cross-fitting interno per evitare overfitting; il secondo no. Usa sempre fit_transform per i dati di training.
Estrazione di Feature da Date e Testo
Le colonne datetime e testuali contengono informazioni preziose che restano del tutto invisibili al modello se non vengono esplicitamente estratte. Vediamo come sfruttarle al meglio.
Feature Temporali con pandas
Da una singola colonna datetime si possono estrarre decine di feature utili: anno, mese, giorno della settimana, ora, se è weekend, la stagione, il trimestre e molto altro. Queste feature catturano pattern ciclici e stagionali che altrimenti andrebbero persi.
# Dataset con date
df_date = pd.DataFrame({
"data_ordine": pd.date_range("2024-01-01", periods=1000, freq="4h"),
"importo": np.random.uniform(10, 500, 1000)
})
# Estrazione feature temporali
dt = df_date["data_ordine"].dt
df_date["anno"] = dt.year
df_date["mese"] = dt.month
df_date["giorno_settimana"] = dt.dayofweek # 0=lunedì, 6=domenica
df_date["ora"] = dt.hour
df_date["giorno_anno"] = dt.dayofyear
df_date["trimestre"] = dt.quarter
df_date["is_weekend"] = (dt.dayofweek >= 5).astype(int)
# Feature cicliche (per catturare la natura circolare di ore e mesi)
df_date["ora_sin"] = np.sin(2 * np.pi * dt.hour / 24)
df_date["ora_cos"] = np.cos(2 * np.pi * dt.hour / 24)
df_date["mese_sin"] = np.sin(2 * np.pi * dt.month / 12)
df_date["mese_cos"] = np.cos(2 * np.pi * dt.month / 12)
# Giorni trascorsi da un evento di riferimento
df_date["giorni_da_inizio"] = (
df_date["data_ordine"] - df_date["data_ordine"].min()
).dt.days
print(df_date.head(10))
Le feature cicliche (seno e coseno) sono particolarmente importanti: senza di esse, il modello non capisce che l'ora 23 è vicina all'ora 0, o che dicembre è vicino a gennaio. La trasformazione seno/coseno proietta i valori ciclici su un cerchio, preservando questa prossimità. Nella mia esperienza, è un trucco semplice che spesso migliora le performance in modo significativo.
Feature dal Testo con TfidfVectorizer
Per le colonne testuali, la tecnica più comune è il TF-IDF (Term Frequency-Inverse Document Frequency), che converte il testo in vettori numerici pesati in base all'importanza di ogni termine nel corpus.
from sklearn.feature_extraction.text import TfidfVectorizer
descrizioni = [
"Smartphone Samsung Galaxy 128GB nero",
"Laptop HP Pavilion 16 pollici SSD 512GB",
"Cuffie Bluetooth wireless con cancellazione rumore",
"Tablet Apple iPad Air 256GB grigio siderale",
"Monitor LG 27 pollici 4K USB-C",
]
tfidf = TfidfVectorizer(
max_features=20,
stop_words=None, # per l'italiano usa una lista custom
ngram_range=(1, 2) # unigrammi e bigrammi
)
X_tfidf = tfidf.fit_transform(descrizioni)
print(f"Shape: {X_tfidf.shape}")
print(f"Vocabolario: {tfidf.get_feature_names_out()}")
ColumnTransformer e Pipeline: il Workflow Professionale
Nelle sezioni precedenti abbiamo visto le singole tecniche. Adesso è il momento di metterle insieme in un workflow pulito, riproducibile e privo di data leakage.
Gli strumenti chiave? ColumnTransformer e Pipeline di scikit-learn.
Perché usare le Pipeline?
Senza pipeline, il codice di preprocessing diventa rapidamente un groviglio di trasformazioni manuali — difficile da mantenere e pieno di insidie. Le pipeline risolvono tre problemi fondamentali:
- Data leakage: il fit avviene solo sui dati di training, mai su quelli di test
- Riproducibilità: tutto il workflow è incapsulato in un unico oggetto serializzabile
- Cross-validation corretta: ogni fold applica il preprocessing in modo indipendente
Se c'è una cosa che ho imparato nei progetti reali, è che le pipeline non sono un lusso: sono una necessità. Il debugging diventa molto più semplice quando ogni step è esplicito e tracciabile.
Costruire una Pipeline Completa
from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import (
StandardScaler, OneHotEncoder, OrdinalEncoder, TargetEncoder
)
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import cross_val_score
import sklearn
# Configurazione globale: output in formato pandas
sklearn.set_config(transform_output="pandas")
# Dataset di esempio realistico
np.random.seed(42)
n = 2000
df_full = pd.DataFrame({
"eta": np.random.randint(18, 70, n).astype(float),
"reddito": np.random.uniform(15000, 120000, n),
"punteggio": np.random.randint(300, 850, n).astype(float),
"istruzione": np.random.choice(
["elementare", "media", "superiore", "laurea", "master"], n
),
"citta": np.random.choice(
["Roma", "Milano", "Napoli", "Torino", "Bologna",
"Firenze", "Palermo", "Genova", "Bari", "Catania"], n
),
"professione": np.random.choice(
[f"prof_{i}" for i in range(50)], n # alta cardinalità
),
})
# Introduciamo valori mancanti
df_full.loc[df_full.sample(frac=0.05).index, "eta"] = np.nan
df_full.loc[df_full.sample(frac=0.03).index, "reddito"] = np.nan
df_full.loc[df_full.sample(frac=0.04).index, "citta"] = np.nan
y_full = (df_full["reddito"].fillna(50000) > 60000).astype(int)
# Definizione delle colonne per tipo
col_numeriche = ["eta", "reddito", "punteggio"]
col_ordinali = ["istruzione"]
col_nominali = ["citta"]
col_alta_cardinalita = ["professione"]
# Pipeline per variabili numeriche
pipe_num = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
# Pipeline per variabili ordinali
pipe_ord = Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("encoder", OrdinalEncoder(
categories=[["elementare", "media", "superiore", "laurea", "master"]]
)),
])
# Pipeline per variabili nominali a bassa cardinalità
pipe_nom = Pipeline([
("imputer", SimpleImputer(strategy="constant", fill_value="sconosciuto")),
("encoder", OneHotEncoder(
drop="first",
sparse_output=False,
handle_unknown="ignore",
min_frequency=0.02
)),
])
# Pipeline per variabili ad alta cardinalità
pipe_target = Pipeline([
("imputer", SimpleImputer(strategy="constant", fill_value="sconosciuto")),
("encoder", TargetEncoder(smooth="auto", cv=5)),
])
# ColumnTransformer: combiniamo tutto
preprocessor = ColumnTransformer(
transformers=[
("num", pipe_num, col_numeriche),
("ord", pipe_ord, col_ordinali),
("nom", pipe_nom, col_nominali),
("target", pipe_target, col_alta_cardinalita),
],
remainder="drop",
verbose_feature_names_out=True
)
# Pipeline finale: preprocessing + modello
pipeline_completa = Pipeline([
("preprocessing", preprocessor),
("classificatore", HistGradientBoostingClassifier(
max_iter=200,
learning_rate=0.1,
random_state=42
)),
])
# Cross-validation
scores = cross_val_score(
pipeline_completa, df_full, y_full,
cv=5, scoring="roc_auc"
)
print(f"ROC-AUC medio: {scores.mean():.4f} (+/- {scores.std():.4f})")
make_column_selector: Selezione Automatica delle Colonne
Se non vuoi elencare manualmente le colonne, make_column_selector seleziona automaticamente le colonne in base al dtype. È particolarmente comodo quando lavori con dataset ampi e variabili che cambiano spesso.
from sklearn.compose import make_column_selector
# Selezione automatica basata sul tipo di dato
preprocessor_auto = ColumnTransformer(
transformers=[
("num", pipe_num, make_column_selector(dtype_include="number")),
("cat", OneHotEncoder(
sparse_output=False,
handle_unknown="ignore"
), make_column_selector(dtype_include="object")),
]
)
# Verifica quali colonne vengono selezionate
print("Numeriche:", make_column_selector(dtype_include="number")(df_full))
print("Categoriche:", make_column_selector(dtype_include="object")(df_full))
Feature-engine: la Libreria Specializzata
Feature-engine è una libreria Python progettata specificamente per il feature engineering. A differenza degli strumenti generici di scikit-learn, offre trasformatori dedicati per ogni tipo di operazione, con un'API completamente compatibile con le pipeline di scikit-learn.
Detto in modo semplice: se fai feature engineering regolarmente, Feature-engine ti farà risparmiare un sacco di codice boilerplate.
Installazione e Trasformatori Principali
# Installazione
# pip install feature-engine
from feature_engine.encoding import (
RareLabelEncoder,
MeanEncoder,
WoEEncoder,
)
from feature_engine.creation import (
CyclicalFeatures,
MathFeatures,
RelativeFeatures,
)
from feature_engine.outliers import Winsorizer
from feature_engine.imputation import (
MeanMedianImputer,
CategoricalImputer,
)
# RareLabelEncoder: accorpa le categorie rare
rle = RareLabelEncoder(
tol=0.05, # categorie sotto il 5% diventano "Rare"
n_categories=5, # mantieni al massimo 5 categorie
replace_with="Altro"
)
df_rle = rle.fit_transform(df_full[["citta"]])
print("Dopo RareLabelEncoder:")
print(df_rle["citta"].value_counts())
Gestione degli Outlier con Winsorizer
Il Winsorizer di Feature-engine limita i valori estremi sostituendoli con un valore soglia calcolato tramite IQR, percentili o deviazione standard. A differenza della semplice rimozione degli outlier, preserva tutte le osservazioni — e questo in molti contesti è fondamentale.
# Winsorizer basato sull'IQR
winsorizer = Winsorizer(
capping_method="iqr",
tail="both",
fold=1.5,
variables=["reddito", "punteggio"]
)
df_wins = winsorizer.fit_transform(df_full[["reddito", "punteggio"]].dropna())
print(f"Reddito originale - max: {df_full['reddito'].max():.0f}")
print(f"Reddito dopo Winsorizer - max: {df_wins['reddito'].max():.0f}")
CyclicalFeatures: Feature Cicliche Automatiche
Ricordi le feature seno/coseno che abbiamo creato manualmente per le date? Ecco, CyclicalFeatures di Feature-engine le genera in automatico con una sola riga.
# Creazione automatica di feature cicliche
df_cycl = pd.DataFrame({
"ora": np.random.randint(0, 24, 500),
"mese": np.random.randint(1, 13, 500),
"giorno_settimana": np.random.randint(0, 7, 500),
})
cycl = CyclicalFeatures(
variables=["ora", "mese", "giorno_settimana"],
max_values={"ora": 24, "mese": 12, "giorno_settimana": 7},
drop_original=True
)
df_cycl_trans = cycl.fit_transform(df_cycl)
print(df_cycl_trans.columns.tolist())
# ['ora_sin', 'ora_cos', 'mese_sin', 'mese_cos',
# 'giorno_settimana_sin', 'giorno_settimana_cos']
MathFeatures: Combinazioni Matematiche tra Feature
# Creazione di feature matematiche tra variabili
math_feat = MathFeatures(
variables=["eta", "punteggio"],
func=["sum", "mean", "std", "min", "max"],
missing_values="ignore"
)
df_math = math_feat.fit_transform(
df_full[["eta", "punteggio"]].dropna()
)
print("Nuove colonne:", [c for c in df_math.columns
if c not in ["eta", "punteggio"]])
Feature Selection: Scegliere le Feature Migliori
Creare molte feature è solo metà del lavoro. L'altra metà — e spesso quella che viene trascurata — è selezionare quelle effettivamente utili. Troppe feature possono causare overfitting, aumentare i tempi di addestramento e rendere il modello meno interpretabile.
Metodi Basati sulla Varianza e sulla Correlazione
from sklearn.feature_selection import VarianceThreshold
# Rimuoviamo le feature con varianza quasi zero
vt = VarianceThreshold(threshold=0.01)
# (utile dopo one-hot encoding per eliminare categorie quasi costanti)
# Rimozione di feature altamente correlate (con pandas)
def rimuovi_correlate(df, soglia=0.95):
"""Rimuove una delle due feature se la correlazione supera la soglia."""
corr_matrix = df.corr().abs()
upper = corr_matrix.where(
np.triu(np.ones(corr_matrix.shape), k=1).astype(bool)
)
da_rimuovere = [
col for col in upper.columns
if any(upper[col] > soglia)
]
return df.drop(columns=da_rimuovere), da_rimuovere
# Esempio
df_test = pd.DataFrame(np.random.randn(100, 5), columns=list("ABCDE"))
df_test["F"] = df_test["A"] * 1.01 + np.random.randn(100) * 0.001
df_ridotto, rimosse = rimuovi_correlate(df_test)
print(f"Colonne rimosse: {rimosse}")
SelectKBest e Mutual Information
from sklearn.feature_selection import SelectKBest, mutual_info_classif
# Selezione delle K migliori feature con mutual information
selector = SelectKBest(
score_func=mutual_info_classif,
k=5
)
# Prepariamo i dati (solo numerici per semplicità)
X_sel = df_full[col_numeriche].dropna()
y_sel = y_full[X_sel.index]
X_selected = selector.fit_transform(X_sel, y_sel)
scores = pd.Series(
selector.scores_,
index=col_numeriche
).sort_values(ascending=False)
print("Score Mutual Information:")
print(scores)
Selezione Basata sul Modello con SelectFromModel
from sklearn.feature_selection import SelectFromModel
from sklearn.ensemble import RandomForestClassifier
# Usiamo l'importanza delle feature di un Random Forest
rf = RandomForestClassifier(n_estimators=100, random_state=42)
sfm = SelectFromModel(rf, threshold="median")
# In una pipeline completa
pipeline_con_selezione = Pipeline([
("preprocessing", preprocessor),
("selezione", SelectFromModel(
RandomForestClassifier(n_estimators=100, random_state=42),
threshold="median"
)),
("classificatore", HistGradientBoostingClassifier(random_state=42)),
])
Progetto Pratico: Pipeline Completa End-to-End
Ok, adesso mettiamo davvero tutto insieme. Costruiremo una pipeline di feature engineering completa su un dataset e-commerce simulato — con preprocessing, feature creation, feature selection e modello, il tutto valutato con cross-validation rigorosa.
Questo è il tipo di codice che puoi adattare direttamente ai tuoi progetti.
import sklearn
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import (
StandardScaler, OneHotEncoder,
OrdinalEncoder, TargetEncoder, PowerTransformer
)
from sklearn.feature_selection import SelectFromModel
from sklearn.ensemble import (
HistGradientBoostingClassifier,
RandomForestClassifier
)
from sklearn.model_selection import (
cross_val_score, StratifiedKFold,
RandomizedSearchCV
)
from sklearn.metrics import classification_report
from scipy.stats import uniform, randint
sklearn.set_config(transform_output="pandas")
# === 1. Simuliamo un dataset di e-commerce ===
np.random.seed(42)
n_samples = 5000
df_ecom = pd.DataFrame({
# Numeriche
"importo_carrello": np.random.exponential(80, n_samples),
"num_prodotti": np.random.poisson(3, n_samples),
"tempo_sessione_min": np.random.gamma(2, 5, n_samples),
"pagine_visitate": np.random.poisson(8, n_samples),
"distanza_magazzino_km": np.random.uniform(1, 500, n_samples),
# Categoriche
"metodo_pagamento": np.random.choice(
["carta", "paypal", "bonifico", "contrassegno"], n_samples,
p=[0.45, 0.30, 0.15, 0.10]
),
"categoria_prodotto": np.random.choice(
["elettronica", "abbigliamento", "casa", "sport",
"libri", "alimentari", "giocattoli", "beauty"], n_samples
),
"citta_spedizione": np.random.choice(
[f"citta_{i}" for i in range(80)], n_samples # alta cardinalità
),
# Datetime
"data_ordine": pd.date_range("2024-01-01", periods=n_samples, freq="2h"),
})
# Target: acquisto completato (1) o carrello abbandonato (0)
logit = (
0.02 * df_ecom["importo_carrello"]
- 0.1 * df_ecom["distanza_magazzino_km"]
+ 0.5 * df_ecom["num_prodotti"]
+ np.random.randn(n_samples) * 5
)
y_ecom = (logit > np.median(logit)).astype(int)
# Introduciamo valori mancanti realistici
for col in ["importo_carrello", "tempo_sessione_min"]:
df_ecom.loc[df_ecom.sample(frac=0.03).index, col] = np.nan
df_ecom.loc[df_ecom.sample(frac=0.02).index, "metodo_pagamento"] = np.nan
# === 2. Feature Engineering manuale dalle date ===
dt = df_ecom["data_ordine"].dt
df_ecom["ora"] = dt.hour
df_ecom["giorno_settimana"] = dt.dayofweek
df_ecom["mese"] = dt.month
df_ecom["is_weekend"] = (dt.dayofweek >= 5).astype(int)
df_ecom["is_orario_lavorativo"] = dt.hour.between(9, 18).astype(int)
# Feature derivate
df_ecom["importo_per_prodotto"] = (
df_ecom["importo_carrello"] / df_ecom["num_prodotti"].clip(lower=1)
)
df_ecom["velocita_navigazione"] = (
df_ecom["pagine_visitate"] /
df_ecom["tempo_sessione_min"].clip(lower=0.1)
)
# === 3. Definizione colonne ===
col_num = [
"importo_carrello", "num_prodotti", "tempo_sessione_min",
"pagine_visitate", "distanza_magazzino_km",
"importo_per_prodotto", "velocita_navigazione",
"ora", "giorno_settimana", "mese"
]
col_bin = ["is_weekend", "is_orario_lavorativo"]
col_nom = ["metodo_pagamento", "categoria_prodotto"]
col_high_card = ["citta_spedizione"]
# === 4. Pipeline di preprocessing ===
pipe_num = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("power", PowerTransformer(method="yeo-johnson")),
])
pipe_nom = Pipeline([
("imputer", SimpleImputer(strategy="constant",
fill_value="mancante")),
("encoder", OneHotEncoder(
drop="first", sparse_output=False,
handle_unknown="ignore", min_frequency=0.02
)),
])
pipe_hc = Pipeline([
("imputer", SimpleImputer(strategy="constant",
fill_value="mancante")),
("encoder", TargetEncoder(smooth="auto", cv=5)),
])
preprocessor = ColumnTransformer([
("num", pipe_num, col_num),
("bin", "passthrough", col_bin),
("nom", pipe_nom, col_nom),
("hc", pipe_hc, col_high_card),
], verbose_feature_names_out=True)
# === 5. Pipeline completa con selezione e modello ===
pipeline_finale = Pipeline([
("prep", preprocessor),
("selezione", SelectFromModel(
RandomForestClassifier(n_estimators=100, random_state=42),
threshold="median"
)),
("modello", HistGradientBoostingClassifier(
max_iter=300,
learning_rate=0.05,
max_depth=6,
random_state=42
)),
])
# === 6. Cross-validation ===
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(
pipeline_finale,
df_ecom.drop(columns=["data_ordine"]),
y_ecom,
cv=cv,
scoring="roc_auc"
)
print(f"ROC-AUC: {scores.mean():.4f} (+/- {scores.std():.4f})")
# === 7. Ottimizzazione iperparametri ===
param_dist = {
"modello__max_iter": randint(100, 500),
"modello__learning_rate": uniform(0.01, 0.2),
"modello__max_depth": randint(3, 10),
"modello__min_samples_leaf": randint(10, 50),
}
search = RandomizedSearchCV(
pipeline_finale,
param_distributions=param_dist,
n_iter=20,
cv=cv,
scoring="roc_auc",
random_state=42,
n_jobs=-1
)
search.fit(
df_ecom.drop(columns=["data_ordine"]),
y_ecom
)
print(f"\nMiglior ROC-AUC: {search.best_score_:.4f}")
print(f"Migliori parametri: {search.best_params_}")
Best Practice e Errori da Evitare
Dopo aver visto tutte le tecniche, ecco le regole d'oro da seguire e le trappole più comuni da evitare. Sono il frutto di tanti errori fatti (e visti fare) nei progetti reali.
Le Regole d'Oro
- Fai sempre il fit solo sul training set. Le pipeline di scikit-learn lo garantiscono se usate con
cross_val_scoreoGridSearchCV. Non fare maifit_transformsull'intero dataset prima dello split. - Usa
TargetEncoderconfit_transformper i dati di training: il cross-fitting interno previene il leakage. - Tratta i valori mancanti prima dell'encoding. La maggior parte degli encoder non gestisce i
NaN. - Monitora la dimensionalità. Dopo one-hot encoding, il numero di feature può esplodere. Usa
min_frequencyeRareLabelEncoderper contenere la crescita. - Documenta le trasformazioni. Ogni step di feature engineering deve essere tracciabile e riproducibile. Le pipeline lo fanno automaticamente.
Errori Comuni
- Data leakage: calcolare la media della colonna target sull'intero dataset e usarla come feature. Sempre fare il fit solo sul training fold.
- Label encoding per nominali: applicare
LabelEncodera variabili come "colore" o "città" introduce un ordine artificiale che non esiste. È un errore più comune di quanto si pensi. - Ignorare le feature cicliche: usare il mese come numero (1-12) senza trasformazione seno/coseno impedisce al modello di capire che dicembre è vicino a gennaio.
- Troppe feature polinomiali: con
degree=3e 10 variabili si generano centinaia di colonne. Inizia condegree=2einteraction_only=True.
FAQ
Qual è la differenza tra feature engineering e feature selection?
Il feature engineering è il processo di creazione e trasformazione delle feature a partire dai dati grezzi: codifica, scaling, estrazione di componenti temporali, creazione di interazioni. La feature selection è il passo successivo, in cui si selezionano solo le feature più informative tra quelle create, eliminando quelle ridondanti o irrilevanti. Nella pratica, i due processi sono complementari e si usano quasi sempre insieme all'interno di una pipeline.
Come posso evitare il data leakage durante il feature engineering?
La regola fondamentale è semplice: non usare mai informazioni dal test set durante il fit delle trasformazioni. In pratica, usa sempre le Pipeline di scikit-learn insieme a cross_val_score o GridSearchCV. Queste funzioni garantiscono che il fit avvenga solo sul training fold di ogni iterazione. Particolare attenzione va prestata al TargetEncoder: usa fit_transform (che applica il cross-fitting interno) e non fit().transform() sul training set.
Quando conviene usare TargetEncoder invece di OneHotEncoder?
Il TargetEncoder è preferibile quando la variabile categorica ha alta cardinalità — decine o centinaia di categorie uniche — dove il one-hot encoding creerebbe un numero enorme di colonne sparse. È anche vantaggioso con i modelli ad albero come HistGradientBoostingClassifier. Per variabili con poche categorie (meno di 10-15), il OneHotEncoder con drop="first" resta la scelta più sicura e interpretabile.
Feature-engine è compatibile con le pipeline di scikit-learn?
Sì, al 100%. Feature-engine è stata progettata fin dall'inizio per essere completamente compatibile con l'API di scikit-learn. Tutti i suoi trasformatori implementano fit, transform e fit_transform, e possono essere inseriti direttamente in una Pipeline o in un ColumnTransformer. È un'estensione naturale dell'ecosistema scikit-learn.
Quante feature devo creare prima della selezione?
Non esiste un numero magico, ma la regola pratica è: crea tutte le feature che hanno una motivazione logica basata sulla conoscenza del dominio, poi lascia che la fase di selezione faccia il suo lavoro. Un errore comune è creare centinaia di feature alla cieca sperando che qualcosa funzioni — questo aumenta il rumore senza necessariamente migliorare le prestazioni. Parti dalle feature più semplici e aggiungi complessità solo se la cross-validation mostra miglioramenti concreti.