Gestire i dati mancanti: buttare, riempire o predire?

Il tuo modello si rifiuta di partire per colpa di qualche NaN? Prima di cancellare righe a caso, fermati: eliminare, imputare con la media o ricostruire dal contesto sono tre strategie con effetti molto diversi. Le confrontiamo con codice vero — e con la regola d'oro che ti salva dal data leakage.

Hai preparato il dataset, scritto model.fit(X, y) tutto contento, e scikit-learn ti ha gelato con un ValueError: Input contains NaN? Benvenuto nel club: succede a chiunque lavori con dati veri. I dataset dei tutorial sono tavole apparecchiate; quelli reali sono groviere. Un sensore che si spegne, un utente che salta una domanda del form, un gestionale migrato male nel 2018 — e la tua tabella si riempie di buchi. La tentazione è risolvere in fretta con un dropna() e via. A volte è la scelta giusta. Spesso è come curare il mal di testa con la ghigliottina: il sintomo sparisce, insieme a metà del paziente. In questo tutorial mettiamo a confronto le tre famiglie di strategie — eliminare, riempire con un valore fisso, ricostruire dal contesto — e le misuriamo sullo stesso problema, con lo stesso metro.

Prima di riempire i buchi, chiediti perché ci sono

Se non ricordi cos’è un NaN, ti basta sapere che è il segnaposto standard di NumPy e pandas per «qui non c’è un valore»: non è zero, non è una stringa vuota, è un’assenza dichiarata. E le assenze non sono tutte uguali.

Pensa a un questionario di quartiere. Se alcune risposte mancano perché a qualcuno è caduta la penna, i buchi sono sparsi a caso: gli statistici lo chiamano missing completely at random. Ma se la domanda «quanto guadagni?» viene saltata soprattutto da chi guadagna molto, il buco dipende dal valore stesso che manca. Cancellare quelle righe non ti dà un questionario più pulito: ti dà un quartiere immaginario dove i redditi alti non esistono. Il campione resta grande abbastanza, ma è diventato bugiardo.

Non ti serve la tassonomia completa (MCAR, MAR, MNAR — la trovi nel rimando in fondo). Ti serve l’abitudine di guardare dove mancano i dati e chiederti se il buco è informativo. A volte «il valore manca» è la feature più predittiva che hai.

Strategia 1 — Eliminare: onesta, ma costosa

df.dropna() butta ogni riga che contiene almeno un buco. È la strategia più onesta — non inventi niente — e va benissimo quando i buchi sono pochi (diciamo sotto il 5%) e sparsi a caso. Ma ha due controindicazioni serie. La prima è aritmetica: con 10 colonne e un modesto 5% di buchi per colonna, se le assenze sono indipendenti rischi di perdere il 40% delle righe. La seconda l’abbiamo appena vista: se i buchi non sono casuali, eliminando le righe distorci il campione.

C’è poi un problema pratico che quasi nessuno ti dice: in produzione dropna() non esiste. Quando il modello è online e arriva un utente con un campo vuoto, non puoi «eliminare la riga» — devi comunque rispondere. Una strategia di imputazione ti serve in ogni caso, almeno per l’inferenza.

Strategia 2 — Riempire con un valore fisso: la media (con un trucco)

L’alternativa più comune: sostituire ogni buco con un valore rappresentativo della colonna — la media, la mediana (più robusta agli outlier), la moda per le categorie. È come ridipingere le macchie su una parete con il «colore medio» della parete: da lontano non si vede, e per molti modelli è più che sufficiente.

Ma il ritocco ha un effetto collaterale: appiattisce. Ogni buco riempito con la media è un punto in più esattamente sulla media, quindi la varianza della colonna si riduce e le correlazioni si annacquano. Con tanti buchi, il muro ridipinto diventa un muro monocolore.

Il trucco che riscatta la strategia costa una riga: add_indicator=True. Oltre a riempire il buco, aggiunge una colonna binaria che dice «qui mancava il valore». Così il modello riceve entrambe le informazioni: il valore ricostruito e il fatto che fosse assente — che, come abbiamo visto, può essere un segnale prezioso di per sé.

Strategia 3 — Ricostruire dal contesto: chiedi ai vicini

Se nel tuo palazzo non sai quanto paga di condominio l’appartamento 4B, non chiedi la media di tutta la città: chiedi ai vicini di pianerottolo, che hanno metratura e piano simili. KNNImputer fa esattamente questo: per ogni riga bucata cerca le k righe più simili sulle colonne complete, e riempie il buco con la loro media. Un gradino più su c’è IterativeImputer, che addestra un piccolo modello di regressione per predire ogni colonna dalle altre, a rotazione, finché i valori non si stabilizzano.

Sono strategie più fedeli alla struttura dei dati, ma non gratis: costano più calcolo (il k-NN confronta ogni riga con tutte le altre), richiedono feature su scale comparabili, e su dataset piccoli rischiano di ricostruire rumore. Onestà intellettuale: se i buchi sono il 2% e sparsi a caso, la mediana con indicatore fa lo stesso lavoro a un decimo del costo. L’artiglieria pesante tirala fuori quando i buchi sono tanti e strutturati.

La stessa tabella bucata, tre strategie 1 · Elimina (dropna) righe con buchi: via semplice, ma perdi dati (e rischi un campione distorto) 2 · Imputa un valore fisso media media ogni buco = stesso valore veloce e robusto, ma appiattisce la varianza 3 · Ricostruisci dal contesto k-NN k-NN ogni buco = valore dei simili più fedele ai dati, ma più costoso Regola d’oro: qualunque strategia scegli, il fit dell’imputer avviene SOLO sul train set

Le tre famiglie di strategie sulla stessa tabella: eliminare le righe bucate (perdi dati), riempire con un valore fisso come la media (appiattisci), ricostruire dai record simili (più fedele, più costoso). In basso, la regola che vale per tutte.

Alla prova dei fatti: le strategie a confronto in codice

Basta teoria: misuriamo. Ti serve Python 3.12 con un ambiente virtuale pulito (con venv o, se ti piace la velocità, uv) e due librerie:

# Nel terminale (VS Code o PyCharm vanno benissimo):
#   python -m venv .venv && source .venv/bin/activate
#   pip install pandas scikit-learn
# oppure, con uv:  uv venv && uv pip install pandas scikit-learn

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 400

# Dataset immobiliare sintetico: prevediamo il prezzo da mq, anno e piano
mq    = rng.normal(90, 25, n).clip(30, 200)
anno  = rng.integers(1950, 2020, n).astype(float)
piano = rng.integers(0, 8, n).astype(float)
prezzo = 1500 * mq + 800 * (anno - 1950) + rng.normal(0, 15000, n)

df = pd.DataFrame({"mq": mq, "anno": anno, "piano": piano, "prezzo": prezzo})
anno_medio_vero = df["anno"].mean()   # media VERA, prima di bucare i dati

# Buchi REALISTICI: l'anno di costruzione manca più spesso
# negli immobili vecchi (il buco è informativo!)
p_buco = np.where(df["anno"] < 1975, 0.35, 0.10)
df.loc[rng.random(n) < p_buco, "anno"] = np.nan
df.loc[rng.random(n) < 0.05, "piano"] = np.nan

print(df.isna().sum())        # quanti buchi per colonna
print(df.isna().mean().round(2))  # in percentuale

Ora il confronto vero. Ogni strategia entra in una Pipeline e viene valutata con la stessa cross-validation — così il paragone è leale:

from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer, KNNImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import Ridge
from sklearn.model_selection import cross_val_score

X = df[["mq", "anno", "piano"]]
y = df["prezzo"]

strategie = {
    "mediana":        SimpleImputer(strategy="median"),
    "mediana + flag": SimpleImputer(strategy="median", add_indicator=True),
    "k-NN (k=5)":     KNNImputer(n_neighbors=5),
}

for nome, imputer in strategie.items():
    pipe = Pipeline([
        ("imputa", imputer),          # riempie i buchi
        ("scala",  StandardScaler()), # scale comparabili (serve al k-NN)
        ("modello", Ridge()),         # regressione regolarizzata
    ])
    r2 = cross_val_score(pipe, X, y, cv=5, scoring="r2")
    print(f"{nome:16s} R² medio = {r2.mean():.3f}")

# E il dropna? Confrontalo a parte: nota che il modello
# addestrato solo sulle righe complete vede un mercato "più nuovo"
completo = df.dropna()
print(f"\nRighe sopravvissute al dropna: {len(completo)} su {n}")
print(f"Anno medio con dropna: {completo['anno'].mean():.0f}")
print(f"Anno medio reale (prima di bucare i dati): {anno_medio_vero:.0f}")

Esegui il codice e osserva due cose. Primo: la strategia con add_indicator=True tende a fare meglio della mediana secca, perché nel nostro dataset il buco su anno è correlato all'età dell'immobile — il flag "mancava" porta informazione. Secondo: il dropna sopravvive con meno righe e con un anno medio spostato verso il nuovo, esattamente la distorsione da campione bugiardo di cui parlavamo.

La regola d'oro: imputa dentro la Pipeline, mai prima

Se le Pipeline di scikit-learn non ti dicono ancora niente, per oggi basta questo: sono catene di passaggi in cui ogni pezzo impara i suoi parametri solo dal train set di ogni fold. Ed è qui che si nasconde l'errore più diffuso: calcolare la media su tutto il dataset e poi fare lo split. Sembra innocuo, ma la media calcolata su tutto contiene anche i valori del test set — è come ripassare per un'interrogazione con il foglio delle domande già in mano. Il voto sale, la preparazione no: si chiama data leakage, e l'imputazione fuori dalla pipeline ne è la porta d'ingresso classica.

Un buco nei dati non è mai solo un buco: è un'informazione sul mondo che ha generato quei dati. Prima di riempirlo, ascolta cosa ti sta dicendo.

Riassumendo il criterio di scelta: pochi buchi casuali → dropna o mediana, senza sensi di colpa. Buchi consistenti o sospetti → SimpleImputer con add_indicator=True come baseline, poi prova KNNImputer e tieni il migliore in cross-validation. E se una colonna è bucata oltre il 60-70%, chiediti se vale la pena tenerla: a volte la risposta onesta è no.

Prova tu

Riprendi il codice sopra e rendi i buchi ancora più cattivi: fai mancare anno con probabilità proporzionale al prezzo (gli immobili di lusso non dichiarano l'età). Poi rilancia il confronto: quale strategia degrada di più? La mediana secca o il k-NN? E il flag di missing quanto recupera? Scrivi le previsioni prima di eseguire: è il modo migliore per scoprire cosa hai capito davvero.

Per approfondire: la guida ufficiale di scikit-learn sull'imputazione (sezione Imputation of missing values, scikit-learn.org) è il riferimento pratico; se vuoi la teoria delle tipologie di missingness (MCAR/MAR/MNAR), il classico è Statistical Analysis with Missing Data di Little e Rubin.

I commenti sono riservati agli iscritti.

Accedi per commentare