Siamo arrivati al modulo 9, che parla di machine learning. Se hai letto ogni lezione fino a questo punto, hai le competenze da data engineer: sai come maneggiare un DataFrame, costruire un ETL e visualizzarne il risultato. Il ML è quello che alcune persone fanno con quei dati dopo. Non è un sostituto di pipeline pulite e di SQL fatto bene; è uno strumento che, ogni tanto, ti permette di prevedere qualcosa di utile a partire dai pattern presenti nei tuoi dati.
Le prime tre lezioni di questo modulo sono l’apertura: la libreria standard (questa qui), la parte che davvero sposta l’ago della bilancia (lezione 50, feature engineering), e la famiglia di modelli che vince sui dati tabulari (lezione 51, gli alberi). Tutto il resto, reti neurali, NLP, sistemi di raccomandazione, arriva dopo o costituisce un corso a sé. Si parte da scikit-learn perché è da lì che inizia quasi ogni carriera Python di ML, ed è dove la maggior parte di queste carriere resta per il lavoro su dati tabulari.
Cos’è scikit-learn, nel 2026
scikit-learn esiste dal 2007. È sulla linea 1.x, il che significa che l’API è stabile e l’azienda per cui lavori probabilmente la tiene fissata da qualche parte in un requirements.txt. Non è la più veloce, non è la più profonda, e non fa GPU. È però l’API più consistente di tutta la data science, ed è la libreria che ha insegnato a una generazione di professionisti come dovrebbe essere fatto un estimator.
L’installazione è quella solita:
uv add scikit-learn
E l’import è sklearn, non scikit-learn. Sì, è fastidioso. È così da quasi vent’anni e nessuno ha intenzione di cambiarlo.
import sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
Il pattern fit/predict
Ecco l’intero modello mentale di scikit-learn. Ogni modello, e ce ne sono dozzine, espone gli stessi due metodi:
model.fit(X, y) # impara dai dati
model.predict(X_new) # predice su nuovi dati
Tutto qui. Una linear regression, una random forest, un support vector machine, un classificatore k-nearest neighbors: tutti hanno questo aspetto. Una volta che hai usato un estimator, li hai usati tutti. Passare dalla logistic regression al gradient boosting è una modifica di una riga.
I classifier di solito espongono anche .predict_proba(X_new), che ti dà le probabilità di classe invece delle etichette di classe rigide, ed è ciò che vuoi davvero la maggior parte delle volte:
from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(X_train, y_train)
predictions = model.predict(X_test) # array di 0/1
probabilities = model.predict_proba(X_test) # array di coppie (P(0), P(1))
La convenzione sulla forma è altrettanto consistente: X è bidimensionale con shape (n_samples, n_features), un array NumPy o un DataFrame pandas, vanno bene entrambi. y è unidimensionale con lunghezza n_samples. Se hai una sola feature, devi comunque fare il reshape a colonna: X.reshape(-1, 1). Dimenticarsene ti regala il famoso errore “Reshape your data either using array.reshape(-1, 1) if your data has a single feature”, che ogni principiante vede almeno una volta.
I transformer, le cose che cambiano X invece di predire da esso, seguono un pattern leggermente diverso:
scaler.fit(X_train) # impara medie e deviazioni standard
X_train_scaled = scaler.transform(X_train)
X_test_scaled = scaler.transform(X_test)
# Oppure, equivalentemente:
X_train_scaled = scaler.fit_transform(X_train)
Il dettaglio cruciale: chiami .fit solo sui dati di training, poi .transform su tutto. Fare il fit sui dati di test è un leak. Ci torneremo sopra nella lezione 50.
Le famiglie di modelli
Lascia che ti dia la mappa. La tassonomia non è rigida, ma sapere quale libreria vive in quale sottomodulo accelera enormemente la caccia nella documentazione.
Modelli lineari (sklearn.linear_model). I classici: LinearRegression per la regressione, LogisticRegression per la classificazione (nonostante il nome). Ridge e Lasso aggiungono rispettivamente la regolarizzazione L2 e L1, che è quello che vuoi quando hai molte feature o feature correlate. I modelli lineari sono veloci, interpretabili e sorprendentemente competitivi su molti dataset reali, soprattutto dopo un buon feature engineering.
from sklearn.linear_model import Ridge, Lasso, LogisticRegression
ridge = Ridge(alpha=1.0) # alpha controlla la forza della regolarizzazione
lasso = Lasso(alpha=0.1) # Lasso fa anche feature selection (porta a zero alcuni pesi)
logreg = LogisticRegression(C=1.0, max_iter=1000) # C è regolarizzazione inversa, attenzione
Modelli ad albero (sklearn.tree, sklearn.ensemble). DecisionTreeClassifier è il blocco fondamentale. RandomForestClassifier e GradientBoostingClassifier sono i cavalli da tiro. In pratica, per un gradient boosting serio, si va su XGBoost o LightGBM invece di quello integrato in scikit-learn: sono più veloci e più accurati. Lo faremo nella lezione 51. Ma la random forest di scikit-learn è davvero buona, e l’API è identica, quindi è un default ragionevole quando vuoi una baseline solida in tre righe.
Instance-based (sklearn.neighbors). KNeighborsClassifier predice in base ai k punti di training più vicini. Semplice, a volte sorprendentemente efficace, lento al momento della predizione su dataset grandi.
Metodi a kernel (sklearn.svm). SVC e SVR per classificazione e regressione. Bella teoria, un tempo dominanti, ora soppiantati per lo più dagli alberi e dalle reti neurali rispettivamente sui dati tabulari e sulle immagini. Ancora utili per dataset piccoli e medi e quando vuoi un confine di decisione liscio.
Reti neurali (sklearn.neural_network). MLPClassifier esiste ma è limitato: niente GPU, nessuna vera flessibilità architetturale. Se ti serve davvero una rete neurale, usa PyTorch. L’MLP in scikit-learn va bene per un primo abbozzo.
Clustering, riduzione della dimensionalità e il resto vivono in sklearn.cluster (KMeans, DBSCAN), sklearn.decomposition (PCA, NMF) e sklearn.manifold (t-SNE, cose vicine a UMAP). Seguono lo stesso pattern .fit / .transform / .fit_predict.
Preprocessing
La maggior parte dei modelli si aspetta numeri in un range sensato. Il modulo di preprocessing di scikit-learn ha gli strumenti standard:
from sklearn.preprocessing import (
StandardScaler, # media 0, std 1 - il default per la maggior parte delle cose
MinMaxScaler, # riscala in [0, 1] - per input limitati
RobustScaler, # usa mediana e IQR - per dati con outlier
OneHotEncoder, # trasforma categorie in colonne 0/1
OrdinalEncoder, # trasforma categorie in codici interi (usalo solo se c'è un ordine)
KBinsDiscretizer, # divide valori continui in intervalli
)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
La cosa da sapere: i modelli ad albero non hanno bisogno di scaling. Splittano su soglie, e una soglia su feature * 1 è uguale a una soglia su feature * 1000. Modelli lineari, reti neurali, KNN e SVM hanno tutti bisogno di scaling: senza, la feature con il range numerico più grande domina tutto il resto.
Per le feature categoriche, la regola pratica è: bassa cardinalità (sotto le ~20 categorie) prende OneHotEncoder; cardinalità alta richiede target encoding o trucchi di hashing, che vedremo nella lezione 50. Non usare mai OrdinalEncoder su una feature che non ha un ordine naturale: ["red", "green", "blue"] che diventa [0, 1, 2] mentirà al tuo modello sulle distanze tra le categorie.
Train/test split
Non si valuta un modello sui dati da cui ha imparato. Farlo ti dà l’errore di training, che sembra sempre fantastico e non ti dice nulla sul futuro. Lo split standard:
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.2,
random_state=42, # impostalo per riproducibilità
stratify=y, # preserva le proporzioni di classe in entrambi gli split - per la classificazione
)
Quella cosa di random_state=42 è un meme ma è anche la mossa giusta: vuoi lo stesso split ogni volta che esegui lo script, altrimenti il debugging diventa impossibile. L’argomento stratify=y conta più di quanto la gente pensi: senza, su dataset sbilanciati puoi finire con un test set che ha, diciamo, il 2% della classe minoritaria mentre il tuo training set ne ha il 5%, e la tua valutazione sarà tarata male.
Per i dati di tipo time-series, non usarlo. Usa TimeSeriesSplit o semplicemente affetta per data: gli split casuali fanno trapelare il futuro nel passato.
Cross-validation
Un singolo train/test split è rumoroso. La cross-validation lo fa k volte, mediando il risultato:
from sklearn.model_selection import cross_val_score
model = LogisticRegression()
scores = cross_val_score(model, X, y, cv=5, scoring="accuracy")
print(scores) # array di 5 punteggi
print(scores.mean(), "+/-", scores.std())
cv=5 è lo standard. Per la classificazione fa stratified k-fold di default. Per maggior controllo, usa direttamente StratifiedKFold e passalo come cv.
Per il tuning finale degli iperparametri, GridSearchCV e RandomizedSearchCV avvolgono la cross-validation attorno a una griglia o a un campione casuale di combinazioni di parametri:
from sklearn.model_selection import GridSearchCV
grid = GridSearchCV(
LogisticRegression(max_iter=1000),
param_grid={"C": [0.01, 0.1, 1.0, 10.0]},
cv=5,
scoring="roc_auc",
)
grid.fit(X_train, y_train)
print(grid.best_params_, grid.best_score_)
Nel 2026, per un tuning serio degli iperparametri, la maggior parte dei team usa Optuna al posto di questo, che fa ricerca bayesiana e gestisce meglio la logica di early stopping. Ma GridSearchCV va bene per problemi piccoli.
Pipeline: il pattern di produzione
Ecco la parte che rende scikit-learn meritevole di essere imparato anche se alla fine passerai ad altre librerie. Una Pipeline concatena i passi di preprocessing con un modello in un singolo oggetto che supporta .fit e .predict:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
pipe = Pipeline([
("scale", StandardScaler()),
("clf", LogisticRegression(max_iter=1000)),
])
pipe.fit(X_train, y_train)
pipe.predict(X_test)
Perché questo conta: la pipeline garantisce che lo scaler sia fittato solo sui dati di training, poi applicato ai dati di test. Non puoi accidentalmente leakare. Significa anche che l’intera cosa si serializza come un singolo oggetto: pickle.dump(pipe, ...) e hai un artefatto deployabile.
Per un preprocessing diverso su colonne diverse, ColumnTransformer:
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
numeric_features = ["age", "income", "tenure"]
categorical_features = ["country", "plan"]
preprocessor = ColumnTransformer([
("num", StandardScaler(), numeric_features),
("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features),
])
pipe = Pipeline([
("prep", preprocessor),
("clf", LogisticRegression(max_iter=1000)),
])
pipe.fit(X_train, y_train)
handle_unknown="ignore" è importante: permette alla tua pipeline di sopravvivere a nuove categorie che compaiono nei dati di produzione. Senza, un valore mai visto fa crashare il predict.
La combinazione pipeline-più-cross-validation è il pattern di produzione. Avvolgi il tuo preprocessing e il tuo modello in una Pipeline, passa la pipeline a cross_val_score o GridSearchCV, e hai una valutazione onesta che non mentirà su cosa può fare il tuo modello su dati mai visti.
scores = cross_val_score(pipe, X, y, cv=5, scoring="roc_auc")
print(scores.mean())
Scegliere la metrica giusta
Una nota prima di chiudere: cross_val_score di default usa accuracy per i classifier, e l’accuracy non è quasi mai quello che vuoi sui dati reali. Se il 5% dei tuoi utenti fa churn e il 95% no, il modello che predice sempre “non farà churn” raggiunge il 95% di accuracy ed è inutile. Scegli la metrica che corrisponde al tuo problema:
roc_auc: quando ti interessa il ranking delle probabilità, non soglie specifiche. Il default per la maggior parte della classificazione binaria.average_precision: meglio del ROC AUC quando le classi sono fortemente sbilanciate. Si concentra sul trade-off precision-recall.f1: quando ti serve un singolo numero che bilanci precision e recall.neg_log_loss: quando contano le probabilità stesse, non solo i ranking (es. per decisioni calibrate a valle).neg_mean_squared_error,neg_mean_absolute_error,r2: per la regressione. La negazione è una convenzione di scikit-learn perché “più alto è meglio” funzioni in modo uniforme.
from sklearn.model_selection import cross_val_score
scores = cross_val_score(pipe, X, y, cv=5, scoring="average_precision")
Per la classificazione con classi sbilanciate, il caso comune in fraud, churn, conversion, parti da average_precision invece che dall’accuracy. Il tuo modello sembrerà peggiore sulla carta, ed è proprio il punto: ora i tuoi numeri rispecchiano la realtà.
Persistere e ricaricare
Una volta che hai una pipeline che funziona, salvala. Lo strumento convenzionale è joblib, che gestisce gli array NumPy in modo più efficiente del pickle vanilla:
import joblib
joblib.dump(pipe, "model.pkl")
# Più tardi, in un processo di serving:
pipe = joblib.load("model.pkl")
predictions = pipe.predict(X_new)
Fissa la versione di scikit-learn nel tuo pyproject.toml: i modelli pickleati possono fallire al caricamento attraverso cambi di versione major. In produzione, il pattern standard è spedire il file del modello con il codice che lo ha caricato: stessa versione di Python, stesse versioni delle librerie, niente sorprese.
Questa è la lezione. Gli estimator sono .fit + .predict. I transformer sono .fit + .transform. Avvolgili entrambi in una pipeline così non leaki. Usa la cross-validation per sapere cosa hai davvero, e scegli una metrica che rispecchi il tuo problema. La prossima lezione, feature engineering, è dove si nasconde la maggior parte dei guadagni reali. La lezione 51, modelli ad albero fatti per bene, è dove la maggior parte dei problemi di ML tabulare viene risolta.