Gestire le variabili categoriali in un progetto di data science o machine learning non è compito facile. Questo tipo di lavoro richiede conoscenza profonda del campo di applicazione e una ampia conoscenza delle molteplici metodologie disponibili.

Per questo motivo, l’articolo presente si concentrerà sullo spiegare i seguenti concetti

  • cosa sono le variabili categoriali e come dividerle nelle tipologie diverse nelle quali sono possono presentare
  • come convertirle in valore numerico in base al loro tipo
  • strumenti e tecnologie per la loro gestione usando principalmente Sklearn

La corretta gestione delle variabili categoriali può migliorare notevolmente il risultato del nostro modello predittivo o della nostra analisi. Infatti, la maggior parte delle informazioni rilevanti all’apprendimento e alla comprensione dei dati potrebbe essere contenuta proprio nelle variabili categoriali.

Ci basti pensare a dei dati tabellari, divisi per la variabile sesso oppure per un certo colore. Queste divisioni, in base al numero di categorie, possono far emergere notevoli differenze tra i gruppi e che possono informare l’analista o l’algoritmo di apprendimento.

Iniziamo col definire cosa sono e come possono presentarsi.

Definizione di variabile categoriale

Le variabili categoriali sono un tipo di variabile utilizzato in statistica e data science per rappresentare dati qualitativi o nominali. Queste variabili possono essere definite come una classe o una categoria di dati che non possono essere quantificati in modo continuo, ma solo in modo discreto.

Ad esempio, un esempio di variabile categoriale potrebbe essere il colore degli occhi di una persona, che può essere blu, verde o marrone.

La maggior parte dei modelli di apprendimento non funzionano con dati in formato categoriale. Occorre quindi sapere come convertire queste informazioni in formato numerico, in modo tale che siano preservate le informazioni.

Le variabili categoriali possono essere classificate in due tipi:

  • Nominali
  • Ordinali

Le variabili nominali sono variabili che non sono vincolate da un ordine preciso. Il sesso, il colore o dei marchi sono degli esempi di variabili nominali poiché non sono ordinabili.

Le variabili ordinali sono invece variabili categoriali divise in livelli ordinabili logicamente. Una colonna in un dataset formata da livelli come Primo, Secondo e Terzo può essere considerata una variabile categoriale ordinale.

È possibile andare più in profondità nella suddivisione delle variabili categoriali, considerando le variabili binarie e cicliche.

Una variabile binaria è semplice da comprendere: è una variabile categoriale che può assumere solo due valori.

Una variabile ciclica invece è caratterizzata da una ripetizione dei suoi valori. Ad esempio, i giorni della settimana sono ciclici, così come lo sono anche le stagioni.

Come trasformare le variabili categoriali

Ora che abbiamo definito cosa siano le variabili categoriali e come possono presentarsi, affrontiamo il discorso della loro trasformazione usando un esempio pratico - un dataset Kaggle chiamato cat-in-the-dat.

Il dataset

Questo è un dataset open source alla base di una competizione introduttiva proprio alla gestione e modellazione delle variabili categoriali, chiamata Categorical Feature Encoding Challenge II. È possibile scaricare il dato direttamente dal link qui in basso.

Categorical Feature Encoding Challenge II | Kaggle
Binary classification, with every feature a categorical (and interactions!)

La particolarità di questo dataset è che contiene solo dati categoriali. Diventa quindi il caso d’uso perfetto per questa guida. Include variabili nominali, ordinali, cicliche e binarie.

Vedremo delle tecniche per trasformare ogni variabile in un formato usabile da un modello di apprendimento.

Il dataset si presenta così

Dato che la variabile target può assumere solo due valori, questo è un compito di classificazione binaria. Useremo la metrica AUC per valutare il nostro modello.

Ora andremo ad applicare delle tecniche di gestione delle variabili categoriali usando il dataset menzionato.

1. Label Encoding (mappatura ad un numero arbitrario)

La tecnica più semplice che c’è per convertire una categoria in un formato usabile è quella di assegnare ogni categoria ad un numero arbitrario.

Prendiamo ad esempio la colonna ord_2 che contiene questi valori.

array(['Hot', 'Warm', 'Freezing', 'Lava Hot', 'Cold', 'Boiling Hot', nan],
      dtype=object)

La mappatura potrebbe avvenire in questo modo usando Python e Pandas:

df_train = train.copy()

mapping = {
    "Cold": 0,
    "Hot": 1,
    "Lava Hot": 2,
    "Boiling Hot": 3,
    "Freezing": 4,
    "Warm": 5
}

df_train["ord_2"].map(mapping)

>> 
0         1.0
1         5.0
2         4.0
3         2.0
4         0.0
         ... 
599995    4.0
599996    3.0
599997    4.0
599998    5.0
599999    3.0
Name: ord_2, Length: 600000, dtype: float64

Questa metodica ha un problema però: occorre dichiarare manualmente la mappatura. Per un numero limitato di categorie non è un problema, ma per un numero elevato potrebbe esserlo.

Per questo useremo Scikit-Learn e l’oggetto LabelEncoder per ottenere lo stesso risultato in maniera più flessibile.

from sklearn import preprocessing

# gestiamo i valori vuoti
df_train["ord_2"].fillna("NONE", inplace=True)
# inizializzamo l'encoder di sklearn
le = preprocessing.LabelEncoder()
# fit + transform
df_train["ord_2"] = le.fit_transform(df_train["ord_2"])
df_train["ord_2"]

>>
0         3
1         6
2         2
3         4
4         1
         ..
599995    2
599996    0
599997    2
599998    6
599999    0
Name: ord_2, Length: 600000, dtype: int64

La mappatura è controllata da Sklearn. Possiamo visualizzarla in questo modo:

mapping = {label: index for index, label in enumerate(le.classes_)}
mapping

>>
{'Boiling Hot': 0,
 'Cold': 1,
 'Freezing': 2,
 'Hot': 3,
 'Lava Hot': 4,
 'NONE': 5,
 'Warm': 6}

È da notare il .fillna("NONE") nello snippet di code di cui sopra. Di fatto, il label encoder di Sklearn non gestisce i valori vuoti e darà errore durante la sua applicazione se ne trova.

Una delle cose più importanti da tenere a mente per la corretta gestione delle variabili categoriali è quella di gestire sempre i valori vuoti presenti. Infatti, la maggior parte delle tecniche più rilevanti non funziona con valori mancanti.

Il label encoder mappa numeri arbitrari ad ogni categoria presente nella colonna, senza una dichiarazione esplicita della mappatura. Questo è comodo, ma introduce una problematica per alcuni modelli predittivi: introduce la necessità di scalare il dato se la colonna non è quella di target.

Infatti, spesso i neofiti del machine learning chiedono quale sia la differenza tra label encoder e one hot encoder, che vedremo tra poco. Il label encoder, per design, dovrebbe essere applicato alle label, cioè alla variabile target che vogliamo predire e non alle altre colonne.

Detto ciò, alcuni modelli anche molto rilevanti nel campo funzionano bene anche con un encoding di questo tipo. Sto parlando di modelli ad albero, tra cui spiccano XGBoost e LightGBM.

Quindi sentiamoci liberi di usare label encoder se decidiamo di usare modelli ad albero, ma per gli altri casi, dobbiamo usare il one hot encoding.

2. One Hot Encoding

Come ho già menzionato nel mio articolo riguardo le rappresentazioni vettoriali nel machine learning, il one hot encoding è una tecnica di vettorizzazione (cioè conversione di un testo in numero) molto comune e famosa.

Funziona così: per ogni categoria presente, si crea una matrice quadrata i cui unici valori possibili sono 0 e 1. Questa matrice informa il modello che tra tutte le possibili categorie, questa riga osservata ha il valore denotato dall’1.

Un esempio:

Freezing 0 0 0 0 0 1
Warm 0 0 0 0 1 0
Cold 0 0 0 1 0 0
Boiling Hot 0 0 1 0 0 0
Hot 0 1 0 0 0 0
Lava Hot 1 0 0 0 0 0

La matrice è di dimensione n_categorie. Questo è una informazione molto utile, perché il one hot encoding tipicamente richiede una rappresentazione sparsa del dato convertito. Cosa significa? Significa che per grandi numeri di categorie, la matrice potrebbe assumere dimensioni altrettanto grandi. Essendo popolata solo da valori di 0 e 1 e poiché solo una delle posizioni può essere popolata da un 1, questo rende la rappresentazione one hot molto ridondante e pesante.

Una matrice sparsa risolve questo problema - vengono salvate solamente le posizioni degli 1, mentre i valori uguali a 0 non vengono salvati. Questo snellisce il problema menzionato e permette di salvare una matrice enorme di informazioni in pochissimo spazio.

Vediamo come si presenta una tale matrice in Python, applicando nuovamente il codice di prima

from sklearn import preprocessing

# gestiamo i valori vuoti
df_train["ord_2"].fillna("NONE", inplace=True)
# inizializzamo l'encoder di sklearn
ohe = preprocessing.OneHotEncoder()
# fit + transform
ohe.fit_transform(df_train["ord_2"].values.reshape(-1, 1))

>>
<600000x7 sparse matrix of type '<class 'numpy.float64'>'
	with 600000 stored elements in Compressed Sparse Row format>

Python restituisce un oggetto di default, e non una lista di valori. Per avere tale lista, bisogna usare .toarray()

ohe.fit_transform(df_train["ord_2"].values.reshape(-1, 1)).toarray()

>>
array([[0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 1.],
       [0., 0., 1., ..., 0., 0., 0.],
       ...,
       [0., 0., 1., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 1.],
       [1., 0., 0., ..., 0., 0., 0.]])

Non preoccupatevi se non comprendete a pieno il concetto: a breve vedremo come applicare il label e one hot encoder al dataset per addestrare un modello predittivo.

💡
Il label encoding e il one hot encoding sono le tecniche più importanti per la gestione delle variabili categoriali. Conoscere queste due tecniche permetterà di gestire la maggior parte dei casi senza problemi.

3. Trasformazioni e aggregazioni

Un possibile metodo di conversione da formato categoriale a numerico è quello di effettuare una trasformazione o una aggregazione sulla variabile.

Facendo un raggruppamento con .groupby è possibile usare il conteggio dei valori presenti nella colonna come output della trasformazione.

df_train.groupby(["ord_2"])["id"].count()

>>
ord_2
Boiling Hot     84790
Cold            97822
Freezing       142726
Hot             67508
Lava Hot        64840
Warm           124239
Name: id, dtype: int64

usando .transform() possiamo sostituire questi numeri alla corrispettiva cella

df_train.groupby(["ord_2"])["id"].transform("count")

>>
0          67508.0
1         124239.0
2         142726.0
3          64840.0
4          97822.0
            ...   
599995    142726.0
599996     84790.0
599997    142726.0
599998    124239.0
599999     84790.0
Name: id, Length: 600000, dtype: float64

È possibile applicare questa logica anche con altre operazioni matematiche - va testato il metodo che più migliora le performance del nostro modello.

4. Creare nuove feature categoriali da variabili categoriali

Guardiamo la colonna ord_1 insieme a ord_2

È possibile creare nuove variabili categoriali unendo le variabili esistenti. Ad esempio, possiamo unire ord_1 con ord_2 per creare una nuova feature

df_train["new_1"] = df_train["ord_1"].astype(str) + "_" + df_train["ord_2"].astype(str)
df_train["new_1"]

>>
0                 Contributor_Hot
1                Grandmaster_Warm
2                    nan_Freezing
3                 Novice_Lava Hot
4                Grandmaster_Cold
                   ...           
599995            Novice_Freezing
599996         Novice_Boiling Hot
599997       Contributor_Freezing
599998                Master_Warm
599999    Contributor_Boiling Hot
Name: new_1, Length: 600000, dtype: object

Questa tecnica può essere applicata in praticamente ogni casistica. L’idea che deve guidare l’analista è quella di migliorare le performance del modello andando ad aggiungere informazioni originariamente difficilmente comprensibili al modello di apprendimento.

5. Usare i NaN come variabile categoriale

Molto spesso i valori nulli vengono rimossi. Questa non è tipicamente una mossa che io consiglio, in quanto nei NaN sono contenute informazioni potenzialmente utili al nostro modello.

Una soluzione è quella di trattare i NaN come una categoria a se stante.

Guardiamo nuovamente la colonna ord_2

df_train["ord_2"].value_counts()

>>
Freezing       142726
Warm           124239
Cold            97822
Boiling Hot     84790
Hot             67508
Lava Hot        64840
Name: ord_2, dtype: int64

Ora proviamo ad applicare il .fillna("NONE") per vedere quante celle vuote esistono

df_train["ord_2"].fillna("NONE").value_counts()

>>
Freezing       142726
Warm           124239
Cold            97822
Boiling Hot     84790
Hot             67508
Lava Hot        64840
NONE            18075

In percentuale, NONE rappresenta circa il 3% dell’intera colonna. Non è poco. Sfruttare il NaN ha ancora più senso e può essere fatto con il One Hot Encoder menzionato poco fa.

Tenere traccia di categorie rare

Ricordiamoci cosa fa il OneHotEncoder: crea una matrice sparsa il cui numero di colonne e righe è uguale al numero delle categorie uniche nella colonna di riferimento. Questo significa che dobbiamo tener conto anche delle categorie che potrebbero essere presenti nel test set e che potrebbero essere assenti nel train set.

Il discorso è analogo per il LabelEncoder - potrebbero esistere delle categorie nel test set ma che non sono presenti nel training set e questo potrebbe crearci problemi in fase di trasformazione.

Risolviamo questo problema andando a concatenare i dataset. Questo ci permetterà di applicare gli encoder su tutti i dati e non solo su quelli di addestramento.

test["target"] = -1
data = pd.concat([train, test]).reset_index(drop=True)
features = [f for f in train.columns if f not in ["id", "target"]]
for feature in features:
    le = preprocessing.LabelEncoder()
    temp_col = data[feature].fillna("NONE").astype(str).values
    data.loc[:, feature] = le.fit_transform(temp_col)
            
train = data[data["target"] != -1].reset_index(drop=True)
test = data[data["target"] == -1].reset_index(drop=True)

Questa metodologia ci aiuta se abbiamo il test set. Qualora non avessimo il test set, terremo conto di un valore come NONE quando una categoria nuova entrerà a far parte del nostro training set.

Modellare i dati categoriali

Passiamo ora alla parte di addestramento di un semplice modello. Seguiremo i passi dall’articolo su come progettare e implementare una cross-validazione al seguente link 👇

Cosa è la cross-validazione nel machine learning
Leggi cosa è la cross-validazione - una tecnica fondamentale per costruire modelli generalizzabili

Iniziamo da zero, importando i nostri dati e creando i nostri fold con StratifiedKFold di Sklearn.

train = pd.read_csv("/kaggle/input/cat-in-the-dat-ii/train.csv")
test = pd.read_csv("/kaggle/input/cat-in-the-dat-ii/test.csv")

df = train.copy()

df["kfold"] = -1
df = df.sample(frac=1).reset_index(drop=True)
y = df.target.values

kf = model_selection.StratifiedKFold(n_splits=5)

for f, (t_, v_) in enumerate(kf.split(X=df, y=y)):
	df.loc[v_, 'kfold'] = f

Questo piccolo snippet di code creerà un dataframe Pandas con 5 gruppi su cui testare il nostro modello.

Ora andiamo a definire una funzione che andrà a testare un modello regressione logistica su ogni gruppo.

def run(fold: int) -> None:
    features = [
        f for f in df.columns if f not in ("id", "target", "kfold")
    ]
    
    for feature in features:
        df.loc[:, feature] = df[feature].astype(str).fillna("NONE")
    
    df_train = df[df["kfold"] != fold].reset_index(drop=True)
    df_valid = df[df["kfold"] == fold].reset_index(drop=True)
    
    ohe = preprocessing.OneHotEncoder()
    
    full_data = pd.concat([df_train[features], df_valid[features]], axis=0)
    print("Fitting OHE on full data...")
    ohe.fit(full_data[features])
    
    x_train = ohe.transform(df_train[features])
    x_valid = ohe.transform(df_valid[features])
    print("Training the classifier...")
    model = linear_model.LogisticRegression()
    model.fit(x_train, df_train.target.values)
    
    valid_preds = model.predict_proba(x_valid)[:, 1]
    
    auc = metrics.roc_auc_score(df_valid.target.values, valid_preds)
    
    print(f"FOLD: {fold} | AUC = {auc:.3f}")

run(0)

>>
Fitting OHE on full data...
Training the classifier...
FOLD: 0 | AUC = 0.785

Invito il lettore interessato a leggere l’articolo sulla cross-validazione comprendere più in dettaglio il funzionamento del codice mostrato.

Vediamo ora come invece applicare un modello ad albero come XGBoost, che funziona bene anche con un LabelEncoder.

def run(fold: int) -> None:
    features = [
        f for f in df.columns if f not in ("id", "target", "kfold")
    ]
    
    for feature in features:
        df.loc[:, feature] = df[feature].astype(str).fillna("NONE")
    
    print("Fitting the LabelEncoder on the features...")
    for feature in features:
        le = preprocessing.LabelEncoder()
        le.fit(df[feature])
        df.loc[:, feature] = le.transform(df[feature])
    
    df_train = df[df["kfold"] != fold].reset_index(drop=True)
    df_valid = df[df["kfold"] == fold].reset_index(drop=True)
    
    x_train = df_train[features].values
    x_valid = df_valid[features].values
    
    print("Training the classifier...")
    model = xgboost.XGBClassifier(n_jobs=-1, n_estimators=300)
    model.fit(x_train, df_train.target.values)
    
    valid_preds = model.predict_proba(x_valid)[:, 1]
    
    auc = metrics.roc_auc_score(df_valid.target.values, valid_preds)
    
    print(f"FOLD: {fold} | AUC = {auc:.3f}")

# eseguiamo su 2 fold
for fold in range(2):
    run(fold)

>>
Fitting the LabelEncoder on the features...
Training the classifier...
FOLD: 0 | AUC = 0.768
Fitting the LabelEncoder on the features...
Training the classifier...
FOLD: 1 | AUC = 0.765

Conclusioni

In conclusione, esistono anche altre tecniche che vale la pena menzionare per la gestione delle variabili categoriali:

  • L’encoding basato sul target, dove si converte la categoria nel valore medio che assume la variabile target in corrispondenza della stessa
  • gli embedding di una rete neurale, che possono essere utilizzati per rappresentare l’entità testuale

Riassumendo, ecco gli step essenziali per una corretta gestione delle variabili categoriali

  • trattare sempre i valori vuoti
  • applicare LabelEncoder o OneHotEncoder in base al tipo di variabile e modello che vogliamo usare
  • ragionare in termini di arricchimento delle variabili, andando a considerare i NaN o NONE come variabili categoriali che possono informare il modello