One-hot e label encoding: numeri al posto di categorie

Il modello vuole numeri, ma la tua colonna dice "Milano", "Roma", "Napoli". Trasformarla in 0, 1, 2 sembra ovvio — e per molti modelli è un errore che inventa un ordine inesistente. Impari quando usare l'ordinal encoding, quando il one-hot, e come farlo bene con ColumnTransformer.

Hai un bel dataset di annunci immobiliari, lanci la regressione e scikit-learn ti risponde ValueError: could not convert string to float: 'Milano'? È il rito di passaggio di chiunque lavori con dati veri: i modelli di machine learning, sotto il cofano, fanno moltiplicazioni e somme — e non puoi moltiplicare «Milano» per un coefficiente. Prima o poi ogni colonna di testo va tradotta in numeri. La traduzione sembra banale (Milano=0, Roma=1, Napoli=2, fatto!) e invece è una delle scelte di preprocessing dove è più facile sbagliare senza che nulla si rompa: il codice gira, il modello si addestra, e intanto gli hai insegnato una bugia. Vediamo quale, e come evitarla.

Perché «Milano=0, Roma=1, Napoli=2» è una bugia

Assegnare un intero a ogni categoria si chiama ordinal encoding (o label encoding quando lo fai sulla variabile target). Il problema è che i numeri, per un modello, non sono etichette: sono quantità. Se codifichi Napoli=2 e Milano=0, una regressione lineare capirà che Napoli è «il doppio di qualcosa» rispetto a Roma=1, e che la distanza Milano–Napoli è il doppio della distanza Milano–Roma. Hai inventato un ordine e delle proporzioni che nei tuoi dati non esistono.

Pensa ai numeri di maglia dei calciatori: il numero 10 non è «il doppio» del numero 5, e calcolare la media dei numeri di maglia di una squadra non ti dice niente. Sono numeri usati come nomi. L’ordinal encoding applicato a categorie senza ordine fa esattamente questo: trasforma nomi in quantità, e i modelli che ragionano su distanze e proporzioni — regressioni, SVM, k-NN, reti neurali — ci cascano in pieno.

Quando invece l’ordine esiste davvero, l’ordinal encoding è la scelta giusta: le taglie S < M < L, i titoli di studio, le fasce «basso/medio/alto». Lì i numeri 0, 1, 2 raccontano una verità: la M sta tra la S e la L.

One-hot: una colonna per categoria, nessun ordine inventato

Per le categorie senza ordine (dette nominali) la soluzione standard è il one-hot encoding: al posto della colonna «città» crei una colonna per ogni città, con 1 dove la categoria è presente e 0 altrove. Ogni riga ha esattamente un 1 acceso — da cui il nome, «uno caldo».

L’analogia più fedele è il citofono di un condominio: non c’è un pulsante «quantità di Rossi», c’è un pulsante per famiglia, e ne premi uno solo. Nessun pulsante è «maggiore» di un altro, nessuna distanza è implicita: ogni categoria ha la sua dimensione indipendente, e il modello impara un coefficiente per ciascuna. Niente ordini inventati, niente proporzioni fantasma.

Il prezzo da pagare è la larghezza: una colonna con 3 città diventa 3 colonne, una con 5.000 codici prodotto diventa 5.000 colonne quasi tutte piene di zeri. Tienilo a mente, ci torniamo tra un attimo.

La colonna «città», prima e dopo PRIMA città Milano Roma Napoli Roma DOPO — ordinal città 0 2 1 2 Attenzione: ordine inventato! Roma (2) non è «il doppio» di Napoli (1). Ok solo se S<M<L. DOPO — one-hot Milano Roma Napoli 100 010 001 010 una colonna per città, un solo 1 per riga: nessun ordine implicito

La stessa colonna nominale codificata in due modi: con l’ordinal encoding un solo numero per riga (comodo, ma l’ordine 0<1<2 è un’invenzione), con il one-hot una colonna binaria per categoria e nessuna gerarchia implicita.

In pratica: OneHotEncoder e OrdinalEncoder (e chi è LabelEncoder)

Prepara l’ambiente: Python 3.12 in un venv (o con uv), editor a scelta tra VS Code e PyCharm, e le solite due librerie. Piccolo chiarimento di nomenclatura che confonde tutti: in scikit-learn LabelEncoder serve solo per la colonna target (le etichette da predire), mentre per le feature si usano OrdinalEncoder e OneHotEncoder. Se in un vecchio tutorial vedi LabelEncoder applicato alle feature, è codice d’altri tempi.

# pip install pandas scikit-learn   (dentro il venv)
import pandas as pd
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder

df = pd.DataFrame({
    "citta":  ["Milano", "Roma", "Napoli", "Roma", "Milano", "Napoli"],
    "taglia": ["S", "L", "M", "M", "S", "L"],   # ordine VERO: S < M < L
    "mq":     [55, 80, 65, 70, 48, 90],
    "prezzo": [1250, 980, 700, 890, 1100, 820],  # affitto mensile, target
})

# One-hot per la categoria nominale (nessun ordine)
ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
citta_oh = ohe.fit_transform(df[["citta"]])
print(ohe.get_feature_names_out())   # ['citta_Milano' 'citta_Napoli' 'citta_Roma']
print(citta_oh)

# Ordinal per la categoria ORDINATA: l'ordine lo dichiari tu,
# altrimenti scikit-learn usa quello alfabetico (L<M<S: sbagliato!)
oe = OrdinalEncoder(categories=[["S", "M", "L"]])
print(oe.fit_transform(df[["taglia"]]).ravel())  # [0. 2. 1. 1. 0. 2.]

Due dettagli del codice che in produzione valgono oro. handle_unknown="ignore" dice al one-hot cosa fare se in inferenza arriva una città mai vista in training («Torino»): tutte le colonne a zero invece di un crash. E l’argomento categories di OrdinalEncoder ti obbliga a dichiarare l’ordine giusto: senza, l’ordinamento alfabetico farebbe L < M < S — una taglia media più grande della large.

Forse conosci anche pd.get_dummies: fa il one-hot in una riga ed è comodissimo per esplorare. Ma non ha memoria: se il batch di domani non contiene nessun annuncio di Napoli, produce una tabella con una colonna in meno e il modello va in errore. Per l’esplorazione va bene; per un modello che deve vivere oltre il notebook, usa OneHotEncoder dentro una pipeline.

Tutto insieme: ColumnTransformer dentro una Pipeline

Nel mondo reale le colonne categoriche convivono con quelle numeriche, e ognuna vuole il suo trattamento. ColumnTransformer è lo smistatore che applica il trasformatore giusto a ogni gruppo di colonne:

from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import Ridge
from sklearn.model_selection import cross_val_score

X = df[["citta", "taglia", "mq"]]
y = df["prezzo"]

pre = ColumnTransformer([
    # (nome, trasformatore, colonne a cui applicarlo)
    ("nominali", OneHotEncoder(handle_unknown="ignore"), ["citta"]),
    ("ordinali", OrdinalEncoder(categories=[["S", "M", "L"]]), ["taglia"]),
    ("numeriche", StandardScaler(), ["mq"]),
])

pipe = Pipeline([("preprocessing", pre), ("modello", Ridge())])

# La pipeline impara le categorie SOLO dal train di ogni fold:
# niente sorprese tra training e produzione
print(cross_val_score(pipe, X, y, cv=3, scoring="r2"))

Se non ricordi perché lo StandardScaler è lì, ti basta sapere che porta le colonne numeriche su una scala comparabile — ne abbiamo parlato nel tutorial su train/test split e scaling. Il punto importante è un altro: l’encoder dentro la pipeline impara le categorie solo dal train set, esattamente come l’imputer dei dati mancanti. Stessa regola d’oro, stesso motivo: niente informazioni dal futuro.

Quando il one-hot NON serve (onestà sulle alte cardinalità)

Codificare le categorie non è un dettaglio tecnico: è dire al modello quali distanze esistono tra i tuoi dati. Sbaglia la codifica, e il modello imparerà con impegno una geografia che non esiste.

Sarebbe disonesto chiudere senza i limiti. Primo: gli alberi di decisione e i loro ensemble (random forest, gradient boosting) ragionano per soglie, non per distanze — con un ordinal encoding se la cavano spesso bene anche su categorie nominali, e il one-hot può perfino peggiorarli frammentando le feature. Secondo: con migliaia di categorie (codici prodotto, ID utente) il one-hot esplode in migliaia di colonne sparse; lì si passa a tecniche come il target encoding (nella libreria category_encoders) o, nel deep learning, agli embedding. Terzo: per i modelli lineari senza regolarizzazione può servire drop="first" per evitare colonne perfettamente ridondanti. Il one-hot è il default sensato per categorie nominali con poche decine di valori e modelli basati su distanze — non un dogma universale.

Prova tu

Prendi il dataset del codice sopra e aggiungi una colonna riscaldamento con i valori «assente», «centralizzato», «autonomo». Domanda non banale: è nominale o ordinale? Prova entrambe le codifiche nel ColumnTransformer e confronta gli score in cross-validation. Poi fai il test cattivo: passa alla pipeline un annuncio con citta="Torino" e verifica con i tuoi occhi cosa succede con e senza handle_unknown="ignore".

Per approfondire: la sezione Encoding categorical features della guida ufficiale scikit-learn (scikit-learn.org, capitolo Preprocessing) copre tutti i parametri visti qui; per le alte cardinalità, la documentazione della libreria category_encoders è il passo successivo naturale.

I commenti sono riservati agli iscritti.

Accedi per commentare