X
    Categories: blog

Reti convoluzionali

Continuiamo con la computer vision, affinando la rete neurale di classificazione delle immagini di cifre decimali scritte a mano.
Nell’articolo precedente abbiamo creato una rete neurale molto semplice che comunque ci ha permesso di ottenere una accuracy del 92,03%.

Analizzando la rete però notiamo subito un grosso inconveniente: il primo layer collega ogni suo neurone ad ogni singolo pixel dell’immagine in ingresso. Già solo con una immagine di 28×28 pixel a livelli di grigi (quindi con un unico canale di colore) si ottengono più di 600.000 parametri al primo livello. 

Con una immagine 1.024×768 (dimensioni normali per una fotografia con qualità sufficiente), considerando di mantenere al primo layer un neurone per pixel (1.024×768 = 786.432 pixel), avremmo 618.476.077.056 parametri a cui poi andrebbero aggiunti 8.650.752 parametri per il secondo layer, per un totale di 618.484.727.808 parametri complessivi per una immagine a livelli di grigio. Mentre per una immagine a colori RGB dovremmo moltiplicare questo numero per 3 ottenendo 1.855.454.183.424 di parametri (si avete letto bene: più di 1.800 MILIARDI). Impossibile elaborare questa rete su un PC.

Come se non bastasse si è dovuto trasformare l’immagine, solitamente di forma quadrata o rettangolare, in un vettore monodimensionale. Questo tende a eliminare eventuali informazioni aggiuntive derivanti dalla contiguità o prossimità dei pixel, informazioni che possono tornare molto utili nella fase di apprendimento e che quindi non potremo sfruttare.

Eppure esistono classificatori di immagini ad alta risoluzione ed alcuni di essi riescono a lavorare anche in realtime.
Dunque, come possiamo migliorare le prestazioni di questo classificatore senza comprare un supercomputer quantistico?

Introduciamo le convoluzioni.

La convoluzione

Wikipedia: In matematica, in particolare nell’analisi funzionale, la convoluzione è un’operazione tra due funzioni di una variabile che consiste nell’integrare il prodotto tra la prima e la seconda traslata di un certo valore.

In questo articolo non approfondiremo la definizione matematica di convoluzione e il suo utilizzo nella teoria dei segnali (in rete troverete tantissimo materiale). Ai fini del nostro esercizio basti sapere che una convoluzione applicata ad una immagine equivale ad applicare un filtro a quella immagine. Vediamo un esempio pratico.

La nostra convoluzione è definita, come al solito, da una matrice chiamata kernel e, come abbiamo detto, rappresenta un filtro. Tale matrice è in generale di dimensioni minori rispetto all’immagine e viene applicata traslando il kernel su di essa, come indicato nella animazione a fianco.

Il kernel in questo caso è la matrice 3 x 3 grigia che si sposta sulla immagine 5 x 5 blu (notate un bordo bianco attorno ad essa).

Il risultato è la matrice 5 x 5 verde, detta feature map.

Il bordo bianco attorno all’immagine (chiamato padding) serve ad ottenere in uscita una matrice 5 x 5.
Senza di esso il risultato sarebbe una matrice 3 x 3. Solitamente si utilizza per evitare che i bordi della immagine tendano a “sparire” nei layer successivi, ma si tratta di un parametro personalizzabile e quindi adattabile ad ogni situazione.

Il risultato del filtro convoluzionale dipende ovviamente dal contenuto numerico della matrice del kernel. Nelle figure seguenti sono riportati alcuni esempi.



Esempio di kernel che evidenzia i contorni di una immagine.



Esempio di kernel che produce un effetto embossed.






Esempi di kernel che producono un effetto blur e uno spostamento a sinistra di un pixel.





In pratica, quindi, attraverso i kernel convoluzionali possiamo ottenere una infinità di filtri i quali possono evidenziare alcune caratteristiche dell’immagine e nasconderne altre (ad esempio un kernel può evidenziare i contorni orizzontali, un altro quelli verticali, un terzo evidenziare solo le parti circolari, e così via).

Un’altra caratteristica importante è che ogni kernel porta con sè un numero di parametri pari al numero di elementi del kernel (più un bias che vale per l’intero kernel).
Quindi, negli esempi sopra, a prescindere dalle dimensioni dell’immagine originale (può essere anche un’immagine da diversi Mega-pixel…) il numero di parametri per quel singolo filtro è dato da:

In una immagine RGB ogni kernel viene ripetuto per ogni canale colore, mentre il bias resta in comune a tutto il blocco. Correggiamo quindi la formula di calcolo e vediamo quanti parametri avremmo per una immagine RGB:

Nella definizione di un layer convoluzionale i parametri in gioco sono i seguenti:

  • kernel size: rappresenta le dimensioni della matrice del kernel di convoluzione. Nel nostro esempio varrebbe 3 x 3.
  • padding: rappresenta le dimensioni del bordo esterno applicato alla immagine di input. E’ il bordo bianco nell’esempio sopra, nel nostro caso quindi varrebbe 1 x 1 (1 pixel per i bordi orizzontali ed 1 pixel per i bordi verticali).
  • stride: rappresenta la velocità di traslazione del kernel sull’immagine, misurata in pixel orizzontali e verticali. Nel nostro esempio il kernel si sposta da sinistra a destra di 1 pixel alla volta e da sopra a sotto di un pixel alla volta, quindi lo stride in questo caso varrebbe 1 x 1.
  • numero di filtri: un layer convoluzionale gestisce un certo numero di kernel in contemporanea che vengono applicati alla stessa immagine in input, in modo da ottenere subito tanti filtri diversi. Nel nostro caso di esempio abbiamo un solo kernel, quindi il numero varrebbe 1.

Generalizziamo ulteriormente la formula di calcolo, che diventa:

Il pooling

Solitamente dopo un layer convoluzionale si utilizza un layer di pooling. Il motivo è semplice: i filtri del layer convoluzionale forniscono informazioni più dense e pure, perchè evidenziano alcune caratteristiche eliminandone altre che costituirebbero rumore. Queste informazioni sono quindi più facili da elaborare e non è necessario portarsi dietro tutta la pesantezza data da una immagine di grandi dimensioni. Il pooling serve proprio a questo: diminuire le dimensioni dell’immagine in input, mantenendo le caratteristiche principali della stessa.

Il funzionamento è molto semplice. Nella animazione a fianco è riportato il funzionamento di un MAX Pooling.
In questo caso si scansiona l’immagine in input (la matrice bianca 5 x 5 a sinistra) con una matrice delle dimensioni del pooling (nell’esempio è la matrice gialla 2 x 2 che scorre sull’immagine originale). Di questa sotto-matrice si prende il valore massimo (da cui max pooling) e lo si usa per costruire la matrice di uscita (la matrice bianca 3 x 3 a destra).

Oltre al max pooling esiste anche l’average pooling che funziona in maniera del tutto simile, ma invece di selezionare il massimo valore della sotto-matrice, calcola il valore medio di tutti i suoi elementi.

Il risultato finale NON E’ un semplice ridimensionamento dell’immagine, perchè viene mantenuta l’informazione massima o media di ogni sotto-matrice.
Si veda l’immagine seguente per rendersi conto del risultato ottenuto.

Anche per il layer di pooling i parametri sono simili a quelli del layer convoluzionale, in particolare per le dimensioni del pool, il padding e lo stride. Nell’immagine qui sopra vengono utilizzati un max ed un average pooling con dimensioni di pool 2 x 2, padding 0 x 0 e stride 2 x 2.

Le reti convoluzionali

In generale le reti convoluzionali sono formate da una sequenza di layer convoluzionali e di pooling, seguita da una rete di classificazione più classica simile a quella che abbiamo definito nell’articolo precedente.

Nell’immagine qui sopra si vede come le convoluzioni creano una serie di feature maps alle quali poi vengono applicate dei pooling (o subsampling). Questo in cascata per diverse volte fino ad arrivare agli ultimi layer fully connected rappresentati dai layer Dense di Keras, i quali eseguono la classificazione finale delle informazioni.

La struttura a pila delle convoluzioni consente di distillare ed astrarre sempre più le informazioni.

Facciamo l’esempio di una rete convoluzionale utilizzata per la face detection.

Partendo da una immagine (insieme di pixel) gli strati convoluzionali più bassi della rete (quelli più vicino all’input) estrapolano informazioni molto elementari come i contorni, le transazioni di luminosità e colore, il livello di contrasto e di nitidezza e così via.

Risalendo la pila, gli strati superiori cominciano ad astrarre le informazioni degli strati precedenti combinandole in vario modo e consentendo di riconoscere parti di immagine (occhi, orecchie, bocca, ecc…)

Gli strati ancora più in alto astraggono ulteriormente le informazioni ricevute dagli strati inferiori, consentendo di discriminare tra un volto umano ed un vaso, ma anche (se la rete è sufficientemente raffinata) di distinguere tra volto caucasico, asiatico, africano, tra biondi e castani, tra uomo e donna e così via.

L’astrazione può raggiungere livelli tali da riconoscere una classe anche se nell’immagine di partenza questa è ruotata o in parte celata, una cosa impensabile o comunque estremamente complesso da realizzare con gli algoritmi classici.

Tutto questo, tra l’altro, fatto in maniera automatica, fornendo semplicemente degli esempi in fase di addestramento.

Codice Python

NOTA: il codice di questo esempio lo trovi qui

Modifichiamo il codice dell’articolo precedente, introducendo una rete neurale convoluzionale e valutiamone l’accuracy.

Questa volta non trasformeremo i dataset di input in vettori monodimensionali, ma dobbiamo comunque assicurarci che ogni singola immagine del dataset arrivi al modello con lo shape corretto per la convoluzione.
In questo caso lo shape di ogni immagine sarà 28x28x1 (altezza x larghezza x numero di canali colore).

NOTA: stiamo usando un backend Tensorflow, quindi lo shape giusto richiede che il canale colore appaia per ultimo. Altri back-end, come Theano, lavorano con uno shape diverso dove il canale colore viene prima delle dimensioni di altezza e larghezza!

# modifico le matrici di pixel in modo da ottenere una matrice di pixels monocromatici
# usando Tensorflow, il canale del colore è l'ultimo dopo le dimensioni (ncifre, dimy, dimx, ncanalicolore)
# con altri backend (ad esempio Theano) il canale va prima delle dimensioni (ncifre, ncanalicolore, dimy, dimx)
X_train = X_train.reshape(X_train.shape[0], 28, 28, 1).astype('float32')
X_test = X_test.reshape(X_test.shape[0], 28, 28, 1).astype('float32')

Dobbiamo anche modificare la nostra rete neurale in modo da utilizzare le convoluzioni ed i pooling.

# definisco un modello di rete neurale convoluzionale
# 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 = Conv2D(32, (5, 5), activation="relu", name="conv_layer")(input_layer)
inner_layer = MaxPooling2D(pool_size=(2, 2), name="maxpool_layer")(inner_layer)
inner_layer = Dropout(rate=0.2, name="drop_layer")(inner_layer)
inner_layer = Flatten(name="flatten_layer")(inner_layer)
inner_layer = Dense(128, activation="relu", name="dense_layer")(inner_layer)
output_layer = Dense(num_classi, activation="softmax", name="output_layer")(inner_layer)

Analizziamo il codice.

Come la volta scorsa, comincio creando il layer di input. Utilizzanzo lo slicing dello shape posso mantenere la stessa definizione della volta precedente.

Aggiungo un layer convoluzionale tramite la classe Conv2D con 32 kernel di dimensione 5×5. Non sono indicati il padding e lo stride, quindi vengono utilizzati i valori di default che, da documentazione Keras, sono un padding valid (che equivale a 0x0, cioè mancanza di padding) e uno stride 1×1. In Keras il padding può valere validsame, dove valid come abbiamo detto significa assenza di padding, mentre same chiede a Keras di calcolare il padding necessario ad ottenere in uscita una feature map di dimensioni uguali all’immagine in ingresso. Per questo layer convoluzionale utilizzerò una funzione di attivazione relu che, come visto negli articoli precedenti, va ad escludere i valori negativi.

Successivamente creo un layer di max pooling con la classe MaxPooling2D, usando un pool size di 2×2. Anche in questo caso uso i valori di default di stride e padding che in questo caso valgono rispettivamente None (che equivale ad usare uno stride di dimensioni pari al pool size) e valid (quindi niente padding).

Continuo aggiungendo un layer Dropout. Questo layer è solitamente utilizzato per migliorare la generalizzazione della rete.
Il suo funzionamento è il seguente: in fase di addestramento “spegne” in modo casuale un numero di connessioni pari alla percentuale indicata con il parametro rate (in questo caso il 20%). In fase di inferenza invece il layer di dropout si disattiva e tutte le connessioni restano attive.
Questo comportamento fa sì che i layer seguenti debbano accontentarsi di un minor numero di informazioni, venendo così stimolati a trovare soluzioni più robuste.

Successivamente utilizzo un layer Flatten che non fa altro che prendere tutti i dati che gli arrivano e metterli in fila in un unico array monodimensionale.

Da qui in poi, come la volta scorsa, uso due layer Dense, il primo con 128 neuroni che fa da intermediario tra l’elevato numero di informazioni che arrivano dal layer flatten ed il successivo layer che va a gestire la vera e propria classificazione.

Analizziamo la summary.

Layer (type)                 Output Shape              Param #   
=================================================================
input_layer (InputLayer)     [(None, 28, 28, 1)]       0         
_________________________________________________________________
conv_layer (Conv2D)          (None, 24, 24, 32)        832       
_________________________________________________________________
maxpool_layer (MaxPooling2D) (None, 12, 12, 32)        0         
_________________________________________________________________
drop_layer (Dropout)         (None, 12, 12, 32)        0         
_________________________________________________________________
flatten_layer (Flatten)      (None, 4608)              0         
_________________________________________________________________
dense_layer (Dense)          (None, 128)               589952    
_________________________________________________________________
output_layer (Dense)         (None, 10)                1290      
=================================================================
Total params: 592,074
Trainable params: 592,074
Non-trainable params: 0

Innanzitutto vediamo che il primo layer convoluzionale riesce a gestire l’intera immagine con soli 832 parametri (verificate con la formula di calcolo definita più sopra).

Poi vediamo che il layer di pooling, quello di dropout e quello di flatten non contribuiscono al numero di parametri. In effetti eseguono solo trasformazioni dirette e predefinite sulle informazioni che ricevono in input.

Infine il grosso dei parametri è, per loro natura, dovuto ai layer densi finali.

Il totale è di 592.074 parametri, che risulta essere inferiore a quelli della rete dell’articolo scorso (erano 623.290).

Addestriamola e valutiamola.

10000/10000 [==============================] - 1s 62us/sample - loss: 0.0335 - acc: 0.9885
Errore del modello: 1.15%

Con questo modello otteniamo un 98.85% di accuracy. Risultato molto interessante, considerando che abbiamo meno parametri di prima, quindi sulla carta la rete è meno complessa della precedente.

Possiamo fare ancora meglio

Prima abbiamo detto che in generale una rete convoluzionale è formata da una pila di convoluzioni e pooling. Verifichiamo se così facendo riusciamo a migliorarci ulteriormente.

NOTA: il codice di questo esempio lo trovi qui

Modifichiamo la nostra rete.

# definisco un modello di rete neurale convoluzionale
# 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 = Conv2D(30, (5, 5), activation="relu", name="conv_layer_1")(input_layer)
inner_layer = MaxPooling2D(pool_size=(2, 2), name="maxpool_layer_1")(inner_layer)
inner_layer = Conv2D(15, (3, 3), activation="relu", name="conv_layer_2")(inner_layer)
inner_layer = MaxPooling2D(pool_size=(2, 2), name="maxpool_layer_2")(inner_layer)
inner_layer = Dropout(rate=0.2, name="drop_layer")(inner_layer)
inner_layer = Flatten(name="flatten_layer")(inner_layer)
inner_layer = Dense(128, activation="relu", name="dense_layer_1")(inner_layer)
inner_layer = Dense(50, activation="relu", name="dense_layer_2")(inner_layer)
output_layer = Dense(num_classi, activation="softmax", name="output_layer")(inner_layer)

Questa volta usiamo una prima convoluzione da 30 filtri e kernel 5×5, un maxpooling, poi una seconda convoluzione da 15 filtri con kernel 3×3 seguita da un ultimo maxpooling. Il resto della rete è rimasta uguale.

Analizziamo la summary

Layer (type)                 Output Shape              Param #   
=================================================================
input_layer (InputLayer)     [(None, 28, 28, 1)]       0         
_________________________________________________________________
conv_layer_1 (Conv2D)        (None, 24, 24, 30)        780       
_________________________________________________________________
maxpool_layer_1 (MaxPooling2 (None, 12, 12, 30)        0         
_________________________________________________________________
conv_layer_2 (Conv2D)        (None, 10, 10, 15)        4065      
_________________________________________________________________
maxpool_layer_2 (MaxPooling2 (None, 5, 5, 15)          0         
_________________________________________________________________
drop_layer (Dropout)         (None, 5, 5, 15)          0         
_________________________________________________________________
flatten_layer (Flatten)      (None, 375)               0         
_________________________________________________________________
dense_layer_1 (Dense)        (None, 128)               48128     
_________________________________________________________________
dense_layer_2 (Dense)        (None, 50)                6450      
_________________________________________________________________
output_layer (Dense)         (None, 10)                510       
=================================================================
Total params: 59,933
Trainable params: 59,933
Non-trainable params: 0

Come noterete il layer conv_layer_2 è accreditato di 4.065 parametri. Verificando con la solita formula, il risultato torna, ma facendo attenzione: in questo caso lo strato convoluzionale riceve in ingresso una matrice 12x12x30 (che è la dimensione di uscita del layer precedente maxpool_layer_1).
Quindi è come se ricevesse una immagine di dimensioni 12×12 pixel con 30 canali colore.
Ricordando la formula di calcolo:

abbiamo quindi:

Tornando alla summary, il risultato complessivo è incredibile: i due strati di convoluzione e pooling hanno abbattuto il numero di parametri complessivi a 59.933, meno di un decimo dei parametri della rete densa originale.

Addestriamo e valutiamo.

10000/10000 [==============================] - 1s 74us/sample - loss: 0.0269 - acc: 0.9915
Errore del modello: 0.85%

Nonostante i pochissimi parametri, il risultato di questa rete è clamoroso: 99,15% di accuracy. Significa che su 10.000 immagini di test, solo 85 sono state classificate in modo sbagliato!

Performance

L’ultimo modello di rete neurale, nonostante abbia un numero di parametri di molto inferiore al modello originale, risulta essere molto più lento in fase di addestramento.
Riporto una riga di log di addestramento eseguito SENZA l’uso di accelerazione di GPU.

Epoch 7/10
60000/60000 [==============================] - 9s 158us/sample - loss: 0.0386 - acc: 0.9875 - val_loss: 0.0258 - val_acc: 0.9920

L’intera epoca di 60.000 immagini viene eseguita in circa 9 secondi e ogni singola immagine viene elaborata in 158 microsecondi.
Con il modello originale eravamo, senza GPU, nell’ordine di 1 secondo e 24 microsecondi.

Come è possibile, visto che il numero di parametri è molto più piccolo?
Il motivo è che la convoluzione ed il pooling vengono propagati sull’intera immagine di input come una finestra scorrevole. Questo per ogni filtro.
Nel nostro modello abbiamo inoltre due layer convoluzionali e due di pooling, quindi i layer superiori devono scorrere ogni loro kernel su ogni feature map dei layer inferiori.
In sostanza, ci sono molti calcoli da fare, ma ci si porta dietro un numero di parametri complessivo minore.
Inoltre questo costo di elaborazione aggiuntivo è ben ripagato dall’aumento di accuracy e dal fatto di poter gestire immagini di dimensioni maggiori senza fare esplodere il numero di parametri.

Per curiosità osserviamo il log di addestramento dello stesso modello utilizzando l’accelerazione GPU.

Epoch 7/10
60000/60000 [==============================] - 4s 60us/sample - loss: 0.0392 - acc: 0.9876 - val_loss: 0.0235 - val_acc: 0.9924

Abbiamo più che dimezzato i tempi, con una modesta scheda Quadro M1000M (quella in dotazione al mio portatile di lavoro).

See you soon!

Come sempre sperimentate nuove reti aggiungendo layer, modificando i parametri, usando average pool, e così via.
Noi 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