X
    Categories: blog

Classificazione con rete neurale

Bene, è giunta l’ora di creare la nostra prima rete neurale ed usarla per eseguire una classificazione. Partiamo con un esempio di computer vision, classificando delle immagini, in particolare delle cifre decimali, utilizzando il dataset MNIST che abbiamo già avuto modo di conoscere negli articoli precedenti.

Prima però facciamo un breve excursus sui framework che utilizzeremo.

I framework

Creare una rete neurale e soprattutto gestirne la fase di addestramento e di inferenza non è una cosa banale, soprattutto quando il numero di livelli e di neuroni iniziano ad essere elevati. Per fortuna sono stati scritti diversi framework per gestire il tutto a più alto livello. Ne citiamo alcuni:

  • Tensorflow
  • Caffe
  • Torch
  • Deeplearning4J
  • Theano
  • Keras
  • MxNet

questo indirizzo potrete trovare un interessante articolo che elenca i 10 migliori framework per il deep learning.

Per i nostri esempi utilizzeremo due framework in combinazione: Tensorflow e Keras.

Tensorflow

Si tratta di una libreria open source manutenuta da Google, multipiattaforma (WindowsLinuxMac OS XAndroid), con API native in vari linguaggi (PythonC/C++JavaGoRUST) più altre API di terze parti per C#R e Scala.

E’ possibiIe compilare i sorgenti con varie ottimizzazioni hardware delle CPU moderne (SSEAVXFMAMKL) o anche attivando l’uso delle librerie CUDA di NVIDIA per sfruttare direttamente la potenza elaborativa parallela delle GPU contenute nelle schede video recenti.

Infine Google ha realizzato appositi processori ASIC chiamati TPU (Tensor Processing Unit) che aumentano ulteriormente la capacità elaborativa portandola a 180 teraflop e che possono essere utilizzati direttamente da Tensorflow.

Questa libreria è una delle più utilizzate, in particolare da Google nelle sue applicazioni (ma non solo) ed è nativamente fruibile in diversi servizi cloud.

La realizzazione di una rete neurale tramite Tensorflow avviene definendo dei tensori (informalmente possiamo dire che sono matrici multidimensionali) e le operazioni che vengono applicate tra loro (pesi moltiplicati per ingressi, sommati a bias, funzioni di attivazione, ecc…).

La libreria mette a disposizione diversi algoritmi di ottimizzazione già definiti (sono gli algoritmi utilizzati per avvicinarsi alla soluzione ottimale) fruibili per la fase di addestramento.

In definitiva, quindi, definire, addestrare ed utilizzare una rete neurale tramite questa libreria non è difficile. Ad ogni modo in caso di reti particolarmente complesse si può avere una leggibilità limitata del codice.

Keras

Anche Keras è una libreria open source ed è stata progettata come interfaccia ad alto livello per librerie di back-end come Tensorflow, CNTK, Theano. In pratica Keras mette a disposizione una interfaccia unificata, maggiormente leggibile e produttiva per definire ed utilizzare reti neurali sfruttando i motori neurali dei back-end a cui si appoggia.

Il successo di questa libreria è stata tale che nel 2017 il team di Tensorflow ha deciso di supportare Keras ufficialmente, tanto da includerla come sottolibreria del framework principale.

Questo significa che con le ultime versioni di Tensorflow è possibile fruire di Keras direttamente tramite importazioni del tipo:

from tensorflow.python.keras import .......

Ovviamente in questo caso, la libreria Keras deve gestire un unico back-end (Tensorflow) quindi il codice sorgente risulta essere ottimizzato rispetto alla libreria generica.

Perchè usiamo Keras?

Non c’è un motivo preciso, se non una questione di abitudine mia personale e in generale ottenere una migliore leggibilità e riusabilità del codice.
Gli esempi che faremo li potrete tranquillamente adattare a qualsiasi framework di vostro gusto. Considerate comunque che l’accoppiata Tensorflow + Keras è molto comune, in rete troverete tantissimi esempi e tutorial che partono da questa base ed anche i più importanti servizi cloud (Google con la sua ML-Engine ovviamente, ma anche AWS per citare solo le due più famose) sono ottimizzati per queste due librerie.

Usare la versione GPU-accelerated di Tensorflow è molto semplice e quindi chi ha a disposizione una scheda NVIDIA di ultima generazione sufficientemente potente può installare i driver e le librerie che servono seguendo queste istruzioni.
Si noti che il package Python da installare per Tensorflow è tensorflow, mentre il package accelerato è tensorflow-gpu.
Per il resto il codice sorgente non dovrà essere minimamente modificato in quanto i due package sono completamente intercambiabili tra loro: l’unica differenza, come vedremo, sarà nella velocità di esecuzione.

Codice Python

NOTA: il codice di questo esempio lo trovi qui

Vogliamo creare una rete neurale che sia in grado di riconoscere e classificare gli esempi MNIST in ingresso. Come ricorderete abbiamo già incontrato il dataset MNIST negli articoli precedenti. Si tratta di un dataset formato da immagini da 28×28 pixel a livelli di grigio contenenti cifre decimali scritte a mano.
Keras mette a disposizione una semplice funzione per importare il dataset.

# ottengo il dataset MNIST già suddiviso in dataset X e Y, di addestramento e di test
(X_train, y_train), (X_test, y_test) = mnist.load_data()

Per capire meglio di cosa stiamo parlando, visualizziamo 4 immagini casuali dal dataset.

# visualizzo 4 cifre random
for i in range(4):
    plt.subplot(2, 2, (i + 1))l
    # in 'shape[0]' è contenuto il numero di esempi del dataset (in questo caso di addestramento)
    plt.imshow(X_train[randint(0, X_train.shape[0])], cmap=plt.get_cmap('gray'))
plt.show()

Ogni pixel è rappresentato da un byte, quindi con valori che variano da 0 (nero) a 255 (bianco). Le immagini sono memorizzate in array Numpy e sia Keras che Tensorflow lavorano sfruttando queste strutture dati, perchè veloci e con funzionalità avanzate di algebra vettoriale.
Come sempre, per rendere i risultati replicabili (stiamo sperimentando), impostiamo un seed random.

# imposto un seed random in modo da ottenere risultati replicabili, d'ora in avanti
numpy.random.seed(1234)

Questa rete neurale prenderà in input ogni singolo pixel. La cosa più semplice è prendere la matrice di 28×28 pixel e “appiattirla” in un vettore di 784 elementi (784 = 28×28). Per fare questo si utilizza la funzione reshape di Numpy che vuole in ingresso le nuove dimensioni (o shape) del vettore. In questo caso manterremo come prima dimensione il numero degli esempi presenti nel dataset. Ne approfittiamo anche per convertire i valori interi in valori in virgola mobile.

# modifico le matrici di pixel in modo da ottenere un vettore di pixel monodimensionale per ogni cifra
# in 'shape[1]' e 'shape[2]' è contenuto le dimensioni in pixel (rispettivamente y ed x)
num_pixel = X_train.shape[1] * X_train.shape[2]
X_train = X_train.reshape(X_train.shape[0], num_pixel).astype('float32')
X_test = X_test.reshape(X_test.shape[0], num_pixel).astype('float32')

Fatto questo andremo a “normalizzare” i valori. Come abbiamo già visto anche negli articoli precedenti, la normalizzazione è un aspetto molto importante che consente di migliorare le performance della fase di addestramento.

# normalizzo i valori dei pixel portandoli dal range intero 0-255 al range in virgola mobile 0.0-1.0
# visto che sono array numpy, è sufficiente eseguire l'operazione direttamente sull'array
X_train = X_train / 255
X_test = X_test / 255

Infine modificheremo i dataset dei risultati attesi (ground truth) utilizzando la codifica one hot. In questo caso il ground truth equivale al codice identificativo della classe (0 = cifra ‘0’, 1 = cifra ‘1’, ….., 9 = cifra ‘9’). Questa codifica va benissimo a livello di comprensione umana, ma ha lo svantaggio di generare un ordinamento naturale tra le classi.

In generale un classificatore deve discriminare tra classi che non godono di proprietà di ordinamento: quale potrebbe essere l’ordine tra “cane”, “gatto”, “libro” e “nutella”?
Utilizzare una codifica che generi un ordinamento può tornare utile in alcuni casi, ma in generale non nella classificazione.
Per questo motivo utilizzeremo una codifica one hot che consente di identificare la classe di appartenenza con una stringa posizionale di cifre “zero” ed “uno” e lunghezza pari al numero di classi da gestire.

Ad esempio: il valore 0 verrà codificato con 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 (l’uno in posizione 0 e le rimanenti 9 posizioni azzerate).
Il valore 1 verrà codificato con 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 (l’uno in posizione 1 e le altre 9 posizioni azzerate).
E così via.

# modifico gli array dei risultati ("ground truth") in modo siano in formato 'one hot encode'
# quindi i valori interi corrispondenti alla classe della cifra (0, 1, 2, ..., 9) vengono
# codificati in stringhe posizionali di 0 ed 1
# esempi:
# 0 --> 1,0,0,0,0,0,0,0,0,0
# 1 --> 0,1,0,0,0,0,0,0,0,0
# 2 --> 0,0,1,0,0,0,0,0,0,0
# ....
# 9 --> 0,0,0,0,0,0,0,0,0,1
# in questo modo è più semplice ottenere un risultato significativo dalla rete neurale, in quanto
# ogni cifra posizionale corrisponderà ad un neurone dello strato di output che si attiverà o meno
# a seconda del risultato della classificazione della rete neurale
# in 'shape[0]' continuerà ad essere contenuto il numero di cifre del dataset
# in 'shape[1]' ci sarà invece il numero di cifre posizionali, corrispondente al numero di classi possibili
y_train = np_utils.to_categorical(y_train)
y_test = np_utils.to_categorical(y_test)
num_classi = y_train.shape[1]

Ora andiamo a creare la rete neurale vera e propria. Come abbiamo detto useremo Keras che ci consente di fare a meno della definizione dei tensori e quant’altro.
Con Keras esistono vari modi per definire una rete neurale.
In internet potrete trovare esempi che utilizzano la classe Sequential o altro ancora.
Io userò la notazione seguente.

# definisco un modello di rete neurale
# come dimensione del layer di ingresso uso quelle del train set (esclusa la dimensione iniziale del numero di cifre del dataset)
input_layer = Input(shape=X_train.shape[1:], name="input_layer")
inner_layer = Dense(num_pixel, name="inner_layer")(input_layer)
output_layer = Dense(num_classi, activation="softmax", name="output_layer")(inner_layer)
 
model = Model(inputs=[input_layer], outputs=[output_layer])
model.summary()

Analizziamo il codice.

Per prima cosa creo un layer di input (non contiene neuroni, ma serve solo a definire le dimensioni del nostro ingresso).
Come si vede la classe Input richiede lo shape di ingresso, che corrisponderà ai 784 pixel. Ho utilizzato la dicitura numpy [1:](chiamata slicing) con la quale indico di prendere gli elementi dall’indice 1 in poi (la proprietà shape è a sua volta un array Numpy). Visto che il numero di esempi del dataset è contenuto nell’elemento di shape ad indice 0, significa che lo sto escludendo.

Una volta definito il layer di input, vado a definire il layer interno con la classe Dense. Questa crea un layer di neuroni, ognuno dei quali connesso ad ogni uscita del layer precedente (in questo caso del layer di input). Il numero di neuroni in questo caso l’ho mantenuto uguale al numero di pixel (potevo usare un qualsiasi altro numero).
Vengono così definiti 784 x 784 pesi (= numero di neuroni del layer moltiplicato il numero di ingressi) a cui si aggiungono ulteriori 784 bias (uno per ogni neurone), per un numero complessivo di 615.440 parametri da calcolare.
E siamo solo al primo layer…

Definisco un layer di output. In un classificatore, questo tipo di layer è, in generale, molto simile a quello definito da me: il numero di neuroni equivale al numero di classi da gestire, inoltre si utilizza la funzione di attivazione Softmax che andrò a spiegare tra poco. Notate che nel layer precedente non avevo definito quale funzione di attivazione utilizzare. Leggendo la documentazione Keras si evince che in questo caso viene utilizzata la funzione di attivazione linear che equivale a a(x) = x.
Il numero di parametri da calcolare per questo layer sarà dato ancora una volta dal numero di ingressi (sono le 784 uscite del layer precedente) moltiplicato per il numero di neuroni (in questo caso 10) a cui si aggiungono i bias di ogni neurone: si ottiene quindi (784 x 10) + 10 = 7.850 parametri.

Una volta definita la struttura interna della rete neurale, creo il modello che poi utilizzeremo per addestrare e valutare i risultati. Per farlo, in questo caso, utilizzo la classe Model alla quale passo l’insieme degli input e degli output (nel nostro esempio abbiamo un solo layer di input e di output).

Prima di proseguire riprendiamo un attimo il discorso della funzione Softmax. Tale funzione è molto utilizzata in statistica e, per farla breve, consente di gestire un vettore di uscita normalizzato di n elementi, dove ogni elemento può valere da 0 ad 1 e la somma di tutti gli elementi è pari ad 1. In sostanza il nostro vettore di uscita sarà in una forma simile a quella one hot che abbiamo scelto per i ground truth. Non solo, ogni posizione corrisponderà in sostanza alla probabilità (normalizzata) che l’immagine appartenga a quella specifica classe. La somma di tutte le probabilità sarà 1 = 100%.

Tornando al codice sorgente, la funzione summary riporta a video una descrizione del modello.

Layer (type)                 Output Shape              Param #   
=================================================================
input_layer (InputLayer) [(None, 784)] 0
_________________________________________________________________
inner_layer (Dense) (None, 784) 615440
_________________________________________________________________
output_layer (Dense) (None, 10) 7850
=================================================================
Total params: 623,290
Trainable params: 623,290
Non-trainable params: 0

Osserviamo che il numero di parametri di ogni layer equivale a quello che abbiamo calcolato in precedenza. L’intero modello contiene 623.290 parametri addestrabili, cioè modificabili in fase di addestramento. Per ora non parliamo dei parametri non addestrabili: nei prossimi articoli capiremo meglio cosa sono.
Questa stampa evidenzia anche lo shape di uscita di ogni layer. In particolare notiamo che mentre l’elemento ad indice 1 equivale al numero di neuroni di ogni layer, l’elemento ad indice 0 equivale sempre a None. Il motivo è che questa dimensione rappresenta il numero di esempi che vengono passati in ingresso al modello e questo valore non può essere conosciuto a design-time, ma solo a run-time e tra l’altro può variare di volta in volta.
Quindi quel None sta ad indicare che c’è una dimensione non nota a priori.

Una volta creato il modello, dobbiamo prepararlo all’addestramento assegnando una loss function, un algoritmo di ottimizzazione ed indicando eventuali metriche da generare.

# compilo il modello indicando che tipo di loss_function devo utilizzare,
# il tipo di ottimizzatore e le metriche che voglio vengano calcolate
model.compile(loss="categorical_crossentropy",
              optimizer="adam",
              metrics=["accuracy"])

Con questo codice diciamo a Keras di utilizzare una funzione categorical_crossentropy come loss function. Senza entrare nei dettagli matematici di questa funzione, basti sapere che è perfettamente adatta al nostro caso, cioè la classificazione di input in più classi. Le loss function predefinite in Keras sono a questo link, ma è anche possibile definire funzioni custom in maniera molto semplice.

Poi gli diciamo di utilizzare un algoritmo di ottimizzazione di nome ADAM. Gli algoritmi di ottimizzazione predefiniti sono a questo link. L’algoritmo ADAM consente, in generale, di trovare velocemente un minimo. In rete troverete comunque esempi dove possono essere utilizzati altri algoritmi.

Infine gli diciamo di calcolare automaticamente la metrica accuracy che ci tornerà utile poi per la valutazione del modello.

Ora avviamo l’addestramento del modello.

# addestro il modello
model.fit(X_train, y_train, 
          validation_data=(X_test, y_test),
          epochs=10, 
          batch_size=256, 
          verbose=1)

Alla funzione di addestramento fit forniamo direttamente i dataset di addestramento di ingresso e di ground truth, analogamente i dataset di valutazione, il numero di epoche di addestramento ed il batch size. In questo modo partirà la fase di addestramento che andrà ad affinare sempre di più le performance del modello.

Epoch 1/10
60000/60000 [==============================] - 1s 20us/sample - loss: 0.3799 - acc: 0.8905 - val_loss: 0.2937 - val_acc: 0.9178
Epoch 2/10
60000/60000 [==============================] - 1s 17us/sample - loss: 0.2941 - acc: 0.9179 - val_loss: 0.2812 - val_acc: 0.9220
Epoch 3/10
60000/60000 [==============================] - 1s 16us/sample - loss: 0.2801 - acc: 0.9224 - val_loss: 0.2773 - val_acc: 0.9214
Epoch 4/10
60000/60000 [==============================] - 1s 16us/sample - loss: 0.2754 - acc: 0.9233 - val_loss: 0.2895 - val_acc: 0.9154
Epoch 5/10
60000/60000 [==============================] - 1s 15us/sample - loss: 0.2731 - acc: 0.9238 - val_loss: 0.2888 - val_acc: 0.9165
Epoch 6/10
60000/60000 [==============================] - 1s 14us/sample - loss: 0.2693 - acc: 0.9253 - val_loss: 0.2751 - val_acc: 0.9256
Epoch 7/10
60000/60000 [==============================] - 1s 14us/sample - loss: 0.2635 - acc: 0.9265 - val_loss: 0.2898 - val_acc: 0.9204
Epoch 8/10
60000/60000 [==============================] - 1s 14us/sample - loss: 0.2626 - acc: 0.9273 - val_loss: 0.2802 - val_acc: 0.9219
Epoch 9/10
60000/60000 [==============================] - 1s 14us/sample - loss: 0.2607 - acc: 0.9275 - val_loss: 0.2861 - val_acc: 0.9240
Epoch 10/10
60000/60000 [==============================] - 1s 14us/sample - loss: 0.2599 - acc: 0.9271 - val_loss: 0.2866 - val_acc: 0.9195

Come si può notare dal log, un’epoca (cioè l’addestramento eseguito su un intero dataset di 60.000 immagini) viene eseguita in circa 1 secondo, usando mediamente 14 microsecondi per immagine. Questo è il risultato ottenuto sul mio portatile utilizzando la sua GPU (una modesta NVIDIA Quadro M1000M).

Sullo stesso portatile, disattivando l’accelerazione GPU, la velocità sale a circa 24 microsecondi per immagine. Può non sembrare una differenza elevata, ma stiamo parlando di immagini 28×28 pixel e di una scheda grafica di minima potenza. Con le dovute proporzioni, andando ad elaborare immagini di dimensioni normali (si pensi ad una fotografia), la differenza tra usare o non usare una potente GPU può significare addestrare la rete neurale in ore piuttosto che in giorni.

Una volta che il modello è stato addestrato, possiamo usarlo per una valutazione delle performance.

# valuto il modello
valutazioni = model.evaluate(X_test, y_test, verbose=1)
print("Errore del modello: {:.2f}%".format(100 - valutazioni[1] * 100))

Alla funzione evaluate passiamo i dataset di test di input e di ground truth (sono 10.000 immagini).
Il risultato è un array con le metriche calcolate sul quel dataset. In particolare l’elemento ad indice 1 di questo array è la accuracy che avevamo richiesto in precedenza (l’indice 0 è sempre la loss).
Da questa calcoliamo e visualizziamo il suo complemento, cioè l’errore, perchè le altre metriche sono già visualizzate nel log.

Il risultato dovrebbe essere simile a questo:

10000/10000 [==============================] - 0s 34us/sample - loss: 0.2862 - acc: 0.9203
Errore del modello: 7.97%

Quindi con una rete neurale minima come quella indicata otteniamo un errore del 7,97% (quindi una accuracy del 92,03%).

Se ricordate l’articolo della classificazione con regressione logistica, riuscimmo ad ottenere una accuracy dell’83,06% e successivamente del 90,53% applicando la regolarizzazione L2.
Noi, utilizzando una rete neurale molto semplice e senza tanti fronzoli, abbiamo già superato l’accuracy di quella regressione logistica, abbastanza raffinata.
Un risultato interessante.

Possiamo fare di più? Certo! E lo vedremo nel prossimo articolo.

See you soon!

Come al solito vi invito a sperimentare, eventualmente modificando il numero di layer o il numero di neuroni nei layer nascosti, usando algoritmi di ottimizzazione diversi, eccetera.
Ci vediamo alla prossima!

Bye!

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