Python, dalle fondamenta Lezione 52 / 60

Modelli lineari e regolarizzati: quando il semplice vince

Perche' i modelli lineari sono ancora la risposta giusta sorprendentemente spesso, e i trucchi di regularization che li rendono pronti per la produzione.

C’e’ una scena ricorrente nel data work: a un junior engineer viene chiesto di modellare un dataset tabulare, lui prende XGBoost, fa tuning per un pomeriggio, il modello arriva a 0.847 di AUC, e va in produzione. Poi un senior dà un’occhiata al problema, fa il fit di una logistic regression con un paio di feature ben scelte, ottiene 0.831 di AUC, e chiede: questi sedici millesimi in piu’ di AUC valgono un modello le cui decisioni non riesci a spiegare al team compliance?

La risposta a volte è sì. Spesso è no. E l’unico modo per saperlo è avere davvero la baseline lineare con cui confrontarti. Questa lezione parla della famiglia di modelli, linear regression, logistic regression e i loro cugini regolarizzati, che dovrebbe essere la prima cosa che provi su qualsiasi problema tabulare, e che sorprendentemente spesso finisce per essere la cosa che mandi in produzione.

La forma di un modello lineare

I modelli lineari predicono prendendo una somma pesata delle feature. Tutto qui. Per un target continuo y, la linear regression dice:

y_hat = w0 + w1*x1 + w2*x2 + ... + wn*xn

L’allenamento consiste nel trovare i pesi w0..wn che minimizzano l’errore quadratico sui training data. Esiste una soluzione in forma chiusa con inversione di matrice, e se la matrice è ben condizionata LinearRegression di scikit-learn la restituisce quasi all’istante. Niente iterazioni, niente learning rate.

Per la classificazione binaria, la logistic regression avvolge quella stessa somma pesata in una sigmoide:

p(y=1 | x) = sigmoid(w0 + w1*x1 + ... + wn*xn)

L’allenamento minimizza la log-loss (cross-entropy). Niente forma chiusa; itera, ma su dati tabulari di dimensioni ragionevoli converge in pochi secondi. Nonostante il nome, la logistic regression è un algoritmo di classificazione: la “regression” si riferisce al fitting di probabilità, non alla predizione di valori continui.

Entrambi i modelli hanno lo stesso sapore: un vettore di coefficienti che puoi leggere, uno per feature.

Il vantaggio dell’interpretabilità

Ecco una cosa che nient’altro ti dà. Dopo il fit, puoi leggere model.coef_ e dire:

Un aumento di 1 unità nella feature X è associato a una variazione di coef_X unità nel target (linear regression), o a una variazione di coef_X log-odds nella probabilità della classe positiva (logistic regression), tenendo costanti tutte le altre feature.

Quella frase, “tenendo costanti tutte le altre feature”, sta facendo un sacco di lavoro, ma rimane comunque un’affermazione sensata. Prova a dire qualcosa di altrettanto diretto su una random forest. O su una rete neurale. Puoi calcolare i valori SHAP, certo, ma sono spiegazioni post-hoc di un modello le cui decisioni interne restano opache.

In settori regolamentati, credit scoring, rischio medico, ovunque un regolatore possa chiedere “perché il vostro modello ha respinto questa persona”, l’interpretabilità non è un nice-to-have. È il vincolo. La logistic regression è ancora il modello dominante nel credit underwriting nel 2026, e non perché i data scientist non abbiano sentito parlare di XGBoost.

Un esempio di regression, in tre modi

Mettiamo su un piccolo problema di regression e facciamo il fit di tre varianti di modello lineare:

import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.metrics import mean_squared_error, r2_score

X, y = fetch_california_housing(return_X_y=True, as_frame=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

def fit_and_report(name, model):
    pipe = Pipeline([("scale", StandardScaler()), ("model", model)])
    pipe.fit(X_train, y_train)
    pred = pipe.predict(X_test)
    rmse = np.sqrt(mean_squared_error(y_test, pred))
    r2 = r2_score(y_test, pred)
    coefs = dict(zip(X.columns, pipe.named_steps["model"].coef_.round(3)))
    print(f"{name:8s}  RMSE={rmse:.3f}  R2={r2:.3f}")
    print(f"          coefs: {coefs}")

fit_and_report("Linear", LinearRegression())
fit_and_report("Ridge",  Ridge(alpha=1.0))
fit_and_report("Lasso",  Lasso(alpha=0.1))

Nota lo StandardScaler davanti. La regularization penalizza la magnitudine dei coefficienti, il che ha senso solo se le feature sono su scale comparabili. Dimenticarsi di scalare prima di Ridge o Lasso è uno dei top tre errori da principiante.

Quando esegui questo codice vedrai RMSE entro pochi punti percentuali l’uno dall’altro fra i tre modelli, ma i coefficienti sembrano diversi. LinearRegression usa qualunque peso minimizzi l’MSE di training, per quanto grande. Ridge li riduce tutti verso zero. Lasso ne porta diversi esattamente a zero: ha già fatto feature selection per te.

La regularization, in un paragrafo

La linear regression semplice su feature rumorose o correlate fa overfit. I coefficienti crescono troppo per inseguire il rumore. La regularization aggiunge alla loss function una penalità sulla magnitudine dei coefficienti. Due varianti contano:

  • L2 (Ridge): la penalità è la somma dei coefficienti al quadrato. Riduce tutti i coefficienti in modo continuo. Buona quando pensi che molte feature contino un po’. Soluzione in forma chiusa; molto veloce.
  • L1 (Lasso): la penalità è la somma dei valori assoluti dei coefficienti. Porta alcuni coefficienti esattamente a zero, facendo feature selection. Buona quando pensi che la maggior parte delle feature siano rumore. Niente forma chiusa; coordinate descent.
  • ElasticNet: una combinazione pesata di L1 e L2. La scelta sensata di default quando non sai quale famiglia si adatti ai tuoi dati.

La forza è controllata dall’iperparametro alpha. Più alto alpha = più shrinkage = modello più semplice. Lo tuni, di solito con cross-validation. scikit-learn fornisce RidgeCV, LassoCV e ElasticNetCV che fanno la CV all’interno del fit:

from sklearn.linear_model import RidgeCV, LassoCV

ridge_cv = RidgeCV(alphas=np.logspace(-3, 3, 50))
ridge_cv.fit(X_train, y_train)
print(f"best alpha = {ridge_cv.alpha_}")

Il bias-variance tradeoff in un paragrafo

I modelli underfit hanno alto bias: sono troppo semplici per catturare il segnale, quindi sono sistematicamente sbagliati nella stessa direzione su diversi dataset. I modelli overfit hanno alta variance: sono così flessibili che si attaccano al rumore, quindi lo stesso modello allenato su un campione diverso appare molto diverso. La regularization sposta un modello da low-bias-high-variance verso higher-bias-lower-variance. Il punto ottimale, l’alpha che minimizza l’errore totale sui dati held-out, è quello che RidgeCV sta cercando.

Quando il lineare vince

Alcune situazioni in cui dovresti aspettarti onestamente che un modello lineare regolarizzato sia competitivo o migliore di un tree ensemble:

  • Dataset piccoli. Con qualche centinaio di righe, gli alberi fanno overfit e non puoi tunare la via d’uscita. I modelli lineari, con un forte prior di regularization, sono stabili.
  • Servono interpretabilità. Settori medici, legali, finanziari regolamentati, ovunque la decisione del modello debba essere difendibile. I coefficienti sono lo strumento di spiegazione più pulito che abbiamo.
  • Relazioni approssimativamente lineari. Rare ma non estinte. Alcuni processi fisici, alcune relazioni economiche, alcune risposte di sensori. Se i tuoi scatter plot sembrano nuvole intorno a una retta, un modello lineare è il fit onesto.
  • Numero di feature >> numero di campioni. La genomica è l’esempio canonico: 30.000 feature, 200 campioni. Gli alberi non sopravvivono. Lasso sì: la penalità L1 è essenzialmente costruita per il caso “la maggior parte delle feature sono rumore”.
  • Serving sensibile alla latenza. L’inference di un modello lineare è un singolo dot product. Microsecondi. Gli alberi e gli ensemble sono più lenti, le reti neurali ancora di più.

Quando il lineare perde

  • Dati tabulari con forti interazioni fra feature. “Il reddito conta di più se hai meno di 30 anni”: un singolo coefficiente sul reddito non riesce a catturarlo. Gli alberi splittano prima sull’età, poi sul reddito, e ottengono l’interazione gratis. È per questo che XGBoost domina le competition tabulari su Kaggle.
  • Forti non-linearità. Relazioni a forma di sigmoide, a dente di sega, o bimodali. A volte puoi rimediare con feature polinomiali (PolynomialFeatures(degree=2) in scikit-learn) o metodi a kernel, ma a quel punto stai lavorando di più che semplicemente fare il fit di un albero.
  • Dati grezzi di immagini, audio, testo. Non provarci nemmeno con modelli lineari su pixel grezzi o conteggi di parole, oltre a una baseline. Per questo ci sono le reti neurali e gli embedding di transformer. Anche se modelli lineari sopra embedding pre-calcolati? Quello è ancora vivo e vegeto nel 2026.

Generalized linear model per target non-Gaussiani

La linear regression assume che i residui siano approssimativamente normali, con varianza costante. Per alcuni tipi di target è sbagliato, e forzarlo produce predizioni sciocche (conteggi negativi, probabilità fuori da [0,1]). I generalized linear model (GLM) estendono la somma lineare pesata ad altre distribuzioni tramite una link function:

  • Logistic regression: target Bernoulli, link logit. (Già coperta.)
  • Poisson regression: target di conteggio (numero di eventi), link log. Usa PoissonRegressor in scikit-learn.
  • Gamma regression: target continuo strettamente positivo con errori asimmetrici a destra (taglie dei sinistri assicurativi, time-to-event), link log. GammaRegressor.
  • Tweedie regression: per target che mescolano una massa puntuale a zero con una coda continua (premio puro assicurativo). TweedieRegressor.
from sklearn.linear_model import PoissonRegressor

# Target is number of website visits per day
model = PoissonRegressor(alpha=0.1)
model.fit(X_train, visit_counts_train)

Questi conservano l’interpretabilità dei modelli lineari rispettando la statistica reale del tuo target. Se il tuo target è un conteggio, usa Poisson, non i minimi quadrati. Le predizioni saranno non-negative per costruzione, la loss rispetterà il fatto che la varianza cresce con la media, e i coefficienti saranno sulla scala logaritmica (un aumento di 1 unità nella feature moltiplica il conteggio predetto per exp(coef)).

Un esempio di classificazione, in breve

Per la classificazione binaria vale lo stesso schema con LogisticRegression. Il parametro C è l’inverso della forza di regularization: C piccolo significa regularization pesante. Di default scikit-learn usa regularization L2; passa penalty="l1" (con solver="liblinear" o "saga") per una logistic in stile Lasso, o penalty="elasticnet" (con solver="saga") per la versione mista.

from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression, LogisticRegressionCV

X, y = load_breast_cancer(return_X_y=True, as_frame=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=42)

clf = Pipeline([
    ("scale", StandardScaler()),
    ("model", LogisticRegressionCV(Cs=20, cv=5, max_iter=5000)),
])
clf.fit(X_train, y_train)
print(f"Test accuracy = {clf.score(X_test, y_test):.3f}")

LogisticRegressionCV fa tuning cross-validato di C automaticamente. Su Wisconsin breast cancer, 30 feature, 569 campioni, raggiunge regolarmente accuracy di 0.97+ ed è completamente interpretabile. Gli alberi non lo battono in modo significativo su questo dataset, e un modello lineare con coefficienti espliciti è ciò che vorresti davvero che un clinico ispezionasse.

Multiclass con la stessa macchina

LogisticRegression si estende al multiclass allenando un classificatore binario per classe (one-vs-rest) o allenando direttamente un modello in stile softmax (multi_class="multinomial", il default nelle versioni moderne di scikit-learn). In ogni caso l’API è identica al caso binario: passi una y con più di due valori unici e lascia che faccia il resto. La storia dell’interpretabilità si degrada un po’: ora hai un coefficiente per feature, per classe, quindi la spiegazione diventa “la feature X spinge in alto la classe A, in basso la B, in alto la C” invece che un singolo numero. Ancora leggibile, solo più denso.

Lo schema pratico

Ecco il workflow che proporrei su qualsiasi nuovo problema tabulare:

  1. Fai il fit di una baseline lineare regolarizzata (RidgeCV per regression, LogisticRegressionCV per classificazione) su feature scalate correttamente. Annota lo score cross-validato.
  2. Fai il fit di un tree ensemble con impostazioni di default (XGBoost, LightGBM, o RandomForest) sugli stessi dati. Annota lo score.
  3. Confronta. Se l’albero batte il modello lineare di 1-2%, manda in produzione il modello lineare. L’interpretabilità, la velocità di inference, la superficie di failure più piccola: valgono due punti di accuracy in quasi qualsiasi contesto business al di fuori di Kaggle.
  4. Se l’albero batte il modello lineare del 10%+, il problema ha davvero non-linearità o interazioni e dovresti investire nel percorso albero: tunarlo, valutarlo con cura, pianificare il costo operativo.

Il bias qui è voluto. Andare di default sul modello più semplice è una protezione contro le modalità di failure del modello che non vedi nella tua valutazione offline: train/serve skew, distribution drift, bug nella feature pipeline che cambiano comportamento in modi che i modelli complessi nascondono. Una logistic regression che sbaglia, sbaglia in modi che puoi leggere dai coefficienti. Un gradient-boosted ensemble da 1000 alberi che sbaglia è una sessione di debugging che ti rovina la settimana.

Prossima lezione: come cercare sistematicamente nello spazio degli iperparametri quando devi prendere il modello più pesante.


References: scikit-learn linear models documentation (scikit-learn.org/stable/modules/linear_model.html), retrieval 2026-05-01.

Cerca