Costruire ed ottimizzare pipeline in scikit-learn (Tutorial)


Un'esigenza comune per qualsiasi data scientist è quella di combinare in sequenza diverse operazioni sui dati, quali ad esempio normalizzazioni, ripulitura dei valori mancanti, riduzione della dimensionalità, ed ovviamente classificazione. Le pipeline sono un modulo di scikit-learn che permette di automatizzare questo processo, creando algoritmi estremamente sofisticati dalla combinazione di oggetti di base della libreria.

In questo tutorial vediamo come, oltre a semplificare la scrittura del codice, le pipeline permettono di ottimizzare in maniera automatica (e simultanea) tutti i passi della catena, per massimizzare l'accuratezza sui dati: possiamo scegliere contemporaneamente sia quali algoritmi eseguire, sia i loro parametri ottimali. Questo tutorial estende un esempio già presente (in lingua inglese) sulla documentazione ufficiale della libreria. La versione corrente della libreria mentre scriviamo è la v0.19.1; per installarla da terminale, è sufficiente digitare:

pip install sklearn=0.19.1

Tutti gli esempi dovrebbero funzionare anche con versioni inferiori della libreria, a meno della loro riorganizzazione in diversi sotto-moduli.

Costruire la pipeline

Iniziamo importando un dataset tra quelli predefiniti di scikit-learn, tenendone da parte un sottoinsieme per il test del modello:

from sklearn.datasets import load_boston
from sklearn.model_selection import train_test_split
data = load_boston()
X_train, X_test, y_train, y_test = train_test_split(data['data'], data['target'])

Per chi non lo avesse mai usato, il Boston dataset è un problema di regressione relativamente piccolo (506 esempi con 13 feature) , ottimo per sperimentare i metodi che andremo ad elencare. Per semplicità, ne approfittiamo per importare tutti i moduli di sklearn che useremo in seguito:

from sklearn.preprocessing import StandardScaler, RobustScaler, QuantileTransformer
from sklearn.feature_selection import SelectKBest, f_regression
from sklearn.decomposition import PCA
from sklearn.linear_model import Ridge

Come potete intuire dalla lista di import, vogliamo implementare una pipeline piuttosto standard costituita da tre passi fondamentali:

  • Normalizzazione dei dati: qui abbiamo a disposizione numerosi moduli predefiniti, dalla semplice normalizzazione affine fino a tecniche che tengono conto degli outlier e della distribuzione dei dati. Nel nostro esempio ne abbiamo scelti tre, tra cui il recentissimo QuantileTransformer (link alla documentazione).
  • Riduzione della dimensionalità: anche qui abbiamo una vasta scelta, incluse varie tecniche non supervisionate. Per semplicità, consideriamo solo la onnipresente PCA ed un metodo univariato di selezione delle feature.
  • Regressione: sempre per semplicità qui usiamo solo un modello lineare con regolarizzazione, ma potremmo facilmente estendere l'esempio considerando altri regressori (facendo attenzione a normalizzare adeguatamente anche l'output del modello, cosa che qui non consideriamo).

Per cominciare, scegliamo un metodo predefinito per ciascuna operazione ed inizializziamo tutte le classi senza usare per ora la pipeline (più avanti vedremo come ottimizzare anche la scelta dei passi):

scaler = StandardScaler()
pca = PCA()
ridge = Ridge()

A questo punto, senza pipeline, dobbiamo applicare ciascun metodo in sequenza ai nostri dati di training:

X_train = scaler.fit_transform(X_train)
X_train = pca.fit_transform(X_train)
ridge.fit_transform(X_train, y_train)

Come si vede, c'è molta ripetizione di codice e, di conseguenza, possibilità di sbagliare metodo e/o di non propagare correttamente i dati. Per semplificare, ripetiamo la stessa sequenza ma creando questa volta un oggetto pipeline:

from sklearn.pipeline import Pipeline
pipe = Pipeline([
        ('scaler', StandardScaler()),
        ('reduce_dim', PCA()),
        ('regressor', Ridge())
        ])

Come si vede, la pipeline non è altro che una lista di elementi, ciascuno composto da un nome e da un oggetto di sklearn, che devono essere eseguiti in sequenza. La possibilità di creare oggetti come questo deriva dalla quasi completa uniformità dell'interfaccia di sklearn: quasi tutti i suoi oggetti possono essere inizializzati ed usati sfruttando in pratica solo due/tre metodi di base, quali fit, transform, ed eventualmente predict.

A questo punto, la pipeline può essere eseguita e valutata con due soli comandi:

pipe = pipe.fit(X_train, y_train)
print('Score finale di test: ', pipe.score(X_test, y_test))

Inoltre, possiamo indicizzare la pipeline per accedere a ciascuno dei passi dopo l'allenamento, ad esempio andando a leggere la varianza spiegata da ciascun componente della PCA:

print(pipe.steps[1][1].explained_variance_)

Che (a seconda del seed) ritornerà qualcosa del genere (le percentuali di varianza spiegata da ciascun componente della PCA):

[ 6.17666461 1.40357729 1.22791087 0.89037592 0.84781455 0.65543078 0.4911068 0.40790576 0.27463223 0.21616899 0.20742042 0.16826568 0.06711765]

Questo grafico spiega visivamente come vengono propagati i dati all'interno della pipeline (immagine copyright di Sebastian Raschka):

Pipeline

Per qualsiasi oggetto all'interno della pipeline, vengono chiamati in sequenza i relativi metodi fit_transform in fase di training, e transform (o predict) in fase di test. Fin qui, l'uso della pipeline è meramente uno strumento di convenienza ed eleganza del codice. Vediamo ora come sfruttarlo per ottimizzare tutti gli iper-parametri del modello.

Ottimizzare la pipeline (versione base)

Nella terminologia comune, gli iper-parametri (un termine mutuato dalla statistica) sono tutti quei valori che possono essere liberamente impostati dall'utente, e che in genere vengono ottimizzati massimizzando l'accuratezza su dei dati di validazione con una ricerca su griglia (o altre tecniche). Di solito si pensa agli iper-parametri come appartenenti all'algoritmo di regressione (o classificazione), ma in realtà qualsiasi tecnica usiamo in pratica ne potrebbe avere: ad esempio, applicando la PCA possiamo scegliere quante componenti principali mantenere. La stessa scelta di una tecnica piuttosto che di un'altra può essere vista come un iper-parametro categorico, che ha tanti valori quanti sono i metodi tra cui possiamo scegliere.

Iniziamo da un caso semplice, in cui continuiamo ad usare le tecniche che abbiamo scelto prima, ma vogliamo ottimizzare il numero di componenti selezionate dalla PCA insieme al valore della regolarizzazione nel modello lineare. Se non siete familiari con il modulo GridSearchCV di sklearn, questo è un buon momento per ripassare il tutorial di base.

Per la PCA, siamo interessati a valutare come cambia l'accuratezza se teniamo un numero variabile di componenti, ad esempio da 1 fino ad 10:

import numpy as np
n_features_to_test = np.arange(1, 11)

Per il parametro di regolarizzazione, seguiamo direttamente il tutorial di base e valutiamo un insieme di valori in un intervallo esponenziale:

alpha_to_test = 2.0**np.arange(-6, +6)

Notiamo subito che questi due iper-parametri non sono indipendenti e vanno selezionati insieme: un diverso numero di componenti della PCA potrebbe richiedere un diverso quantitativo di regolarizzazione, e viceversa. Dobbiamo quindi valutare tutte le possibili combinazioni dei due iper-parametri. Fortunatamente, con la pipeline questo è immediato. Prima di tutto, andiamo a costruire un dizionario con tutti i parametri da valutare:

params = {'reduce_dim__n_components': n_features_to_test,\
              'regressor__alpha': alpha_to_test}

L'unico aspetto degno di nota è il modo in cui nominiamo i parametri, che deriva da una convenzione dell'oggetto pipeline: nome dello step (che abbiamo scelto in fase di inizializzazione dell'oggetto), seguito da due underscore, seguito dal nome del parametro. L'ottimizzazione vera e propria è equivalente al tutorial della libreria:

from sklearn.model_selection import GridSearchCV
gridsearch = GridSearchCV(pipe, params, verbose=1).fit(X_train, y_train)
print('Final score is: ', gridsearch.score(X_test, y_test))

Grazie alla pipeline, possiamo trattare l'intero algoritmo come un unico stimatore, ed ottimizzare i parametri simultaneamente:

In[*]: gridsearch.best_params_
Out[*]: {'reduce_dim__n_components': 8, 'regressor__alpha': 8.0}

Per concludere questo tutorial, facciamo un ulteriore passo avanti e vediamo come utilizzare le pipeline per selezionare in automatico anche quale algoritmo utilizzare.

Ottimizzare la pipeline (versione avanzata)

Prima, abbiamo scelto un intervallo di valori per ciascun parametro da testare. In teoria, possiamo pensare di fare la stessa cosa per scegliere non solo un valore, ma anche quale oggetto utilizzare, ad esempio per normalizzare i dati:

scalers_to_test = [StandardScaler(), RobustScaler(), QuantileTransformer()]

Come detto prima, possiamo pensare a questa situazione come se avessimo un iper-parametro categorico che prende tre valori, uno per ciascun algoritmo che vogliamo testare. Grazie alla pipeline, possiamo aggiungere anche questo alla nostra ricerca su griglia, semplicemente rimpiazzando un intero step della pipeline stessa:

params = {'scaler': scalers_to_test,
        'reduce_dim__n_components': n_features_to_test,\
        'regressor__alpha': alpha_to_test}

Si noti come il primo nome seleziona un intero step, mentre il secondo ed il terzo elemento selezionano un parametro all'interno di ciascuno step. Potremmo pensare di selezionare così anche lo step di riduzione della dimensionalità, ad esempio scegliendo tra PCA e SelectKBest. L'unico problema in questo caso è che, mentre il primo richiede di ottimizzare un parametro che si chiama n_components, il secondo richiede di ottimizzare un parametro che si chiama diversamente, k (anche se l'effetto finale è lo stesso, ovvero di avere un certo numero di feature in output).

Fortunatamente, gestire questo problema in sklearn è molto semplice, poiché GridSearchCV permette di ottimizzare non solo dizionari di parametri, ma anche liste di dizionari di parametri:

params = [
        {'scaler': scalers_to_test,
         'reduce_dim': [PCA()],
         'reduce_dim__n_components': n_features_to_test,\
         'regressor__alpha': alpha_to_test},

        {'scaler': scalers_to_test,
         'reduce_dim': [SelectKBest(f_regression)],
         'reduce_dim__k': n_features_to_test,\
         'regressor__alpha': alpha_to_test}
        ]

Concludiamo quindi lanciando la nostra ricerca su griglia:

gridsearch = GridSearchCV(pipe, params, verbose=1).fit(X_train, y_train)
print('Final score is: ', gridsearch.score(X_test, y_test))

Nel nostro caso, abbiamo scelto alla fine uno scaling robusto, una PCA (con 9 componenti), ed una regressione bassamente regolarizzata:

In[*]: gridsearch.best_params_
Out[*]: 
{'reduce_dim': PCA(copy=True, iterated_power='auto', n_components=9, random_state=None,
   svd_solver='auto', tol=0.0, whiten=False),
 'reduce_dim__n_components': 9,
 'regressor__alpha': 8.0,
 'scaler': RobustScaler(copy=True, quantile_range=(25.0, 75.0), with_centering=True,
        with_scaling=True)}

Ovviamente una prova con un dataset così piccolo non è molto realistica ed i risultati potrebbero variare molto, ma l'esempio è facilmente estendibile anche a dataset più complessi. Con troppi iper-parametri potremmo però dover cambiare la tecnica di ricerca per evitare una ricerca esaustiva (es., con una ricerca casuale su griglia). Nel caso più generale ancora, potremmo pensare di testare qualsiasi combinazione esistente di algoritmi e parametri e cercare di scegliere sempre quella migliore: il sogno proibito dell'automatic machine learning.


Se questo articolo ti è piaciuto e vuoi tenerti aggiornato sulle nostre attività, ricordati che l'iscrizione all'Italian Association for Machine Learning è gratuita! Puoi seguirci anche su Facebook e su LinkedIn.

Previous Post Next Post