X
    Categories: blog

Apprendimento supervisionato – Classificazione con regressione logistica

Continuiamo il nostro viaggio tra gli strumenti di Intelligenza Artificiale, andando a vedere un metodo di classificazione. Lo faremo utilizzando una… regressione… Come vedremo si tratta di un ulteriore tipo particolare di regressione lineare, quindi molte cose le abbiamo già viste negli articoli precedenti.

Cos’è la classificazione

Con classificazione si intende il processo di assegnazione, ad ogni esempio in ingresso, di una classe di appartenenza, basandosi su caratteristiche comuni, dei vari esempi elaborati.

Un esempio classico che troverete in ogni tutorial di intelligenza artificiale, è quello di distinguere tra tre varietà di Iris (Iris setosaIris virginica e Iris versicolor) basandosi su quattro caratteristiche del fiore: lunghezza e la larghezza del sepalo e del petalo.

In una situazione ideale avremmo le classi completamente distinguibili tra loro, senza sovrapposizioni. Ovviamente a noi non interessano i casi semplici, quindi nella realtà la situazione sarà in generale quella in cui alcune classi (se non tutte) tendono a mischiarsi in vario modo tra loro. Il nostro obiettivo quindi sarà quello di trovare uno strumento che possa fornire dei risultati con un certo grado di accuratezza.

Regressione logistica

Partiamo dal caso più semplice di classificazione: la classificazione binaria. In questo caso sono presenti due classi di appartenenza e l’obiettivo quindi sarà quello di assegnare un dato in input ad una classe o all’altra in base a determinate caratteristiche.

Come si può intuire dalla figura a fianco, lo scopo di un algoritmo di classificazione sarà quello di individuare la retta che meglio riesce a separare le due classi nello spazio delle caratteristiche. La retta trovata viene chiamata decision boundary ed essendo una retta è definita da una equazione che abbiamo già incontrato.

con b il nostro bias e w i nostri pesi.

Una volta trovati i valori di bias e pesi (ad esempio con una regressione lineare multipla), sappiamo che fornendo all’equazione i valori di ingresso x otteniamo un valore di y, che in questo caso utilizzeremo per classificare l’ingresso in base a queste regole:

  • y < 0 : il dato in ingresso appartiene alla classe che sta sotto la decision boundary (i cerchi azzurri nell’esempio in figura)
  • y > 0 : il dato in ingresso appartiene alla classe che sta sopra la decision boundary (le croci rosse nell’esempio in figura)
  • y = 0 : il dato di ingresso è poggiato esattamente sopra la decision boundary. Solitamente si decide preventivamente la classe di appartenenza in questi casi.

Ora dovremmo rendere questo risultato intuitivo in una forma maggiormente fruibile da un algoritmo.

Abbiamo detto che stiamo parlando di classificazione binaria, quindi avremo due classi che chiamiamo genericamente Classe A (per i cerchi azzurri) e Classe B (per le croci rosse). Sappiamo che un algoritmo lavora meglio con dei numeri piuttosto che con degli identificativi astratti, quindi procediamo a codificare le due classi in valori numerici. Usiamo ad esempio il valore 0 per la Classe A e 1 per la Classe B.

Definiamo quindi una ulteriore funzione, chiamata funzione di attivazione, che in base al valore di y possa indicarci la classe di appartenenza (in forma numerica). Decidiamo in questo caso di assegnare alla Classe B gli ingressi che si verranno a trovare esattamente sulla decision boundary.

WELL DONE! Abbiamo creato il nostro primo algoritmo di classificazione.

Analizziamo meglio il risultato che abbiamo ottenuto: il nostro algoritmo ci dice che un dato in ingresso è di Classe A o è di Classe B. Abbiamo però detto in precedenza che nella vita reale difficilmente esiste il bianco ed il nero. Come nella figura a fianco, potremmo trovarci di fronte a classi non completamente separabili, quindi alcuni ingressi potrebbero essere classificati in maniera non corretta, ad esempio come pallino blu solo perchè si trova sopra la retta, mentre nella realtà in quel particolare caso l’ingresso doveva essere classificato come pallino rosa. Questo errore di classificazione si verificherà con maggiore probabilità per i punti che sono prossimi alla retta di confine, mentre per quelli che se ne allontanano la classificazione sarà più affidabile.

Quindi sarebbe più utile una funzione di attivazione che possa dirci contemporaneamente la classe di appartenenza e il livello di attendibilità, di confidenza di questa classificazione.

Vediamo come fare. Innanzitutto introduciamo il concetto di probabilità condizionata. Sfruttiamo la nostra codifica delle classi per rivedere la nostra classificazione. Se 0 significa appartenenza alla Classe A, allora 1 significa appartenenza alla Classe B o anche NON appartenenza alla Classe A. Del resto siamo nell’ipotesi di una classificazione binaria, quindi quello che non è Classe A è per forza Classe B e viceversa. Allora, dati i valori di X (dati in ingresso) e W (pesi), definisco come probabilità condizionata della classe 0, la probabilità che Y (cioè la classe di appartenenza dei dati in ingresso) sia la classe 0. In notazione statistica:

La probabilità è un valore che va da 0 ad 1 ed in particolare 0 significa nessuna probabilità che l’evento si verifichi, 1 significa assoluta certezza che l’evento si verifichi e i valori intermedi sono interpretabili come una percentuale normalizzata di probabilità (0.5 = 50%, 0.3 = 30%, e così via). Abbiamo quindi che se P(Y=0|X,W)=1 allora è certo che l’ingresso è classificato come Classe A, mentre se P(Y=0|X,W)=0 allora non c’è possibilità che l’ingresso possa essere classificato come Classe A (il che, nel nostro caso, equivale a dire che è certo che l’ingresso è classificato come Classe B). Tutti i valori intermedi indicano quanta probabilità ha l’ingresso X di essere classificato come Classe A, quindi in sostanza indica l’appartenenza (o la non appartenenza) alla Classe A associata ad un livello di confidenza di questa classificazione.

Bene, sembra che abbiamo trovato una funzione che fa al caso nostro. Ma questa funzione… com’è definita? Sappiamo che deve digerire dati che vanno da -∞ a +∞ e fornire come risultato valori compresi tra 0 ed 1 (rappresenta una probabilità). Non solo. Possiamo dire che lo stesso comportamento deve averla la probabilità condizionata della classe 1, cioè anche P(Y=1|X,W): deve quindi anch’essa convertire valori da -∞ a +∞ in valore da 0 ad 1. Infine, visto che siamo nell’ambito di una classificazione binaria, possiamo dire che se un ingresso non è di Classe A, sarà di Classe B e viceversa, che si esprime con:

Facciamola breve… Dopo varie disquisizioni matematiche, che salteremo a piè pari, possiamo dire che la funzione di attivazione che ci interessa è la cosiddetta funzione sigmoidale, la cui curva e definizione matematica è riportata qui sotto.

Come si vede, si tratta di una funzione la cui ordinata tende a 0 per ascisse tendenti a -∞, mentre tende ad 1 per ascisse tendenti a +∞. Nell’ascissa 0 il valore dell’ordinata è 0,5. In verità le sigmoidi sono una famiglia di curve, le cui caratteristiche sono di avere una curva ad S, di essere continue, derivabili, con derivata prima non negativa e dotate di minimo e massimo locale. Quella riportata in figura è una delle classiche funzioni sigmoidali utilizzate come funzione di attivazione, anche nelle reti neurali come vedremo più avanti, e a tutte queste caratteristiche aggiunge anche di avere valori compresi tra 0 ed 1 e di avere un valore perfettamente equilibrato (0,5) per un ingresso pari a 0, che fa di lei un’ottima scelta per il nostro caso.

In conclusione la nostra regressione logistica sarà una regressione classica a cui viene applicata la funzione di attivazione sigmoidale. Il risultato complessivo rappresenta la probabilità che ha l’ingresso di appartenere alla classe positiva, dove con classe positiva si intende la classe identificata dai valori posti sopra la decision boundary (nel nostro caso si trattava della Classe B, rappresentata dalle croci rosse).

Come abbiamo visto anche dal grafico sopra, il valore 0,5 della sigmoide indica una ascissa 0 che corrisponde ad un punto che sta esattamente sulla decision boundary. Quindi nel nostro caso la classe di appartenenza sarà Classe A quando la regressione logistica fornisce un valore < 0,5, sarà Classe B per valori ≥ 0,5.

Funzione di costo

Abbiamo detto che la regressione logistica è una regressione classica a cui viene applicata una successiva funzione di attivazione. Ciò significa che le funzioni di costo viste negli articoli precedenti non possono essere utilizzate, la funzione di attivazione rende diversa l’unità di misura del risultato (una probabilità) e della funzione di costo classica (solitamente una distanza). Sarebbe un po’ come se voler misurare una temperatura con un goniometro… Dobbiamo fare in modo che anche la funzione di costo ragioni sulle probabilità. Ad esempio con la funzione likelihood che rappresenta la probabilità di ottenere un output desiderato, considerando un certo ingresso e un certo vettore di pesi. Anch’essa, essendo una probabilità, avrà valori compresi tra 0 ed 1 corrispondenti rispettivamente a nessuna probabilità di ottenere il risultato desiderato oppure certezza di ottenerlo. In questo caso, quindi, dovremmo trovare i valori dei pesi (le uniche variabili che possiamo modificare) che portano la funzione likelihood il più vicino possibile ad 1. Senza addentrarci troppo in dettagli che, se volete, potrete approfondire in autonomia, normalmente si tende ad utilizzare la negative log-likelihood, cioè il logaritmo naturale della funzione likelihood moltiplicato per -1. Questa funzione ha una curva simile a quella in figura e possiamo quindi utilizzare il già noto algoritmo del gradient descentper trovarne il minimo.

E codice sia!

NOTA: il codice di questo esempio lo trovi qui

Cominciamo con una classificazione binaria. Utilizzeremo un dataset pubblico che potete trovare in Kaggle, una libreria online dove troverete un enorme numero di dataset di vario genere. Il nostro è a questo indirizzo: 

https://www.kaggle.com/uciml/pima-indians-diabetes-database

Si tratta di un dataset con un elenco di attributi clinici che possono indicarci se un paziente è affetto o meno da diabete. Dovremo scaricarlo in locale come file CSV e poi caricarlo tramite Pandas. Delle varie colonne presenti, l’ultima, denominata Outcomerappresenta il nostro valore di output atteso.

# carico il dataset (contiene già gli header, quindi non li devo specificare)
pima = pd.read_csv("./diabetes.csv")
 
# diamo un'occhiata al contenuto
print(pima.head())
  Pregnancies  Glucose  ...  Age  Outcome
0 6 148 ... 50 1
1 1 85 ... 31 0
2 8 183 ... 32 1
3 1 89 ... 21 0
4 0 137 ... 33 1

La colonna Outcome contiene questi valori che rappresentano le nostre classi (siamo fortunati… sono già in formato numerico…)

# diamo un'occhiata alle classi di Outcome (la nostra proprietà di uscita)
print(pima["Outcome"].unique())
[1 0]

Il valore 1 indica che in quelle condizioni il paziente ha diabete, il valore 0 indica assenza di diabete. Da questo in punto in poi si prosegue come visto negli articoli precedenti: prepariamo la matrice X dei dati di input (dalla quale possiamo togliere la colonna Outcome), il vettore Y dei dati di output (corrispondente alla colonna Outcome), suddividiamo X ed Y in due dataset, uno di train ed uno di test, eseguiamo la regressione e facciamo una predizione sul dataset di test.

In questo caso useremo la classe di regressione LogisticRegression fornita sempre da SciKitLearn. Per valutarne le prestazioni useremo innanzitutto una matrice di confusione, alla quale passiamo in ingresso i risultati predetti e quelli attesi. Visualizziamo anche delle metriche come l’accuracy, la precisione e la recall, così facciamo un ripasso.

# eseguo una predizione anche delle confidenze delle classificazioni
# mi servirà per il calcolo della negative log-likelihood
Y_pred_proba = logReg.predict_proba(X_test)
 
# creo una matrice di confusione per analizzare il comportamento della predizione
cnf_matrix = confusion_matrix(Y_test, Y_pred)
 
# visualizzo la matrice di confusione in modo grafico
class_names = [0, 1]
fig, ax = plt.subplots()
tick_marks = np.arange(len(class_names))
plt.xticks(tick_marks, class_names)
plt.yticks(tick_marks, class_names)
sns.heatmap(pd.DataFrame(cnf_matrix), annot=True, cmap="YlGnBu", fmt='g')
ax.xaxis.set_label_position("top")
plt.tight_layout()
plt.title('Matrice di confusione', y=1.1)
plt.ylabel('Classi reali')
plt.xlabel('Classi predette')
plt.show()
 
# valutiamo il modello con le metriche messe a disposizione da SciKitLearn
print("Accuracy:", accuracy_score(Y_test, Y_pred))
print("Precision:", precision_score(Y_test, Y_pred))
print("Recall:", recall_score(Y_test, Y_pred))
print("Neg. Log-Likelihood:", log_loss(Y_test, Y_pred_proba))
Accuracy: 0.8072916666666666
Precision: 0.7659574468085106
Recall: 0.5806451612903226
Neg. Log-Likelihood: 0.4552448184256869

Come vediamo i risultati non sono eclatanti, ma comunque decenti. L’accuracy è abbastanza alta (80%) e corrisponde in questo caso ad un calcolo semplice: rapporto tra numero di esempi correttamente classificati e numero di esempi complessivi. Dalla matrice vediamo sulla diagonale principale le classificazioni corrette, sulle altre posizioni le classificazioni non corrette.

Anche la precisione non è male: in pratica ci dice che quando il modello di regressione logistica prevede che i pazienti soffriranno di diabete, i pazienti lo faranno il 76% delle volte.

La recall ci dice che se ci sono pazienti che hanno il diabete nel dataset di test, allora il modello di regressione logistica può identificarlo il 58% delle volte.

La negative log-likelihood di 0,45 non ci entusiasma (dovrebbe essere molto più vicina a 0), ma è anche vero che i dati in nostro possesso sono pochi (il dataset contine 768 righe di cui il 25% usato per il test).

Classificazione multiclasse

Abbiamo visto che la regressione logistica si presta bene alla classificazione binaria, ma quando abbiamo più classi da distinguere, come procediamo?

Un metodo può essere il cosiddetto One-Against-All o One VS All.

In pratica con questo metodo si creano diversi classificatori, uno per ogni classe da identificare, e li si allenano sul dataset di train in modo separato, quindi come semplice classificatore binario: il primo classificatore identificherà ciò che è Classe 1 e ciò che non è Classe 1, il secondo identificherà ciò che è Classe 2 e ciò che non lo è, e così via.

A questo punto, quando si dovrà predirre un nuovo esempio in ingresso, questo verrà passato ad ognuno dei classificatori, i quali forniranno la probabilità di appartenenza di quell’esempio alla classe di riferimento.

Ad esempio potremmo avere tre classificatori, come in figura, e quindi ottenere:

P(Y=1|X,W)=0,5
P(Y=2|X,W)=0,3
P(Y=3|X,W)=0,2

Il risultato complessivo sarà l’assegnazione dell’esempio in ingresso alla classe associata al classificatore con probabilità maggiore (in questo caso Classe 1).

Ancora codice!

NOTA: il codice di questo esempio lo trovi qui

Questa volta proveremo a identificare delle cifre utilizzando come dataset il famoso MNIST che contiene 70.000 esempi di immagini 28×28 monocromatiche contenenti cifre decimali scritte a mano. Ma come, vi chiederete, stiamo per fare classificazioni di immagini senza utilizzare reti neurali, convoluzioni, deep learning? Sì, si può fare anche con una semplice regressione logistica! Vediamo come. La libreria SciKitLearn ci consente di accedere direttamente a questo dataset, visto che è usato molto frequentemente per gli esercizi di intelligenza artificiale. Data la grandezza dei dataset, questa volta ci vorrebbero ore per allenare la regressione. Per cui limitiamoci ad un dataset di 5.000 esempi. Questa quantità potete modificarla a piacimento tramite una variabile nel codice.

# dati caricati da https://www.openml.org/d/554
X, Y = fetch_openml('mnist_784', version=1, return_X_y=True)
 
# limitiamo il dataset
dataset_limit = 5000
X = X[:dataset_limit]
Y = Y[:dataset_limit]
 
# diamo uno sguardo ai dati di input e di output
print("Dimensioni dei dati di input:", X.shape)
print("Classi di output:", np.unique(Y))
Dimensioni dei dati di input: (5000, 784)
Classi di output: ['0' '1' '2' '3' '4' '5' '6' '7' '8' '9']

Le classi di output ci tornano, in quanto sono esattamente le 10 cifre decimali a cui siamo abituati. Da notare però che questa volta non sono codificate, in quanto non si tratta di numeri, ma bensì di stringhe. Quindi questa volta dovremo procedere ad una codifica, anche se abbastanza banale. Notiamo inoltre che X è una matrice di 5.000 x 784 valori. Abbiamo detto che ci siamo limitati a 5.000 esempi, quindi il primo numero ci torna. Ma non avevamo detto che erano immagini 28 x 28? Beh, in effetti 784 = 28 x 28… In sostanza le nostre immagini bidimensionali sono state trasformate in vettori monodimensionali, dove ogni elemento rappresenta un pixel, ed ogni pixel rappresenta una feature. Proviamo quindi a ricostruire qualche immagine da questo dataset, per vedere come sono fatte. Uso la funzione reshape che consente di modificare le dimensioni del nostro vettore nell’originale matrice 28 x 28.

# visualizziamo le 10 cifre decimali per vedere come sono fatte
for i in range(0, 10):
    digit = X[Y == str(i)][0].reshape([28, 28])
    ll_plot = plt.subplot(2, 5, i + 1)
    ll_plot.imshow(digit, cmap="gray")
plt.show()

Le immagini sono monocromatiche, quindi c’è un solo canale colore per ogni pixel che assume valori da 0 (nero) a 255 (bianco) con tutti i valori intermedi per determinare i vari livelli di grigio.

Torniamo alle label: abbiamo detto che sono da codificare in quanto sono in forma di stringa. Possiamo procedere manualmente o lasciare fare a SciKitLearn che come al solito mette a disposizione una classe di trasformazione apposita.

# eseguo la codifica automatica delle label
labEnc = LabelEncoder()
Y_enc = labEnc.fit_transform(Y)
 
# visualizzo le label codificate
print("Label codificate:", np.unique(Y_enc))
 
# confronto label originali con label codificate
for i in range(10):
    # prendo il primo indice di Y_enc che contiene il valore corrente di i
    indice_label = np.where(Y_enc == i)[0][0]
    # visualizzo la label originale in stessa posizione di quella codificata per un confronto
    print("Label originale:", Y[indice_label], type(Y[indice_label]),
          "- Label codificata:", Y_enc[indice_label], type(Y_enc[indice_label]))
Label codificate: [0 1 2 3 4 5 6 7 8 9]
Label originale: 0 <class 'str'> - Label codificata: 0 <class 'numpy.int32'>
Label originale: 1 <class 'str'> - Label codificata: 1 <class 'numpy.int32'>
Label originale: 2 <class 'str'> - Label codificata: 2 <class 'numpy.int32'>
Label originale: 3 <class 'str'> - Label codificata: 3 <class 'numpy.int32'>
Label originale: 4 <class 'str'> - Label codificata: 4 <class 'numpy.int32'>
Label originale: 5 <class 'str'> - Label codificata: 5 <class 'numpy.int32'>
Label originale: 6 <class 'str'> - Label codificata: 6 <class 'numpy.int32'>
Label originale: 7 <class 'str'> - Label codificata: 7 <class 'numpy.int32'>
Label originale: 8 <class 'str'> - Label codificata: 8 <class 'numpy.int32'>
Label originale: 9 <class 'str'> - Label codificata: 9 <class 'numpy.int32'>

Vediamo che la codifica è ottimale, visto che la stringa della label originale è stata convertita direttamente in intero.

Ora procediamo come al solito, creando il dataset di test e di train, standardizzando i dataset e procedendo con la regressione logistica come già visto. Infatti la classe di SciKitLearn riesce automaticamente a capire che si tratta di una classificazione multiclasse e quindi a procedere con l’algoritmo One Vs All. In ogni caso, tramite i parametri di istanza è possibile modificare questo comportamento, ma al momento a noi va bene così. Calcoliamo infine le metriche e la matrice di confusione e analizziamo i risultati.

Risultati regressione logistica default
Accuracy: 0.8306666666666667
Precision: 0.837012688411258
Recall: 0.8306666666666667
Neg. Log-Likelihood: 2.071366080536661

I risultati non sono male, visto l’83% delle volte la cifra è correttamente riconosciuta. Certo la likelihood è un po’ altina, quindi di sicuro si può fare di meglio. Proviamo ad impostare dei parametri per la regressione logistica diversi dal default, ad esempio forzando l’uso di regolarizzazione L2 e usando un resolver che possa utilizzarlo (per comprendere i parametri, fate riferimento all’ampia pagina di documentazione).

# ripeto modificando i parametri della regressione logistica
logReg = LogisticRegression(multi_class='multinomial',
                            penalty='l2', solver='sag')
Risultati regressione logistica modificata
Accuracy: 0.9053333333333333
Precision: 0.9053995420419972
Recall: 0.9053333333333333
Neg. Log-Likelihood: 0.46288195097921264

Come vediamo i risultati sono migliorati sensibilmente, con un 90% di accuratezza e una likelihood decisamente migliore. Anche la matrice di confusione è migliorata, con i valori in diagonale principale più alti della versione precedente.

Esercizi a casa

Abbiamo iniziato questo articolo parlando di fiori. Se volete potete sperimentare il dataset Iris contenuto in SciKitLearn e trovare un buon modello di classificazione che consenta di distinguere le tre specie di fiore in base alle caratteristiche dei petali e sepali.

Vi dò solo un indizio.

from sklearn import datasets
 
# importo il dataset Iris
iris = datasets.load_iris()
X = iris.data
Y = iris.target
 
print("Nomi features:", iris.feature_names)
print("Nomi classi:", iris.target_names)

Alla prossima!

Cristiano Casadei: Lavoro in Maggioli dal ’96 e ho contribuito a diversi prodotti dell’Azienda: da Concilia a TradeWin, ai prodotti per i Demografici. Dal 2016 entro a far parte a tempo pieno del team dei Progetti Speciali, ora R&D. In questo team ho contribuito allo sviluppo di Revisal, Scacco2 e ora mi occupo di studiare e sperimentare soluzioni che fanno uso di Intelligenza Artificiale. Come si può intuire dalla foto, amo la montagna.
Related Post