C’è una verità silenziosa nel machine learning tabulare che la maggior parte dei corsi salta: il modello raramente conta quanto le feature. Puoi prendere lo stesso dataset, lanciarci sopra gli stessi gradient-boosted tree due volte, e una versione batte l’altra di dieci punti percentuali puramente perché qualcuno ha passato un weekend a pensare a cosa mettere sul lato dell’input. Quel weekend è feature engineering, e questa è la lezione che, più di qualsiasi altra in questo modulo, sposterà i tuoi numeri.
Vedremo le categorie di feature engineering, poi parleremo di leakage, il bug che silenziosamente gonfia le tue metriche e rovina la produzione. La maggior parte del codice vero e proprio vive nelle pipeline che hai già visto nella lezione 49; la parte difficile è il pensiero.
Cosa significa davvero “engineering” qui
Il feature engineering è l’atto di trasformare colonne grezze in rappresentazioni da cui un modello può imparare. Alcune trasformazioni sono meccaniche (centratura e scaling). Alcune richiedono conoscenza del dominio (sapere che una transazione alle 3 di notte è più sospetta di una alle 3 del pomeriggio). Alcune sono semplicemente furbe (codificare l’ora del giorno come sin(2*pi*hour/24) e cos(2*pi*hour/24) così che il modello veda che le 23:00 sono vicine alle 00:00).
Le categorie che attraverserò:
- Scaling
- Encoding di variabili categoriche
- Interazioni
- Feature temporali
- Aggregazioni
- Feature testuali
- Gestione dei valori mancanti
E poi la sezione sul leakage, che è la parte più importante di questa lezione e quella che la maggior parte dei tutorial frettolosi salta.
Scaling
La maggior parte dei modelli non-tree si preoccupa della scala dei tuoi input. Modelli lineari, reti neurali, k-nearest neighbors e SVM fanno tutti peggio, a volte in modo catastrofico, quando una feature va da 0 a 1 e un’altra da 0 a 1.000.000. La soluzione è standardizzare ogni colonna numerica a media 0 e deviazione standard 1:
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
I modelli ad albero, random forest, XGBoost, LightGBM, non se ne curano. Splittano su soglie, e una soglia su un valore moltiplicato per 1000 è la stessa cosa di una soglia sul valore stesso. Non perdere tempo a fare scaling per gli alberi.
Due varianti che vale la pena conoscere. MinMaxScaler riscala in [0, 1], che è quello che vuoi per gli input di reti neurali con funzioni di attivazione limitate. RobustScaler usa la mediana e l’intervallo interquartile, che è quello che vuoi quando i tuoi dati hanno outlier: un singolo grande valore non tirerà la media e gonfierà la deviazione standard.
Per feature numeriche con coda molto lunga (reddito, importi di transazioni, page view), applica prima una trasformazione logaritmica: np.log1p(x), cioè log(1+x), che gestisce gli zeri elegantemente. Dopo il log, la distribuzione è più vicina alla simmetria e lo scaling fa quello che dovrebbe fare.
Encoding di variabili categoriche
Le categorie devono diventare numeri. L’encoding giusto dipende dalla cardinalità e dal fatto che ci sia o meno un ordine.
Bassa cardinalità, niente ordine: one-hot encoding. Ogni categoria diventa la propria colonna 0/1.
from sklearn.preprocessing import OneHotEncoder
encoder = OneHotEncoder(handle_unknown="ignore", sparse_output=False)
X_encoded = encoder.fit_transform(X[["country", "plan"]])
handle_unknown="ignore" è quello che ti salva in produzione quando spunta una nuova categoria che non era nei dati di training. Senza, il tuo servizio crasha nel momento in cui il marketing lancia in un nuovo paese.
Cardinalità alta: niente one-hot. Se hai 50.000 ID utente unici o 10.000 SKU di prodotto, l’one-hot ti dà una matrice sparsa da 50.000 colonne che la maggior parte dei modelli non riesce a digerire. Le due opzioni reali:
- Target encoding: sostituisci ogni categoria con la media della variabile target per quella categoria. Quindi se hai una colonna
countrye il tasso medio di conversione per gli utenti in Italia è il 4,2%, alloracountry=ITdiventa0.042. Potente, ma una trappola di leakage (di più sotto). Usa la libreriacategory_encoderso ilTargetEncoderdi sklearn (aggiunto nella 1.3, con un cross-fitting fatto bene per evitare il leakage). - Hashing: passi la categoria attraverso una funzione hash modulo
n, finendo conncolonne. Veloce, lossy, niente leakage. Buono per cardinalità molto grandi dove non ti importa dell’identità esatta della categoria.
Categorie ordinate: l’ordinal encoding va bene. ["small", "medium", "large"] a [0, 1, 2] preserva l’ordine. Ma non farlo mai su categorie senza ordine: stai mentendo al modello sulla distanza.
Interazioni
A volte due feature significano qualcosa solo insieme. Il reddito da solo non ti dice molto; il reddito per membro del nucleo familiare sì. L’ora del giorno da sola ti dice qualcosa; l’ora del giorno combinata con il giorno della settimana ti dice molto di più.
Per i modelli ad albero, le interazioni vengono gratis: un albero che splitta su income, poi su household_size dentro un ramo, ha scoperto un’interazione. Per i modelli lineari, devi esplicitarle:
import pandas as pd
df["income_per_member"] = df["income"] / df["household_size"]
df["weekend_x_hour"] = df["is_weekend"] * df["hour"]
Oppure usa PolynomialFeatures per generare automaticamente tutti i prodotti a coppie: utile per piccoli set di feature, pericoloso per quelli grandi perché il numero di interazioni esplode quadraticamente.
Feature temporali
I timestamp vanno scompattati. Una colonna datetime grezza porta quasi nessun segnale che un modello possa usare direttamente; estrarne i componenti sì:
df["hour"] = df["timestamp"].dt.hour
df["day_of_week"] = df["timestamp"].dt.dayofweek
df["month"] = df["timestamp"].dt.month
df["is_weekend"] = df["day_of_week"].isin([5, 6]).astype(int)
df["is_business_hour"] = df["hour"].between(9, 17).astype(int)
Per le feature cicliche, ora, giorno della settimana, mese, c’è un problema sottile: se codifichi l’ora come 0-23, il modello vede 23 e 0 come distanti 23 unità, quando in realtà sono distanti 1 ora. L’encoding ciclico via sin/cos lo risolve:
import numpy as np
df["hour_sin"] = np.sin(2 * np.pi * df["hour"] / 24)
df["hour_cos"] = np.cos(2 * np.pi * df["hour"] / 24)
Ora il modello vede correttamente l’avvolgimento. Lo stesso trucco funziona per il giorno della settimana (periodo 7), il mese (periodo 12), il giorno dell’anno (periodo 365,25).
Non includere il timestamp grezzo stesso a meno che tu non stia provando a catturare un trend di lungo termine, e anche allora, usa (timestamp - reference_date).days così che il valore sia limitato.
Aggregazioni
Qui è dove si nasconde il vero guadagno sui problemi a livello di utente o di transazione. Per ogni utente, calcoli feature come:
- Importo totale speso negli ultimi 7 giorni
- Numero di transazioni negli ultimi 30 giorni
- Importo medio di transazione negli ultimi 90 giorni
- Giorni dall’ultima transazione
In pandas, è un groupby più un’aggregazione con finestra. Il dettaglio, e ora stiamo scivolando nella sezione sul leakage, è che “ultimi 7 giorni” deve significare ultimi 7 giorni relativi al momento della predizione. Se la tua predizione è per la transazione T, allora le aggregazioni devono usare solo dati con timestamp strettamente precedente a T. Includere T stesso nell’aggregazione è un leak: il modello vede la risposta.
# Sbagliato (leak): aggregazione su tutti i dati
df["user_total_spend"] = df.groupby("user_id")["amount"].transform("sum")
# Giusto: finestra mobile strettamente prima di ogni riga
df = df.sort_values(["user_id", "timestamp"])
df["spend_past_7d"] = (
df.groupby("user_id")
.rolling("7D", on="timestamp", closed="left")["amount"]
.sum()
.reset_index(level=0, drop=True)
)
closed="left" esclude la riga corrente. Senza, hai leakato.
Feature testuali
Le colonne di testo libero, descrizioni, query di ricerca, ticket di supporto, non vanno dentro grezze. Le due strade classiche:
Bag of words / TF-IDF: conta i termini, pesa per inverse document frequency. Economico, interpretabile, sorprendentemente competitivo su documenti brevi.
from sklearn.feature_extraction.text import TfidfVectorizer
vec = TfidfVectorizer(max_features=5000, ngram_range=(1, 2))
X_text = vec.fit_transform(df["description"])
Embedding: passi il testo attraverso un modello sentence transformer e usi il vettore risultante come feature. Segnale molto migliore, soprattutto quando il significato conta più delle parole esatte. Nel 2026, lo standard è sentence-transformers di Hugging Face; il piccolo modello all-MiniLM-L6-v2 è veloce su CPU e abbastanza buono per la maggior parte dei problemi tabulari più testo.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
embeddings = model.encode(df["description"].tolist()) # array (n, 384)
Concatena le colonne dell’embedding alle altre tue feature e trattale come qualsiasi altro input numerico.
Valori mancanti
I dati reali hanno buchi. La maggior parte dei modelli non li gestisce nativamente (XGBoost e LightGBM sono eccezioni: splittano direttamente sulla mancanza). Tre strategie:
- Imputare: sostituisci i mancanti con la media, la mediana o la moda. La mediana è di solito il default più sicuro per le feature numeriche.
- Valore sentinella: sostituisci con un valore che il modello non possa confondere con dati reali.
-999per una feature a valori positivi,"_missing_"per una categoria. - Colonna indicatore: aggiungi una colonna 0/1 separata che dice se il valore era mancante. Spesso combinata con l’imputazione, perché il fatto stesso della mancanza può essere predittivo.
from sklearn.impute import SimpleImputer
df["age_missing"] = df["age"].isna().astype(int) # l'indicatore
imputer = SimpleImputer(strategy="median")
df[["age"]] = imputer.fit_transform(df[["age"]])
Per i modelli ad albero con supporto nativo per i valori mancanti (XGBoost, LightGBM), basta lasciare i NaN dentro: la libreria li gestirà e spesso fa meglio di quanto farebbe la tua imputazione.
Leakage: il bug che ti mente
Ora la parte che richiede più tempo per essere imparata di qualsiasi altra. Il leakage avviene quando informazioni che non sarebbero disponibili al momento della predizione si infilano nelle tue feature di training. Il modello le coglie, ottiene punteggi grandiosi in cross-validation, e crolla in produzione perché il segnale leakato non c’è all’inferenza.
Due sapori principali:
Target leakage: una feature include informazioni sul target. L’esempio classico: prevedere se un cliente farà churn, con una feature come cancellation_processed_date. Quel campo viene popolato solo dopo che il churn è avvenuto. Includerlo dà al modello un predittore quasi perfetto che non esiste quando vuoi davvero predire.
La soluzione: pensa con attenzione a quando ogni feature diventa disponibile. Se stai predicendo al tempo T, ogni feature deve essere derivabile da dati prima di T. Se una colonna potrebbe essere popolata dopo l’evento target, droppala o ricalcolala come al tempo T.
Train-test leakage: il preprocessing usa statistiche dal test set. Se fitti uno StandardScaler sul dataset completo prima dello split, lo scaler ha visto la media dei dati di test. Il punteggio di cross-validation viene gonfiato di una piccola quantità che, su problemi tirati al massimo, è la differenza tra lanciare e non lanciare.
La soluzione è esattamente quello per cui le pipeline di scikit-learn sono state progettate: avvolgi il preprocessing in una Pipeline, passala a cross_val_score, e la libreria rifitta automaticamente tutto dentro ogni fold.
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
pipe = Pipeline([
("scale", StandardScaler()),
("clf", LogisticRegression(max_iter=1000)),
])
# Dentro ogni fold, lo scaler rifitta solo sulla porzione di training di quel fold.
scores = cross_val_score(pipe, X, y, cv=5)
Se calcoli aggregazioni o target encoding fuori dalla pipeline, sei tornato alla prevenzione manuale del leakage: prima fai lo split, poi calcola le feature usando solo i dati di training, poi trasforma i dati di test con il transformer fittato sul training.
Un check di pancia utile: se il tuo modello ottiene punteggi sospettosamente buoni al primo tentativo, diciamo il 99% di accuracy su un problema che il tuo esperto di dominio dice essere difficile, supponi che ci sia leakage finché non viene dimostrato il contrario. I modelli reali su problemi reali sono imperfetti. Un modello perfetto di solito significa un modello che sbircia.
Una nota sull’assistenza AI
Quando chiedi a un assistente AI di scrivere codice di feature engineering per un dataset, è bravo sui pattern standard: dagli un campione dei tuoi dati e ti suggerirà scaler, encoder e split di feature temporali ragionevoli. Su cosa è inaffidabile è la semantica temporale delle aggregazioni. Suggerirà allegramente feature come “media di tutte le transazioni passate” senza una finestra o un closed="left", e il codice risultante farà trapelare il futuro nel presente.
Leggi sempre il codice di feature suggerito dall’AI con una domanda in testa: “al momento in cui facciamo una predizione, ogni colonna di input contiene solo informazioni che erano disponibili prima di quel momento?” Se la risposta è no per qualsiasi colonna, sistemala prima di fidarti delle metriche.
Chiusura
Il feature engineering è la parte lenta e poco glamour del ML tabulare, ed è anche la parte dove il lavoro paga. Le trasformazioni in questa lezione coprono la maggior parte dei casi che incontrerai in dataset reali; la disciplina sul leakage è quello che mantiene onesti i tuoi numeri. Prossima lezione, passiamo tutto questo attraverso modelli ad albero, la famiglia che, nel 2026, vince ancora sui problemi tabulari.