Elaborato Cennamo Emanuele N46001451

Transcript

Elaborato Cennamo Emanuele N46001451
Scuola Politecnica e delle Scienze di Base
Corso di Laurea in Ingegneria Informatica
Elaborato finale in Teoria dei Segnali
Riconoscimento di Cifre mediante Reti
Convoluzionali
Anno Accademico 2016/2017
Candidato:
Emanuele Cennamo
matr. N46001451
Dedico questo mio lavoro, che
si pone a conclusione di un
lungo e faticoso cammino, alle
persone che mi hanno
sostenuto ed incoraggiato in
questi anni: mamma e papà.
Indice
Indice .................................................................................................................................................. III
Introduzione ......................................................................................................................................... 4
Capitolo 1: Neuroni e Reti Neurali ...................................................................................................... 6
1.1 Il Percettrone di Rosenblatt ........................................................................................................ 7
1.2 Neurone Sigma ........................................................................................................................... 9
1.3 Architettura di una Rete Neurale ............................................................................................. 10
Capitolo 2: Riconoscimento di cifre manoscritte tramite MLP ......................................................... 11
2.1 La discesa del gradiente ........................................................................................................... 12
2.2 Backpropagation ...................................................................................................................... 16
2.3 Test della rete ........................................................................................................................... 20
Capitolo 3: Migliorare le prestazioni di una Rete Neurale ................................................................ 22
3.1 The Cross-Entropy cost function ............................................................................................. 22
3.2 Metodologie per la Regolarizzazione ...................................................................................... 26
3.3 Scelta degli Hyper-parameters ................................................................................................. 32
Capitolo 4: Reti Neurali Convoluzionali ........................................................................................... 34
4.1 Idee alla base delle ConvNet .................................................................................................... 34
4.2 Schema di una ConvNet ........................................................................................................... 36
4.3 Test della rete ........................................................................................................................... 37
Conclusioni ........................................................................................................................................ 40
Bibliografia ........................................................................................................................................ 41
Introduzione
Questo lavoro di tesi si occupa del problema del riconoscimento di cifre scritte a mano e
studia soluzioni basate sull’uso di reti neurali artificiali, ed in particolare reti neurali
convoluzionali. Per comprendere il problema, consideriamo un esempio: nella figura
sottostante notiamo una sequenza numerica di cifre scritte, evidentemente, a mano;
chiunque abbia almeno un titolo elementare sarà in grado di affermare, con una certa
semplicità, che la sequenza corrisponde a “5-0-4-1-9-2”.
Cosa è accaduto? Perché siamo in grado di riconoscere questa sequenza? Beh, potremmo
rispondere a questa domanda affermando che fin da una giovane età, abbiamo visto,
volontariamente o involontariamente, diverse volte scrivere uno stesso numero utilizzando
calligrafie diverse e nelle prime fasi di apprendimento, quando ovviamente la nostra
conoscenza era ancora insufficiente, ogni volta che compivamo un errore questo ci veniva
corretto, facendo sì che la nostra capacità di riconoscerli aumentasse gradualmente.
Tuttavia, questa operazione non è semplice come sembra: infatti, per arrivare alla soluzione,
abbiamo impiegato (inconsciamente) circa 140 milioni di neuroni appartenenti alla corteccia
visiva del nostro cervello, tutti opportunamente collegati da miliardi di connessioni per
permettere la cooperazione. Messa in questi termini è comprensibile uno scoraggiamento
inziale se si desidera realizzare un programma per una macchina in grado di emulare il
comportamento del nostro cervello. Una prima idea potrebbe essere quella di implementare
una soluzione in grado di riconoscere direttamente le forme delle nostre cifre, ad esempio:
4
consideriamo un immagine raffigurante un “9”, questa presenta un anello nella parte
superiore e una barra verticale in basso a destra rispetto all’anello stesso; la difficoltà tuttavia
si presenta durante la fase di traduzione in un algoritmo, questo perché, essendo una cifra
scritta a mano, dovremmo andare a valutare una quantità talmente grande di eccezioni da
rendere la nostra idea impraticabile. Questo ci fa capire che l’approccio è completamente
sbagliato! Dobbiamo quindi ricercare tecniche alternative per implementare un programma
in grado di adattarsi alle diverse eccezioni senza l’intervento diretto di un programmatore,
che sia in grado di imparare da una serie di esempi e sfruttare questa conoscenza per operare.
Scopo del seguente elaborato sarà proprio quello di introdurre tali tecniche ed utilizzarle per
risolvere il nostro problema di riconoscimento di cifre scritte a mano; organizzato in 4
capitoli, verranno prima esposti i principi fondamentali alla base delle reti neurali, come
esse sono organizzate e gli elementi che la compongono; poi, nel capitolo 2, mostreremo
come risolvere il problema di riconoscimento tramite semplici Reti neurali multistrato, i
Multilayer Perceptrons (MLP); il capitolo 3, invece, sarà dedicato all’introduzione di
diverse metodologie per migliorare il comportamento di una rete neurale; ed infine, il
capitolo 4, sarà dedicato alle Reti Neurali Convoluzionali, verranno prima introdotte e poi
utilizzate per il problema di riconoscimento di cifre manoscritte.
Desidero ringraziare sinceramente il Professore Poggi Giovanni, per il materiale e le
indicazioni fornite durante la stesura del seguente elaborato.
5
Capitolo 1: Neuroni e Reti Neurali
Prima di addentraci nel capitolo richiamiamo la definizione di “Hecht-Nielsen”: Una Rete
Neurale può essere identificata come una struttura parallela in grado di elaborare
informazioni distribuite; il tutto si basa sul paradigma “connessionista”, ovvero, utilizzare
tanti elementi di elaborazione (neuroni) in grado di svolgere un compito elementare, e
combinare opportunamente i risultati ottenuti per risolvere un compito complesso. I Neuroni
hanno una propria memoria locale, sono in grado di elaborare localmente informazioni
ricevute in ingresso e restituire un’uscita ottenuta con la combinazione dei valori di ingresso
e del contenuto della memoria locale; la comunicazione tra esse avviene tramite una serie
di canali unidirezionali. Dalla figura (1), notiamo, che i
neuroni ricevono in ingresso, tramite i canali, un vettore e
restituiscono in uscita un valore che viene copiato e
distribuito o ad altri neuroni o in uscita, perché soluzione di
un problema. L’uscita complessiva, come per l’ingresso,
Fig. 1: Esempio di Rete Neurale
sarà rappresentata da un vettore, la cui dimensione sarà
indipendente da quella del vettore di input. In base alla tipologia di connessioni tra i neuroni
possiamo fare una suddivisione delle nostre reti neurali: Reti feed-forward e Reti ricorrenti.
Alla prima appartengono tutte le reti caratterizzate da un unico flusso unidirezionale di dati,
ovvero prive di cicli; il valore in uscita da ciascun neurone verrà consegnato solo ed
esclusivamente a neuroni appartenenti allo strato successivo. Alla seconda categoria, invece,
appartengono tutte quelle reti nelle quali i propri neuroni saranno in grado di inviare il
proprio risultato anche a elementi appartenenti al medesimo strato (connessioni laterali), a
elementi appartenenti a strati precedenti, o in alcuni casi particolari, riproporre l’uscita in
6
ingresso al neurone stesso che l’ha prodotta. Seguendo un approccio top-down, abbiamo
analizzato in generale le reti neurali, adesso quindi ci tocca fare una analisi della struttura di
un generico neurone: L’insieme [x1, x2, …, xn] rappresenta il vettore del
segnale di ingresso; il neurone realizzerà a partire da questi, insieme ai
dati memorizzati nella memoria locale, un’uscita “y” utilizzando
opportunamente una “funzione di trasferimento”; questa può essere sia
semplice, come ad esempio eseguire un semplice prodotto scalare, sia
complessa per permettere ai neuroni la possibilità di risolvere problemi
Fig. 2: Neurone
maggiormente complicati. La memoria locale, rappresentata in figura (2)
da un rettangolino, conterrà una specie di conoscenza maturata durante la fase di
addestramento, solo in questa sarà possibile alterare i contenuti, in modalità operativa,
infatti, la memoria locale sarà a sola lettura.
1.1 Il Percettrone di Rosenblatt
Il primo neurone in grado di implementare una vera e propria regola di apprendimento, fu
il percettrone; introdotto da Frank Ronsenblatt nel ’58 fu utilizzato nei lavori scientifici per
oltre 10 anni, tuttavia con l’avanzare della tecnologia, divennero sempre più evidente le
limitazioni di cui soffriva permettendogli di risolvere solo problemi
semplici, per questo motivo fu abbandonato a favore di elementi più
complessi. In figura (3) abbiamo lo schema del nostro percettrone: riceve
in ingresso un vettore di valori binari e restituisce un’uscita
esclusivamente binaria; l’insieme [w0, w1, …, wn] rappresenta il vettore
Fig. 3: Percettrone
dei pesi: numeri reali, che andranno ad indicare una sorta di importanza
dell’ingresso associato; i pesi insieme a quella che tra poco definiremo “bias”, rappresentano
la memoria locale. La funzione di trasferimento è definita come un prodotto scalare tra il
vettore dei pesi e il vettore degli ingressi, l’uscita sarà quindi:
1,
𝑠𝑒 ∑ 𝑤𝑖 𝑥𝑖 > threshold
𝑖
𝑜𝑢𝑡𝑝𝑢𝑡 = 𝑦 =
0,
{
𝑠𝑒 ∑ 𝑤𝑖 𝑥𝑖 ≤ threshold
𝑖
Quindi nel momento in cui il percettrone dovrà esprimere una “decisione” questa verrà
7
influenzata, ovviamente, dagli ingressi e dalla loro importanza. Per fissare il concetto
consideriamo un esempio: immaginiamo di voler realizzare un neurone che decida per noi
se è il caso o meno di avventurarci verso il cinema, quali saranno gli ingressi in grado di
influenzare la nostra decisione? Sicuramente un fattore potrebbe essere il meteo, un altro
potrebbe essere la distanza ed un altro ancora il denaro a disposizione, tuttavia è ovvio che
avere pochi soldi disponibili dovrebbe avere una maggiore importanza rispetto ad altro, e
per questo entreranno in gioco i nostri pesi. Infine definiamo anche la soglia come un
parametro in grado di influenzare l’uscita, tornando all’esempio precedente potremmo
identificarla con la nostra voglia di andare al cinema: più la soglia è alta, più sarà difficile
che questa venga superata dal prodotto scalare e di conseguenza abbiamo una maggiore
probabilità che il neurone restituisca “0”. Per una maggiore comprensibilità riscriviamo la
nostra espressione in forma vettoriale definendo “w” il vettore dei pesi, “x” il vettore degli
ingressi e “b” (bias) l’opposto del nostro valore di threshold:
𝑦= {
1,
0,
se 𝐰 ∗ 𝐱 + b > 0
se 𝐰 ∗ 𝐱 + b ≤ 0
Dove * indica il prodotto scalare. Ovviamente un percettrone da solo non potrà mai emulare
il processo di decisione associato alla mente umana, quindi l’idea sarebbe quella di farli
cooperare realizzando così la già citata rete neurale. Anche se non ne abbiamo ancora
introdotto l’architettura, immaginiamo una generica rete per riconoscere una cifra scritta a
mano, e supponiamo ad esempio di inserirvi in ingresso le intensità dei pixel appartenenti
ad un’immagine raffigurante un “9”. Scopo della nostra rete, sarà quello di modificare i
propri pesi e le proprie polarizzazioni (biases) in maniera tale da essere in grado di
classificare correttamente la nostra cifra; noi vorremmo che ogni piccola variazione dei pesi
portasse solo ad una piccola variazione della nostra uscita, infatti, se questo fosse vero
saremmo in grado di far comportare la nostra rete come meglio crediamo; ad esempio
supposto che essa abbia riconosciuto la nostra cifra come un “8”, tramite una piccola serie
di alterazione dei pesi e delle polarizzazioni potremmo portare la nostra rete sempre più
vicino a classificarla come un “9”. Purtroppo, in una rete composta da percettroni, una
8
piccola alterazione può portare la nostra rete a capovolgere completamente l’uscita
restituita; quindi, supponendo che siamo anche riusciti a far classificare correttamente
l’ingresso come un “9”, potremmo allo stesso tempo aver modificato anche il
riconoscimento di tutte le altre cifre. Per questo motivo siamo costretti ad abbandonare
l’idea di utilizzare il percettrone, a favore di qualche elemento più sofisticato in grado di
soddisfare l’ipotesi sopra scritta.
1.2 Neurone Sigma
Il “Sigmoid Neuron” è una versione “migliorata” del nostro percettrone, la differenza
sostanziale riguarderà solo l’uscita, che potrà assumere un qualsiasi valore reale compreso
tra 0 ed 1. Definiamo quindi la nostra funzione di trasferimento (o funzione sigma), come:
1
𝑦 = 𝜎(𝑤 ∗ 𝑥 + 𝑏)
𝑑𝑜𝑣𝑒 𝜎(𝑧) =
1 + 𝑒 −𝑧
L’uscita del nostro neurone sarà caratterizzata dall’espressione:
1
𝑦=
1 + 𝑒 − ∑𝑖 𝑤𝑖 𝑥𝑖 − 𝑏
Possiamo sottolineare la somiglianza con il percettrone tramite un semplice esempio:
immaginiamo che “𝑧 = 𝑤 ∗ 𝑥 + 𝑏” sia un numero molto grande e maggiore di zero, avremo
che 𝑒 −𝑧 → 0 e di conseguenza 𝜎(𝑧) ≈ 1, invece,
supponendo “z” un valore molto grande in valore
assoluto ma minore di zero abbiamo che 𝑒 −𝑧 → ∞ e
quindi 𝜎(𝑧) ≈ 0, ottenendo un’approssimazione di
un’uscita binaria. A questo punto, l’ipotesi che ci
Fig. 4: Funzione Sigma
aveva portato alla bocciatura del percettrone, verrà soddisfatta dal nostro neurone sigma?
La risposta è evidentemente affermativa, ed è dovuta al fatto che l’uscita potrà assumere
infiniti valori compresi in [0,1]; la variazione di quest’ultima potrà essere vista come:
∂y
∂y
Δy ≈ ∑𝑗
Δw𝑗 + Δb
∂w𝑗
∂b
Avere un’uscita non binaria può rivelarsi utile o meno a seconda dei casi: potremmo
definirla utile se per esempio volessimo usare l’output per rappresentare la media
dell’intensità dei pixel in un’immagine, mentre risulta essere meno utile quando vogliamo
9
ad esempio indicare se un valore di output “è un 9” oppure “non è un 9”. In questo caso è
ovvio che sarebbe un lavoro più facile da fare utilizzando il percettrone, indicando con 1 o
con 0 rispettivamente “è un 9” o “non è un 9”, possiamo tuttavia ovviare a questo problema
utilizzando una banalissima approssimazione: se l’uscita sarà maggiore o uguale a 0.5 allora
verrà approssimata a 1, altrimenti 0.
1.3 Architettura di una Rete Neurale
Quando abbiamo parlato del percettrone abbiamo banalmente affermato che da solo non è
in grado di risolvere problemi più complessi, ovviamente questo vale anche per i nostri
Neuroni Sigma. Avremo bisogno di una rete composta
da molti di questi elementi. In, particolare nel seguito
consideriamo una semplice rete chiamata percettrone
multilivello (MLP) anche quando l’elemento base è il
neurone sigma. Come abbiamo già detto, le reti MLP
Fig. 5: Esempio di MLP
sono feedforward quindi, i neuroni di uno strato saranno in grado di inviare i propri output
solo agli strati direttamente successivi. Possiamo suddividere la nostra architettura in tre tipi
di livelli: “input layer”, contenente tutti i neuroni che inoltrano il vettore di ingresso allo
strato successivo; “output layer” contenente tutti i neuroni che restituiscono il vettore di
uscita; “hidden layer” uno strato di neuroni “nascosti” che non vedono né il vettore degli
ingressi né il vettore delle uscite della rete. Il numero degli input è solitamente fissato dal
problema, ad esempio poste tre variabili di ingresso avremo tre neuroni appartenenti
all’omonimo strato; oppure, in analogia con il nostro problema, posta un’immagine di
dimensione 64x64 pixel grayscale, supponendo di voler codificare le intensità dei pixel,
avremo un totale di 64 ∗ 64 = 4096 neuroni di ingresso; supponendo inoltre di voler sapere
solo se una cifra sia ad esempio un “9” o meno ci basterà utilizzare un solo neurone di uscita.
Non andrò ad introdurre esempi di architetture ricorrenti perché non necessarie per risolvere
il nostro problema del riconoscimento delle cifre, in realtà queste avrebbero un potenziale
maggiore rispetto alle feedforward tuttavia il loro utilizzo è ancora molto ridotto dati gli
algoritmi di apprendimento ancora particolarmente limitati.
10
Capitolo 2: Riconoscimento di cifre manoscritte tramite MLP
Passiamo ora a ricercare una prima soluzione per il nostro problema del riconoscimento di
cifre scritte a mano; possiamo innanzitutto suddividere questo in due sotto-problemi:
1. In primo luogo, è opportuno suddividere, o “segmentare” un’immagine contenente più
cifre in più immagini indipendenti ciascuna contenente una sola cifra; ad esempio:
2. Dopo la segmentazione potremo, procedere con la classificazione di ogni singola cifra.
Tuttavia, andremo a concentrare la nostra attenzione solo sul secondo punto perché:
decisamente più interessante e allo stesso tempo complicato. Alla fine del secondo capitolo
abbiamo detto che l’architettura a cui siamo interessati è una MLP, composta da neuroni
sigma e suddivisa in tre strati. Il primo è sicuramente lo strato di input contenente tutti i
neuroni in grado di codificare i valori dei pixel; questi non hanno un compito decisionale
ma solo quello di “smistare” il vettore di ingresso presso lo strato nascosto. Per la nostra
rete abbiamo limitato l’utilizzo a sole immagini di dimensioni 28x28 in scala di grigi (il
motivo verrà spiegato più avanti), di conseguenza ci aspetteremmo di avere un totale di 784
neuroni di input in grado di “smistare” valori
compresi tra 0 e 1; in particolare 0.0 indicherà il
bianco, 1.0 il nero e tutti i valori intermedi
rappresenteranno le diverse sfumature. Il secondo
strato è uno strato nascosto ed indicheremo con “n”
il numero di neuroni ad esso appartenenti; visto che
non esiste un metodo univoco per la scelta di tale
Fig. 6: Esempio di architettura per il nostro
problema di riconoscimento delle cifre manoscritte
numero il nostro scopo sarà quello di sperimentare la soluzione per diversi valori di “n”.
11
Infine lo strato di uscita della rete contiene 10 neuroni; se il primo avrà uscita circa uno,
allora la rete indicherà che la cifra riconosciuta è “0”, se invece sarà il secondo ad
approssimare l’unità allora la cifra riconosciuta sarà “1”, e così via. Indichiamo quindi i
neuroni con identificativi da 0 a 9, colui che presenterà il valore di attivazione più alto
rappresenterà la cifra riconosciuta.
2.1 La discesa del gradiente
Ora che abbiamo fissato l’architettura della nostra rete dobbiamo trovare un modo per
permettere a quest’ultima di imparare a riconoscere correttamente le cifre. La prima cosa
che serve è un insieme di esempi di addestramento per la nostra rete, ovvero un insieme
sufficientemente grande di immagini (ognuna delle quali rappresenterà una cifra) con la
rispettiva etichetta; lo scopo sarà quello di dare “in pasto” alla rete le immagini, lasciare che
questa le classifichi ed infine che le confronti con l’etichetta di riferimento; sarà poi la rete
stessa a modificare i propri parametri interni in base ai risultati ottenuti. La costruzione di
un adeguato dataset può essere complicata; fortunatamente ne esistono diversi già pronti
all’uso, in particolare per i nostri studi andremo ad utilizzare il “MNIST Database” che
contiene decine di migliaia di immagini di cifre scritte a mano scansionate, insieme alle
rispettive classificazioni corrette. Il nome di MNIST sta per “Modified NIST” (United
States’s National Institute of Standards and Technology); esso è composto da un totale di
60000 immagini di addestramento che compongono il training data e 10000 immagini di
test che compongono il test data. In figura (7) possiamo notare un esempio delle immagini
che sono state inserite nel nell’insieme MNIST;
queste sono tutte “grayscale” e di dimensioni 28x28
(questo spiega perché precedentemente avevamo
valutato uno strato di input di soli 784 neuroni). Il
training set servirà appunto per addestrare la nostra
rete, il test set, invece, per valutarne le prestazioni.
Fig. 7: Esempio di Cifre MNIST
Definiamo adesso il vettore 𝑥 come l’insieme degli input della rete, date immagini di
dimensione 28x28 pixel, allora la dimensione del vettore sarà di 784 elementi. Insieme agli
12
ingressi definiamo le uscite, quindi il vettore 𝑦 composto da tanti elementi quanti sono i
nostri neuroni di uscita, nel nostro caso, 10; ad esempio, per indicare che l’immagine
corrisponde ad un “6” il vettore 𝑦 sarà:
𝑦(𝑥) = (0,0,0,0,0,0,1,0,0,0)𝑇
Lo scopo della nostra rete è quello di restituire un’uscita che approssimi al meglio quella
desiderata e l’addestramento serve a perseguire tale obiettivo. A questo punto come
facciamo a sapere quanto siamo effettivamente vicini al nostro obiettivo? Per rispondere a
questa domanda, andiamo a definire la funzione di costo (Mean Squared Error), come:
1
C(w, b) ≡
∑ ∥ y(x) − a ∥2
2𝑛
𝑥
Dove, “w” rappresenta tutti i pesi della rete, “b” tutte le polarizzazioni, “n” il numero totale
degli ingressi di addestramento, “a” il vettore delle uscite della rete quando “x” è l’ingresso
e, infine, “y(x)” l’uscita desiderata. C(w, b) è non negativo, poiché ogni termine della
somma sarà maggiore o al più (nei casi ideali) uguale a zero, in più è abbastanza evidente
che minore sarà il costo, più le uscite ottenute approssimeranno quelle desiderate, e di
conseguenza migliore sarà il comportamento della nostra rete. Il numero delle immagini
classificate correttamente, tuttavia, non è una funzione regolare dei “pesi” e delle “biases”
della rete; infatti, piccole modifiche a tali parametri potrebbero non causare alcuna modifica
nel numero delle immagini correttamente classificate. È evidente che quindi questo
complica il tutto, data la difficoltà di capire come scegliere le modifiche per ottenere
prestazioni migliori; fortunatamente, sarà qui che entrerà in gioco la nostra funzione di costo
quadratico: piuttosto che valutare le modifiche in funzione del numero delle immagini
correttamente classificate, conviene valutarle in funzione della nostra C(w, b); in particolare,
un algoritmo di addestramento sarà tanto più efficiente quanto più riuscirà a minimizzare la
“cost function”. Consideriamo una generica rappresentazione di una funzione di costo C(v),
come abbiamo capito il nostro scopo sarà quello di trovare il minimo assoluto del nostro
paraboloide in modo tale che la funzione di costo raggiunga il suo valore più piccolo. Per
ottenere un punto estremante (come il minimo) possiamo tranquillamente farlo sfruttando
le proprietà delle derivate, tuttavia il problema è che le moderne reti neurali possono
comprendere miliardi di parametri, quindi ottenere direttamente il nostro minimo potrebbe
13
non essere un compito particolarmente semplice;
dobbiamo quindi considerare un algoritmo che si
comporti come un risolutore di un problema reale:
immaginiamo il nostro paraboloide come una valle,
se noi buttassimo una pallina in quest’ultima è
evidente che per le leggi fisiche questa tenderà, dopo
Fig. 8: Esempio di Cost Function
un certo intervallo di tempo, a raggiungere il fondo.
Supponiamo ora di spostare la pallina di una piccola quantità ∆𝑣1 con direzione 𝑣1 e di una
piccola quantità ∆𝑣2 con direzione 𝑣2 , la variazione di C corrisponderà a:
∂C
∂C
1
∂𝑣2
Δ𝐶 ≈ ∂𝑣 ∆𝑣1 +
∆𝑣2
Definiamo il vettore delle variazioni e il gradiente di C rispetto alle variabili 𝑣1 e 𝑣2 come:
∆v = (∆𝑣1 , ∆𝑣2 )𝑇
∂C
∂C 𝑇
∇C = (∂𝑣 , ∂𝑣 )
1
2
Possiamo quindi riscrivere tramite queste la variazione di C:
∆C ≈ ∇C ∗ ∆v
Supponiamo adesso di scegliere il vettore delle variazioni ∆𝑣 = −η∇C, ottenendo:
∆C ≈ −η∇C ∗ ∇C = −η∥ ∇C ∥2
Abbiamo detto che il nostro scopo è quello di minimizzare il valore di costo ottenuto, quindi
è evidente che la variazione di C deve essere negativa in modo tale da ottenere valori sempre
più piccoli; il secondo parametro per ovvi motivi non può essere negativo, quindi affinché
le nostre ipotesi siano verificate è necessario che “η” (tasso di apprendimento) sia un valore
strettamente positivo. Abbiamo quindi fatto un passo da gigante verso la nostra soluzione,
tuttavia però ci assale un dubbio: abbiamo detto quali sono i vincoli che deve rispettare il
nostro tasso, ma non come questo deve essere scelto. Come abbiamo detto, ci stiamo
spostando lungo la superfice del nostro paraboloide, quindi dovremmo ripetere più volte
questi spostamenti fino a raggiungere un punto che sia sufficientemente piccolo; è ovvio
che quanto più grande fosse il nostro tasso, quanto più velocemente “scenderemmo” lungo
il paraboloide, tuttavia allo stesso tempo rischieremmo di non raggiungere mai un valore
sufficientemente piccolo perché verrebbe sempre superato; di controparte, nemmeno troppo
piccolo va bene perché altrimenti prima di raggiungere il nostro obbiettivo saranno necessari
14
tantissimi spostamenti, richiedendo tempi troppo elevati. La giusta scelta sarà quella di
considerare un compromesso. Fino ad ora abbiamo considerato un esempio in due variabili,
ovviamente possiamo tranquillamente estendere la nostra trattazione anche a vettori di “𝑛”
dimensioni. Abbiamo visto come valutare la variazione del costo, ma per poter permettere
alla rete di imparare è necessario modificare i suoi parametri interni tramite una generica
regola di apprendimento:
𝑣 → 𝑣′ = 𝑣 − η∇C
La quale, tradotta in funzione dei “pesi” e delle “biases”, assume l’espressione:
∂C
𝑤𝑘 → 𝑤 ′ 𝑘 = 𝑤𝑘 − η ∂𝑤
∂C
′
𝑏𝑙 → 𝑏 𝑙 = 𝑏𝑙 − η ∂𝑏
𝑘
𝑙
Prendiamo di nuovo in considerazione la nostra funzione di costo, e riscriviamola come:
1
∥ 𝑦(𝑥) − 𝑎 ∥2
𝐶 = ∑ 𝐶𝑥 ,
𝑑𝑜𝑣𝑒
𝐶𝑥 =
𝑛
2
𝑥
Quindi per calcolare il gradiente di C sarà necessario calcolare prima tutti i singoli gradienti
di Cx e poi ottenerne la media:
∇C =
1
∑ ∇𝐶𝑥
𝑛
𝑥
È ovvio che l’apprendimento, a causa di ciò, sarà decisamente lento per una grande quantità
di ingressi; un’idea per superare quest’ultimo ostacolo è quello di stimare il
gradiente ∇C calcolando ∇𝐶𝑥 solo per un sottoinsieme più piccolo di ingressi scelto a caso,
garantendo così una buona stima in tempi decisamente ridotti; questa tecnica prenderà il
nome di “Stocastic Gradient Descent”, ottenendo:
∑𝑚
∑𝑥 ∇𝐶𝑥
𝑗=1 ∇𝐶𝑋𝑗
∇C =
≈
𝑛
𝑚
Dove “𝑚” non è altri che la dimensione del sottoinsieme di ingressi scelto; ovviamente
dobbiamo modificare anche la nostra regola di apprendimento, ottenendo:
∂𝐶𝑋𝑗
η
𝑤𝑘 → 𝑤 ′ 𝑘 = 𝑤𝑘 − ∑
𝑚
∂𝑤𝑘
𝑗
𝑏𝑙 → 𝑏 ′ 𝑙 = 𝑏𝑙 −
∂𝐶𝑋𝑗
η
∑
𝑚
∂𝑏𝑙
𝑗
1
Concludiamo il paragrafo, dicendo che in alcuni casi è possibile omettere il fattore 𝑛 dalla
15
nostra funzione di costo, questo capita quando non è noto a priori il numero totale degli
esempi di addestramento perché, ad esempio, potrebbero essere generati a “run time”.
2.2 Backpropagation
Abbiamo visto nel paragrafo precedente come le reti neurali possano apprendere
modificando i loro parametri, sfruttando un algoritmo di discesa del gradiente; adesso però
ci ritroviamo di fronte ad un altro problema, ovvero la necessità di capire come calcolare il
gradiente della funzione di costo: andiamo quindi ad introdurre l’algoritmo
“backpropagation”. Inizialmente introdotto nel 1970, fu apprezzato solo dopo circa 16 anni,
quando comparve in un saggio di David Rumelhart, Geoffrey Hinton, e Ronald Williams,
dove fu effettivamente mostrato che questo risultava funzionare molto più velocemente
rispetto ai suoi concorrenti. Sulla scia di quanto visto per una rete MLP, identifichiamo:
𝑙
𝑎𝑗𝑙 = 𝜎 (∑ 𝑤𝑗𝑘
𝑎𝑗𝑙−1 + 𝑏𝑗𝑙 )
𝑘
Dove:
𝑙
• 𝑤𝑗𝑘
andrà ad indicare il peso per il collegamento tra il k-esimo neurone appartenente allo
(l-1)-esimo strato e lo j-esimo neurone appartenente allo l-esimo strato;
• 𝑏𝑗𝑙 indicherà la polarizzazione dello j-esimo neurone appartenente allo l-esimo strato;
• 𝑎𝑗𝑙 indicherà “l’attivazione” dello j-esimo neurone appartenente allo l-esimo strato.
Possiamo quindi dedurre che per ogni strato abbiamo una matrice dei pesi 𝑤 𝑙 : il generico
𝑙
peso 𝑤𝑗𝑘
corrisponderà all’elemento della matrice avente riga “j” e colonna “k”; in modo
analogo per ogni l-esimo strato abbiamo un vettore di polarizzazione 𝑏 𝑙 , e un vettore di
attivazione 𝑎𝑙 . Detto questo, potremmo riscrivere la nostra espressione in forma matriciale,
ricordando che la funzione “𝜎” verrà applicata per ogni elemento di un generico vettore v;
ad esempio, supposta una generica funzione 𝑓(𝑥) = 𝑥2 , abbiamo che:
𝑓(2)
2
4
𝑓 ([ ]) = [
]=[ ]
𝑓(3)
3
9
Adesso siamo in grado di riscrivere la nostra equazione in forma vettorizzata:
𝛼 𝑙 = 𝜎(𝑤 𝑙 𝛼 𝑙−1 + 𝑏 𝑙 )
Oppure:
𝛼 𝑙 = 𝜎(𝑧 𝑙 )
𝑑𝑜𝑣𝑒 𝑧 𝑙 ≡ 𝑤 𝑙 𝛼 𝑙−1 + 𝑏 𝑙
16
Le 𝑧 𝑙 rappresentano gli ingressi pesati dei neuroni dello l-esimo strato.
2.2.1 Ipotesi fondamentali sulla funzione di costo
L’obiettivo dell’algoritmo “backpropagation” è quello di calcolare le derivate parziali della
funzione di costo rispetto ai pesi e alle polarizzazioni; a tal fine la funzione di costo deve
godere di due particolari proprietà:
1. La funzione di costo deve poter essere scritta come media di funzioni di costo
1
corrispondenti ai singoli esempi di training x, ovvero: 𝐶 = 𝑛 ∑𝑥 𝐶𝑥 ; questo perché
l’algoritmo andrà a calcolare le derivate parziali direttamente dei singoli costi Cx;
2. Il costo dovrà sempre poter essere scritto come una funzione delle uscite della nostra
rete neurale, ovvero: 𝐶 = 𝐶(𝑎𝐿 ); dove 𝑎𝐿 è il vettore di attivazione dello strato di output.
È abbastanza banale dimostrare che la nostra funzione di costo quadratica rispetta
perfettamente queste due ipotesi: nel paragrafo precedente abbiamo già dimostrato la prima,
per la seconda basta vedere che questa può essere scritta come:
1
1
𝐶 = ∥ y − α𝐿 ∥2 = ∑(y𝑗 − α𝑗𝐿 )2
2
2
𝑗
2.2.2 Le quattro equazioni su cui si fonda la backpropagation
Prima di addentrarci nella discussione, introduciamo una nuova quantità, δ𝑗𝑙 , che
chiameremo “errore” dello j-esimo neurone appartenente allo l-esimo strato; esso sarà in
grado di alterare il funzionamento del nostro neurone, infatti, invece di ricevere in uscita un
output del tipo 𝜎(𝑧𝑗𝑙 ) otterremo 𝜎(𝑧𝑗𝑙 + ∆𝑧𝑗𝑙 ) dove ∆𝑧𝑗𝑙 sarà una piccola quantità che si
sovrapporrà all’ingresso pesato. È abbastanza facile immaginare che questa modifica
non altererà solo l’uscita dello j-esimo neurone ma andrà anche a propagarsi attraverso tutti
i successivi strati della rete, la cui decisione dipenderà da quella del neurone affetto da
errore. Quindi, è evidente che il costo complessivo ne risentirà anche di una quantità:
∂C
𝑙
𝑙 ∆𝑧𝑗
∂𝑧𝑗
L’idea a questo punto è quella di utilizzare l’errore per i nostri scopi, ovvero sfruttare la
quantità ∆𝑧𝑗𝑙 per provare a minimizzare il nostro costo. Definiamo δ𝑙 il vettore degli
errori associati allo l-esimo strato e un generico errore δ𝑗𝑙 come:
17
δ𝑗𝑙 ≡
∂C
∂𝑧𝑗𝑙
In questo modo possiamo avviare la trattazione delle nostre 4 equazioni:
1. Equazione per l’errore nello strato di output δ𝐿 :
∂C
δ𝑗𝐿 ≡ 𝐿 𝜎 ′ (𝑧𝑗𝐿 )
∂𝑎𝑗
Il primo termine del secondo membro misura quanto velocemente si modifica il costo in
funzione della j-esima attivazione dello strato di output, mentre il secondo termine, ovvero
𝜎 ′ (𝑧𝑗𝐿 ), rappresenta quanto velocemente la funzione di attivazione 𝜎 sta cambiando;
estendendo in forma matriciale, otterremo:
δ𝐿 = ∇𝑎 𝐶 ⊙ 𝜎 ′ (𝑧 𝐿 )
Dove ⊙ indica il prodotto di Hadamard, o punto a punto, fra matrici, mentre ∇𝑎 𝐶
rappresenta tutte le derivate parziali di C rispetto a “𝑎𝑗𝐿 ” per ogni valore di “j”; calcolare la
derivata di una funzione costo quadratica, non è particolarmente complicato:
1
1
𝐶 = 2 ∥ y − α𝐿 ∥2 = 2 ∑𝑗(y𝑗 − α𝑗𝐿 )2 ↔
∂C
∂𝑎𝑗𝐿
= −(y𝑗 − α𝑗𝐿 )
Quindi possiamo riscrivere la nostra equazione anche nella forma:
δ𝐿 = −(y − α𝐿 ) ⊙ 𝜎 ′ (𝑧 𝐿 )
2. Equazione per l’errore δ𝑙 in termini di errore nello strato successivo δ𝑙+1 :
δ𝑙 = ((𝑤 𝑙+1 )𝑇 δ𝑙+1 ) ⊙ 𝜎 ′ (𝑧 𝑙 )
Tramite questa, supponendo di conoscere il valore di δ𝑙+1 e la matrice trasposta dei pesi
associata allo (l+1)-esimo strato, possiamo recuperare informazioni su come si sposta
all’indietro l’errore attraverso la rete. Banalmente, tramite l’utilizzo delle prime due
equazioni, possiamo calcolare l'errore δ𝑙 per ogni strato.
3. Equazione per il tasso di variazione del costo rispetto a qualsiasi “biases” della rete:
∂C
= δ𝑗𝑙
∂𝑏𝑗𝑙
Ovvero l’errore δ𝑗𝑙 è esattamente uguale alla derivata parziale rispetto alla polarizzazione di
un j-esimo neurone appartenente allo l-esimo strato. Possiamo quindi estendere la relazione
a tutta la rete, ottenendo:
∂C
= δ
∂𝑏
18
4. Equazione per il tasso di variazione del costo rispetto a qualsiasi peso della rete:
∂C
𝑙−1 𝑙
𝑙 = 𝑎𝑘 δ𝑗
∂𝑤𝑗𝑘
Riscrivendola in forma matriciale:
∂C
= 𝑎𝑙−1 δ𝑙
∂𝑤 𝑙
↔
∂C
= 𝑎𝑖𝑛 δ𝑜𝑢𝑡
∂𝑤
Questa seconda riscrittura, decisamente più leggibile, ci lascia intuire che possiamo
calcolare la derivata parziale di C rispetto ai pesi di un generico strato “l” attraverso il vettore
di attivazione dello strato immediatamente precedente (che quindi corrisponderà
all’ingresso dello l-esimo strato) ed il vettore dell’errore associato allo l-esimo strato. Una
diretta conseguenza di quest’ultima equazione è che quando 𝑎𝑖𝑛 è molto piccolo, quindi
staremo parlando di un vettore a bassa attivazione, tenderà ad esserlo anche la derivata
parziale del costo rispetto ai pesi, portando “w” a variare molto lentamente e a parlare di:
“apprendimento lento”. Guardiamo adesso il livello di uscita, quindi faremo riferimento alla
prima equazione, possiamo facilmente notare che qui comparirà la funzione 𝜎 ′ (𝑧𝑗𝐿 ). Nel
capitolo precedente abbiamo mostrato il grafico di una sigmoide, notando che per valori di
“z” molto grandi o molto piccoli, “𝜎(𝑧)” tende ad assumere un comportamento costante;
ciò porta la derivata, in questi punti, ad essere circa nulla, ottenendo come conseguenza un
apprendimento lento. Diremo quindi che un peso nello strato finale imparerà lentamente se
il neurone di uscita è a bassa attivazione (~0) o alta attivazione (~1). Possiamo
tranquillamente estendere queste considerazione anche agli strati intermedi, nella seconda
equazione, infatti, compare nuovamente la derivata della funzione sigma; nel prossimo
capitolo vedremo come risolvere questo problema.
2.2.3 Passi per la costruzione di un algoritmo backpropagation
Per la costruzione di un algoritmo backpropagation dobbiamo affrontare le seguenti azioni:
1. Input x: Impostare il corrispondente vettore di attivazione per l’input layer (𝑎1 ).
2. Feedforward: Per ogni l = 2, 3, …, L calcolare:
𝑧 𝑙 = 𝑤 𝑙 𝑎𝑙−1 + 𝑏 𝑙 ,
𝑎𝑙 = 𝜎(𝑧 𝑙 )
3. Output error δ𝐿 : Applicare la prima equazione vista nel sottoparagrafo precedente.
19
4. Backpropagate the error: Per ogni l = L-1, L-2, …, 2 calcolare la seconda equazione
vista nel sotto-paragrafo precedente.
5. Output: Il gradiente della funzione di costo è data da:
∂C
𝑙−1 𝑙
𝑙 = 𝑎𝑘 δ𝑗
∂𝑤𝑗𝑘
∂C
= δ𝑗𝑙
∂𝑏𝑗𝑙
L’algoritmo ha calcolato il gradiente della funzione di costo per un singolo esempio di
training “x", quindi in realtà siamo andati a valutare Cx e non C; per considerare un intero
training set dovremo combinarlo con un algoritmo di apprendimento come “SGD –
Stocastic Gradient Descent”:
1. Inserire in ingresso una serie di esempi di addestramento;
2. Per ogni “x” calcolare il gradiente tramite la backpropagation;
3. Gradient descent: Per ogni l = L, L-1, …, 2 applichiamo la regola di apprendimento:
𝜂
𝑤 𝑙 → 𝑤 𝑙 − ∑ δ𝑥,𝑙 (𝑎 𝑥,𝑙−1 )𝑇 ,
𝑚
𝑥
𝜂
𝑙
𝑙
𝑏 → 𝑏 − ∑ δ𝑥,𝑙 .
𝑚
𝑥
2.3 Test della rete
Possiamo adesso valutare le prestazione della nostra rete, in particolare analizzeremo la
precisione di classificazione sui dati di test. I parametri utilizzati, sono gli stessi presentati
nel libro “Neural Networks and Deep Learning” [1], tuttavia i risultati ottenuti li ho generati
personalmente sul mio calcolatore; infatti, sarebbe inutile riportare gli stessi risultati dato
che: essendo i pesi e le polarizzazioni iniziali generati casualmente tramite v.a. Gaussiane
(parleremo di questo in modo approfondito nel prossimo capitolo), i risultati saranno,
soprattutto per le prime epoche, diversi; inoltre per motivi di spazio mostrerò i risultati
ottenuti solo dalle prime tre e ultime tre epoche di addestramento. Il programma che ho
utilizzato per inizializzare la nostra rete prende il nome di “network.py" il cui codice
sorgente è disponibile su “github.com” [2]. Ricordiamo velocemente che un mini-batch è
un sottoinsieme casuale di esempi di training, e l’algoritmo SGD effettuerà un unico passo
di discesa del gradiente per ognuno di essi. In questa prima analisi abbiamo deciso di
utilizzare 30 neuroni nascosti e un tasso di apprendimento pari a 3.0; è abbastanza evidente
20
che i risultati sono molto promettenti, infatti, già dopo la prima epoca di addestramento
abbiamo riconosciuto un totale di 8993/10000
immagini appartenenti al test set, per poi arrivare
alla fine del ciclo di addestramento a saper
riconoscere correttamente 9495/10000 immagini,
Fig. 9: Tabella di precisione sul Test Set
ottenendo una precisione del 94.95% sul test data. Ovviamente è facile immaginare che
cambiando i nostri parametri otteniamo una percentuale di precisione diversa: proviamo, ad
esempio, ad aumentare da 30 a 100 il totale dei
neuroni nascosti e vediamo cosa accade. Per quanto
riguarda le prime epoche, non abbiamo un
particolare cambio di percentuale, cosa diversa
Fig. 10: Tabella di precisione sul Test Set
invece verso la fine; al termine del ciclo di apprendimento la percentuale è salita dal 94.95%
al 96.51%; in questo caso abbiamo visto che aumentare il numero di neuroni nascosti ci
aiuta ad ottenere una rete migliore. Nei paragrafi precedenti abbiamo affermato che la scelta
del tasso di apprendimento deve essere ben ponderata, ovvero scegliere un valore che non
sia né troppo grande né troppo piccolo, spiegando
teoricamente gli effetti che questi avranno sulla rete;
a questo punto sembra interessante avere anche una
dimostrazione pratica, per questo valuteremo prima
Fig. 11: Tabella di precisione sul Test Set
un tasso di apprendimento eccessivamente grande (100.0) sia uno eccessivamente piccolo
(0.001): Avere un tasso eccessivamente grande significa non raggiungere mai un valore
sufficientemente piccolo in grado di minimizzare il
più possibile la funzione di costo, infatti notiamo in
figura (11) che la rete raggiungerà una precisione
del 10.1% senza riuscire mai a migliorarla.
Fig. 12: Tabella di precisione sul Test Set
Situazione leggermente diversa nell’avere un tasso eccessivamente piccolo, in questo caso
fino alla quinta o sesta epoca la precisione è altalenante, dopo inizia a presentare una serie
di piccoli miglioramenti, dimostrando che è possibile tramite questi ottenere una rete
ottimale ma che 30 epoche non saranno sufficienti.
21
Capitolo 3: Migliorare le prestazioni di una Rete Neurale
Nel capitolo precedente abbiamo trovato una soluzione (non particolarmente complessa) per
implementare delle reti neurali in grado di risolvere il problema del riconoscimento delle
cifre scritte a mano, ottenendo anche una percentuale di precisione molto alta; adesso
consideriamo alcuni miglioramenti allo scopo di ottenere una rete più efficiente e allo stesso
tempo più veloce.
3.1 The Cross-Entropy cost function
Una cosa interessante della mente umana è sicuramente la risposta all’errore, ovvero, un
essere umano dopo aver commesso un errore ha maggiori possibilità che questo non venga
più; potremmo quindi banalmente dire che un uomo è in grado di imparare più velocemente
dopo aver sbagliato. Definiamo adesso un “toy problem” come un problema il cui scopo è
esclusivamente illustrare (o mettere alla prova) metodi di risoluzione; questi, quindi, devono
essere descritti in modo preciso e sintetico. Consideriamo quindi un “toy problem”:
vogliamo valutare un neurone in grado di restituirci in
uscita il valore “0” quando in ingresso si presenta un
“1”; consideriamo un peso 𝑤 = 0.6 e 𝑏 = 0.9,
Fig. 13: Neurone Sigma
questi inizialmente vanno sempre fissati, in maniera tale da avere un punto di partenza per
il nostro algoritmo. Considerato 𝑥 = 1.0, l’uscita sarà uguale a:
1
𝑦=
= 0.82
−(0.6∗1+0.9)
1+𝑒
Notiamo quindi che la risposta del nostro neurone si avvicina molto di più all’unità che allo
zero, dobbiamo quindi procedere con l’apprendimento: scegliamo un tasso di 0.15 e un
numero di epoche pari a 300. Ho scritto il seguente codice utilizzando la versione 2.7 di
python: inizializza un neurone, procede con l’apprendimento e stampa i risultati.
22
from random import choice
import numpy as np
import fpformat as fp
""" ************************* CLASSE NEURONE SIGMA ************************ """
class SigmaNeuron():
def __init__(self, initial_weight, initial_bias):
self.weight = initial_weight
self.bias = initial_bias
def cost_derivative(self, output_activation, y):
return (output_activation-y)
def training(self, training_data, epoch, eta):
print("--------------------- Fase di apprendimento ------------------")
x, expected = choice(training_data)
for i in xrange(epoch):
z = np.dot(self.weight, x) + self.bias
result = sigmoid(z)
result_derivate = sigmoid_prime(z)
delta = self.cost_derivative(result, expected)*result_derivate
self.weight = self.weight - (eta)*delta*x/1
self.bias = self.bias - (eta)*delta/1
z = np.dot(self.weight, x) + self.bias
self.stamp_result(i, x, sigmoid(z))
def stamp_result(self, i, x, result):
print("Epoch [{}]: imput: {} -> output: {}
(w,b)=({},{})".format(
i,x,fp.fix(result,2),fp.fix(self.weight,2),fp.fix(self.bias,2)))
def classify(self, x):
print("--------------------- Fase di decisione ---------------------")
z = np.dot(self.weight, x) + self.bias
result = sigmoid(z)
print("imput: {} -> output: {}".format(x, fp.fix(result, 2)))
def sigmoid(z):
return 1.0/(1.0+np.exp(-z))
def sigmoid_prime(z):
return sigmoid(z)*(1-sigmoid(z))
""" ***************************** TEST NEURONE **************************** """
sigma_neuron = SigmaNeuron(0.6, 0.9)
training_data = [(1, 0)]
sigma_neuron.training(training_data, 300, 0.15)
print("")
sigma_neuron.classify(1)
Alla fine del nostro apprendimento il risultato ottenuto sarà:
--------------------- Fase di apprendimento --------------------Epoch [0]: imput: 1 -> output: 0.81 - (w,b)=(0.58,0.88)
Epoch [1]: imput: 1 -> output: 0.81 - (w,b)=(0.56,0.86)
Epoch [2]: imput: 1 -> output: 0.80 - (w,b)=(0.54,0.84)
Epoch [3]: imput: 1 -> output: 0.79 - (w,b)=(0.53,0.83)
Epoch [4]: imput: 1 -> output: 0.79 - (w,b)=(0.51,0.81)
Epoch [5]: imput: 1 -> output: 0.78 - (w,b)=(0.49,0.79)
…
Epoch [296]: imput: 1 -> output: 0.09 - (w,b)=(-1.28,-0.98)
Epoch [297]: imput: 1 -> output: 0.09 - (w,b)=(-1.28,-0.98)
Epoch [298]: imput: 1 -> output: 0.09 - (w,b)=(-1.28,-0.98)
Epoch [299]: imput: 1 -> output: 0.09 - (w,b)=(-1.28,-0.98)
--------------------- Fase di decisione --------------------imput: 1 -> output: 0.09
Il neurone, dopo 300 epoche di addestramento, riuscirà a restituire un’uscita che sia una
buona approssimazione di quella desiderata, il costo verrà fortemente ridotto nelle prime
150 epoche per poi ricevere solo piccole variazioni in quelle successive. Cosa accade invece
se inizializziamo i parametri interni del neurone con il valore 2.0? L’uscita sarà:
23
𝑜𝑢𝑡𝑝𝑢𝑡 =
1
1+
𝑒 −(2∗1+2)
= 0.98
Questa volta il neurone restituirà praticamente “1” in uscita; dovrà quindi essere eseguita
una fase di apprendimento, ottenendo:
--------------------- Fase di apprendimento --------------------Epoch [0]: imput: 1 -> output: 0.98 - (w,b)=(2.00,2.00)
Epoch [1]: imput: 1 -> output: 0.98 - (w,b)=(1.99,1.99)
Epoch [2]: imput: 1 -> output: 0.98 - (w,b)=(1.99,1.99)
Epoch [3]: imput: 1 -> output: 0.98 - (w,b)=(1.99,1.99)
…
Epoch [39]: imput: 1 -> output: 0.98 - (w,b)=(1.88,1.88)
…
Epoch [82]: imput: 1 -> output: 0.97 - (w,b)=(1.72,1.72)
…
Epoch [118]: imput: 1 -> output: 0.96 - (w,b)=(1.54,1.54)
…
Epoch [153]: imput: 1 -> output: 0.93 - (w,b)=(1.28,1.28)
…
Epoch [191]: imput: 1 -> output: 0.83 - (w,b)=(0.81,0.81)
…
Epoch [208]: imput: 1 -> output: 0.72 - (w,b)=(0.47,0.47)
…
Epoch [230]: imput: 1 -> output: 0.50 - (w,b)=(0.00,0.00)
…
Epoch [296]: imput: 1 -> output: 0.21 - (w,b)=(-0.67,-0.67)
Epoch [297]: imput: 1 -> output: 0.21 - (w,b)=(-0.67,-0.67)
Epoch [298]: imput: 1 -> output: 0.20 - (w,b)=(-0.68,-0.68)
Epoch [299]: imput: 1 -> output: 0.20 - (w,b)=(-0.68,-0.68)
--------------------- Fase di decisione --------------------imput: 1 -> output: 0.20
Cosa notiamo di diverso? Vediamo che per le prime 150 epoche l’apprendimento sembra
non avere effetto, i parametri del neurone verranno infatti modificati tramite variazioni
troppo piccole. Ben diversa invece è la situazione nelle epoche successive, la velocità di
apprendimento aumenterà notevolmente ma ormai troppo tardi, non basteranno infatti le
rimanenti epoche per approssimare al meglio lo zero in uscita; l’effetto ottenuto prende il
nome di apprendimento lento, permettendoci di dedurre che più il neurone sbaglierà la sua
classificazione, maggiore sarà la difficoltà affrontata da quest’ultimo in fase di
apprendimento. La causa dell’apprendimento lento è già stata introdotta nel capitolo
precedente: abbiamo dimostrato che la colpa è dovuta alla derivata prima della funzione
sigma la quale è legata alla derivata della funzione costo secondo le relazioni:
𝜕𝐶
= (𝑎 − 𝑦)𝜎 ′ (𝑧)𝑥
𝜕𝑤
𝜕𝐶
= (𝑎 − 𝑦)𝜎 ′ (𝑧)𝑥
𝜕𝑏
Tornando al nostro esempio, essendo la coppia (x,y) = (1,0) abbiamo che entrambe valgono
“𝑎𝜎 ′ (𝑧)”; dobbiamo quindi passare alla ricerca di una funzione di costo che elimini
24
quest’accoppiamento. Introduciamo la funzione di costo Cross-entropy per un neurone:
1
𝐶 = − ∑[𝑦 𝑙𝑛(𝑎) + (1 − 𝑦)ln(1 − 𝑎)]
𝑛
𝑥
Prima di tutto per dimostrare che questa sia una valida funzione di costo, dobbiamo
verificare che sia una quantità positiva e allo stesso tempo che tenda a zero:
1. C > 0: vero, perché abbiamo una somma di quantità negative (il logaritmo restituisce un
valore negativo quando l’argomento è minore dell’unità), moltiplicata per “-1”.
2. C → 0: per dimostrarla consideriamo nuovamente il problema giocattolo e
immaginiamo che il nostro neurone si sta comportando bene, restituendo 𝑎 ≈ 0, quindi:
𝐶𝑥 ≈ [0 ∗ 𝑙𝑛(𝑎) + (1 − 0)𝑙𝑛(1 − 0)] = 𝑙𝑛(1) = 0
Abbiamo dimostrato, quindi, che questa è una funzione di costo valida, tuttavia ciò che a
noi interessava era sapere se questa riesce a superare il problema dell’apprendimento lento;
consideriamo, quindi, la derivata della nostra funzione 𝜎(𝑧):
𝜎(𝑧) =
−𝑒 −𝑧
1
1+𝑒 −𝑧
1+𝑒 −𝑧
1
↔ 𝜎 ′ (𝑧) = − (1+𝑒 −𝑧 )2 = − (1+𝑒 −𝑧 )2 + (1+𝑒 −𝑧 )2 = 𝜎(𝑧)(1 − 𝜎(𝑧))
Sapendo che 𝑎 = 𝜎(𝑧), riscriviamo la funzione di costo come:
∂C
1
𝑦
1 − 𝑦 ∂𝜎
1
𝑦
1−𝑦
= − ∑(
−
)
= − ∑(
−
) 𝜎 ′ (𝑧)𝑥𝑗
∂𝑤𝑗
𝑛
𝜎(𝑧) 1 − 𝜎(𝑧) ∂𝑤𝑗
𝑛
𝜎(𝑧) 1 − 𝜎(𝑧)
𝑥
𝑥
𝑦(1 − 𝜎(𝑧)) − (1 − 𝑦)𝜎(𝑧) ′
1
1
= − ∑(
) 𝜎 (𝑧)𝑥𝑗 = ∑(𝜎(𝑧) − 𝑦) 𝑥𝑗
𝑛
𝑛
𝜎(𝑧)(1 − 𝜎(𝑧))
𝑥
𝑥
Ed ecco il risultato che ci aspettavamo, ovvero la velocità con cui il peso apprende è
controllato da (𝜎(𝑧) − 𝑦), quindi dall’errore di output e non più dalla derivata
dell’attivazione; è possibile fare lo stesso ragionamento anche per le polarizzazioni:
𝜕𝐶
1
𝑦
1 − 𝑦 𝜕𝜎
1
= − ∑(
−
)
= ∑(𝜎(𝑧) − 𝑦)
𝜕𝑏
𝑛
𝜎(𝑧) 1 − 𝜎(𝑧) 𝜕𝑏 𝑛
𝑥
𝑥
Tornando ora al nostro esempio, modifichiamo il codice eliminando “result_derivate()”
dal calcolo della delta mentre il metodo “cost_derivative()” restituirà il rapporto
“(output_activation–y)/(output_activation-output_activation**2)” otterremo:
--------------------- Fase di apprendimento --------------------Epoch [0]: imput: 1 -> output: 0.81 - (w,b)=(0.57,0.87)
Epoch [1]: imput: 1 -> output: 0.80 - (w,b)=(0.55,0.85)
Epoch [2]: imput: 1 -> output: 0.79 - (w,b)=(0.52,0.82)
…
Epoch [299]: imput: 1 -> output: 0.04 - (w,b)=(-1.73,-1.43)
--------------------- Fase di decisione --------------------imput: 1 -> output: 0.04
25
Abbiamo considerato la nostra funzione di costo Cross-entropy per un singolo neurone;
tuttavia possiamo tranquillamente estendere tali studi anche per reti neurali multi-strato:
1
𝐶 = − ∑ ∑[𝑦𝑗 𝑙𝑛(𝑎𝑗𝐿 ) + (1 − 𝑦𝑗 )𝑙𝑛(1 − 𝑎𝑗𝐿 )]
𝑛
𝑥
𝑗
Applichiamo ora la funzione di costo Crossentropy per il riconoscimento di cifre, e vediamo
come, e se, cambia la nostra precisione sul test
set. Alla fine del ciclo di apprendimento abbiamo
una precisione del 95.50%, circa 0.55% in più
Fig. 14: Tabella di precisione sul Test Set
rispetto alla vecchia funzione di costo quadratica, ed in più ci viene restituita una precisione
elevata già dalle prime epoche.
3.2 Metodologie per la Regolarizzazione
Scopo principe di qualunque progetto di macchina intelligente è quello di generalizzare il
suo lavoro a nuove situazioni, potremmo dire quindi che il vero test per un generico modello
è la sua capacità di fare previsioni anche in situazioni dove non è mai stato esposto.
Cerchiamo quindi di capire se la nostra rete per il riconoscimento di cifre si comporta
correttamente o meno nei confronti della generalizzazione. Un’eventuale mancata
generalizzazione solitamente è più evidente quando il training set non è eccessivamente
grande, quindi valutiamo solo 1000 immagini di apprendimento. I prossimi grafici che
presenterò li ho generati tramite un programma chiamato “overfitting.py” il cui codice
sorgente è messo a disposizione sul sito “github.com” [2]; per ogni grafico sono andato a
considerare il “range” compreso tra le epoche 200 e 399;
questa decisione è stata presa per avere un buon
ingrandimento delle fasi successive all’apprendimento
iniziale. Il primo grafico rappresenta i valori assunti dal
costo in funzione delle diverse epoche; l’andamento di
questo è molto incoraggiante, era infatti proprio quello che
Fig. 15: Cost on Training Data
ci aspettavamo: una funzione decrescente. Il secondo grafico invece rappresenta la
26
precisione sul test set; questo lascia trapelare che dopo un certo numero di epoche, in questo
caso circa 280, la percentuale tende ad assumere valori
intorno all’82%, mostrando quindi che il modello non sta
più imparando, nonostante la funzione di costo ci mostri
un miglioramento continuo. Diremo che la rete oltre
l’epoca 280 sarà affetta da overfitting (sovradattamento)
o da overtraining (sovrapprendimento). L’overfitting è
Fig. 16: Accuracy on Test Data
un problema serio nelle reti neurali, soprattutto quando queste sono caratterizzate da un gran
numero di pesi e biases; ricordando ad esempio la nostra rete composta da [784; 30; 10]
neuroni, abbiamo: 784 ∗ 30 + 30 + 30 ∗ 10 + 10 = 26860 parametri liberi tra pesi e
polarizzazioni, quindi anche una rete sufficientemente semplice come quella per riconoscere
cifre con soli 30 neuroni nascosti, possiede decine di migliaia di parametri; dobbiamo quindi
trovare un modo per rilevare il sovradattamento e delle tecniche alternative per ridurlo. Una
tecnica per rilevarlo è già stata introdotta, ovvero vedere quando la rete ha smesso di
imparare, e quindi terminare preventivamente l’apprendimento, tuttavia questa non è
infallibile: in alcuni casi la precisione sul test rimane costante per un tempo limitato per poi
riprendere a crescere, e quindi rischia di essere falsamente dichiarato affetto da overfitting.
Quali tecniche potrebbero essere utilizzate per garantire la riduzione dell’overfitting? Una
possibilità sarebbe quella di ridurre le dimensioni della nostra rete, tuttavia spesso avere una
grande quantità di parametri liberi risulta necessario ai fini della risoluzione del problema
quindi dobbiamo bocciarla istantaneamente; fortunatamente esistono un insieme di tecniche
di regolarizzazione che andremo ad esporre nei successivi sotto-paragrafi.
3.2.1 L2 Regularization
“L2 Regularization” è una delle più efficienti e famose tecniche di regolarizzazione: consiste
nell’aggiungere un termine supplementare alla funzione di costo chiamato “termine di
regolarizzazione”. Prendiamo la funzione di costo Cross-entropy e regolarizziamola:
1
𝜆
𝐶 = − ∑ ∑[𝑦𝑗 𝑙𝑛(𝑎𝑗𝐿 ) + (1 − 𝑦𝑗 ) 𝑙𝑛(1 − 𝑎𝑗𝐿 )] +
∑ 𝑤2
𝑛
2𝑛
𝑥
𝑗
𝑤
Il primo termine del secondo membro è stato già presentato nei paragrafi precedenti, il
27
secondo è dovuto alla regolarizzazione: corrisponde alla somma dei quadrati di tutti i pesi
𝜆
della rete scalata di un fattore 2𝑛 con λ > 0, chiamato “parametro di regolarizzazione”, e 𝑛,
come al solito, rappresenta la dimensione dell’insieme di apprendimento. Possiamo
utilizzare questa tecnica per qualsiasi funzione di costo, quindi da adesso in poi procediamo
utilizzando una relazione generale del tipo:
𝐶 = 𝐶0 +
𝜆
∑ 𝑤2
2𝑛
𝑤
Lo scopo della regolarizzazione è quello di fare in modo che si raggiunga un compromesso
tra il trovare piccoli pesi per la rete e allo stesso tempo minimizzare la funzione di costo
originale; l’importanza relativa dei due elementi di compromesso dipende dal valore di λ:
quando è molto piccolo allora preferiamo minimizzare la funzione di costo originale,
viceversa quando è grande preferiamo operare con piccoli pesi. Le derivate della funzione
di costo saranno:
∂C ∂C0 𝜆
=
+ 𝑤
∂𝑤 ∂𝑤 𝑛
∂C ∂C0
=
∂𝑏
∂𝑏
Notiamo subito che la regolarizzazione non va ad alterare il valore delle polarizzazioni
quindi riscriviamo solo la regola di apprendimento per i
pesi:
𝑤 →𝑤−𝜂
∂C0
𝜆
𝜆
∂C0
− 𝜂 𝑤 = (1 − 𝜂 ) 𝑤 − 𝜂
∂𝑤
𝑛
𝑛
∂𝑤
A causa del rapporto dovuto alla regolarizzazione abbiamo
che il peso verrà scalato di una certa quantità, per questo
Fig. 17: Cost on Training Data
motivo questa tecnica di regolarizzazione viene anche
chiamata tecnica per il “decadimento di peso”. Torniamo
nuovamente al nostro problema della classificazione di
cifre MNIST e vediamo come cambia la precisione con
l’aggiunta di un fattore λ = 0.1. Notiamo che il costo sui
dati di addestramento diminuisce nel tempo in modo
Fig. 18: Accuracy on Test Data
analogo al caso non regolarizzato, questo non è un problema perché avere un costo
decrescente è proprio ciò a cui aspiriamo; quello che ci interessa valutare sono le
28
conseguenze sulla precisione sul test set: notiamo che, nonostante alcune oscillazioni,
continua ad aumentare ottenendo una soppressione dell’overfitting. In più, abbiamo che la
precisione di picco di classificazione è di circa 87.1%, decisamente superiore rispetto a
quella di 82,27% ottenuta nel caso non regolarizzato. Concludiamo il paragrafo informando
che esiste una versione di L2 in grado di vincolare anche le biases, tuttavia non viene mai
utilizzata perché non altera particolarmente i risultati; Infatti, avere un’elevata
polarizzazione non rende un neurone così sensibile ai suoi ingressi come l’avere grandi pesi,
anzi una “b” sufficientemente grande garantisce una maggiore flessibilità al comportamento
della rete.
3.2.2 L1 Regularization
Anche qui andremo a modificare la nostra funzione di costo:
𝜆
𝐶 = 𝐶0 +
∑ |𝑤|
2𝑛
𝑤
In analogia con quanto visto con L2 definiamo la derivata parziale della funzione costo e la
regola di apprendimento per i pesi:
∂C ∂C0 𝜆
=
+ 𝑠𝑔𝑛(𝑤)
∂𝑤 ∂𝑤 𝑛
𝜆
∂C0
𝑤 → 𝑤 − 𝜂 𝑠𝑔𝑛(𝑤) − 𝜂
𝑛
∂𝑤
Dove “𝑠𝑔𝑛(𝑤)” è una classica funzione segno: restituisce valore -1 se 𝑤 < 0, altrimenti 1.
In entrambe le regole di aggiornamento, sia questa che quella presentata nella
regolarizzazione L2, hanno come scopo di andare a ridurre i pesi, la differenza è il modo con
cui raggiungeranno il proprio obiettivo: in L2, i pesi si riducono di una quantità
proporzionale alla w, in L1, invece, i pesi si riducono di una quantità costante diversa da
zero; questa modalità diversa comporta anche un approccio diverso nei confronti della
dimensioni dei pesi: L1 tende a restringere poco il peso se questo è particolarmente grande,
viceversa se piccolo viene particolarmente ridotto; deduzione immediata dovuta al fatto che
la quantità con cui viene ridotto il peso è sempre uguale indipendentemente dal valore del
peso. Il comportamento di L2 è diametralmente opposto, ridurrà particolarmente i pesi molto
grandi e ci andrà più leggero per i pesi molto piccoli.
29
3.2.3 Dropout
La Dropout è una tecnica radicalmente diversa rispetto alle due regolarizzazioni presentate
sopra, questa infatti non si basa sulla modifica della funzione di costo ma tende ad
intervenire direttamente sulla rete stessa. Per fissarla meglio
partiamo immediatamente con un esempio: 3 neuroni di input, 6
nascosti e 2 di uscita. Il primo passo è disabilitare la metà dei
neuroni nascosti, avviare la fase di addestramento sulla rete
modificata, una volta terminata ripristinare i neuroni disabilitati ed
Fig. 19: Esempio di rete MLP
infine sceglierne un nuovo sottoinsieme casuale da inabilitare;
ripetere poi questo procedimento diverse volte. È ovvio notare che i
nuovi pesi e polarizzazioni sono dovuti quindi ad una fase di training
in cui la metà dei neuroni dello strato nascosto erano inabilitati,
quindi in fase operativa ci ritroveremo un numero doppio di neuroni
attivi rispetto a quello considerato in fase di apprendimento; per
Fig. 20: Rete MLP con
Neuroni disabilitati
compensare questa differenza andremo a dimezzare i pesi in uscita dagli “hidden neurons”.
A questo punto la domanda che ci poniamo è capire quali vantaggi porta rispetto alle due
regolarizzazioni viste in precedenza. Per capire i guadagni apportati dobbiamo immaginare
una serie di reti neurali aventi lo stesso numero di neuroni nello strato di input e output e
dobbiamo immaginare di addestrarle tutte utilizzando lo stesso training data; naturalmente,
le reti potrebbero essere inizializzate in modo diverso, dovuto ad esempio dalla generazione
casuale (tramite v.a. Gaussiane) dei pesi e delle polarizzazioni, di conseguenza è possibile
che queste restituiranno risultati diversi in uscita; in particolare supponiamo di avere 5 reti
e supponiamo di porre in ingresso un’immagine rappresentante una cifra, supponiamo che
3 di queste restituiscono un “9” mentre le rimanenti due, cifre diverse; allora è abbastanza
facile immaginare che il risultato esatto sia proprio il “9” e che le due reti rimanenti hanno
effettuato una classificazione errata; abbiamo quindi scelto il risultato che presentava più
consensi dalle nostre reti; l’esempio introdotto è analogo a quello che fa nella pratica la
nostra Dropout, ovvero formare reti neurali differenti e poi utilizzare una sorta di media per
30
la scelta del risultato. Ogni rete verrà addestrata separatamente e quindi ognuna di esse sarà
affetta da un sovradattamento diverso; questa compensazione lo ridurrà fortemente nella
fase di test. Un altro effetto interessante di questa procedura è data dal fatto che questa riduce
i co-adattamenti dei neuroni; un neurone, infatti, non può invocare la presenza di altri
particolari elementi perché disabilitati, portandolo così ad essere più robusto.
3.2.4 Espansione artificiale di un training data
Finora abbiamo sempre considerato un sottoinsieme ridotto di 1000 su 50000 immagini di
apprendimento; che cosa accade, invece, se andiamo a
valutare l’intero insieme di apprendimento? L’overfitting
aumenterà, diminuirà o rimarrà invariato? Per verificare
cosa accade, utilizziamo ancora una volta il nostro
programma “overfitting.py”, tuttavia, visto che “𝑛” va da
Fig. 21: Accuracy on Test Data
1000 a 5000, per mantenere il rapporto analogo a quello
precedente dobbiamo scegliere 𝜆 = 5.0 . Grazie alla linea tratteggiata indicante il valore di
96% è facile vedere che, anche se di poco, abbiamo dopo ogni epoca un margine di
miglioramento della precisione. Tuttavia questo grafico non riesce a rendere un’idea
abbastanza chiara sul come varia la precisione in rapporto all’aumento delle immagini del
training set, generiamone quindi un altro utilizzando un
altro programma sempre disponibile su “github.com” [2],
chiamato: “more_data.py”. Anche in questo grafico è
abbastanza chiaro che la precisione di classificazione
migliora notevolmente con l’aumentare dei dati di
Fig. 22: Accuracy on Validation Data
addestramento, allo stesso tempo però quanto più scegliamo
un training set grande tanto più il margine di miglioramento è minore. Se pur poca la
differenza di percentuale questa esiste e quindi logicamente se andassimo ad utilizzare
milioni o miliardi di campioni rispetto ai 50000 appartenenti al MNIST database, possiamo
comunque ottenere un miglioramento sostanziale. Abbiamo quindi dedotto che usare un
training set sufficientemente ampio aiuta a ridurre l’overfitting; ma come possiamo
31
ottenerlo? Purtroppo questo è un problema difficile da risolvere, o perlomeno costoso, infatti
non è semplice recuperare o costruire nuovi dati di allenamento. Allora l’idea a questo punto
è: piuttosto che costruire campioni da zero, possiamo recuperarne di nuovi a partire da quelli
esistenti. Prendiamo in considerazione una cifra appartenente al dataset MNIST,
raffigurante un 5, e ruotiamola di 15 gradi, la cifra è ancora riconoscibile tuttavia a livello
di pixel questi sono stati completamente alterati generando quindi un ingresso non presente
nel training set.
Possiamo fare tante piccole rotazioni a tutte le immagini presenti nel dataset in modo tale
da quadruplicare se non di più la sua grandezza.
3.3 Scelta degli Hyper-parameters
Gli iper-parametri non sono un argomento nuovo per il nostro elaborato, infatti
corrispondono a: tasso di apprendimento, parametro di regolarizzazione, λ e così via; il
nostro scopo, a questo punto, è capire come andare a sceglierli in modo tale che la nostra
rete tenderà a comportarsi nel modo migliore; purtroppo però ancora non esiste un metodo
preciso dovremmo, infatti procedere con l’introduzione di diverse strategie empiriche che
possano aiutarci nell’andare a ricercarli. Andremo così ad introdurre la “Broad Strategy”,
questa consiste nel procedere per esperimenti per poi scegliere quella che si è comportata
meglio; per poter applicare questa strategia, per prima cosa conviene evitare l’utilizzo di un
dataset completo per velocizzare i tempi, ad esempio per il caso delle cifre MNIST,
conviene ridurre il training set a 1000 immagini, mentre per il set di validazione e di test
scegliamo 100 immagini. Partiamo proprio con uno dei parametri più critici, il tasso di
apprendimento: l’idea è quella di partire da un valore di 0.01, se il costo diminuisce già
durante le prime epoche passiamo a scegliere 0.1, 1.0 e così via fino a trovare un valore per
cui il costo tenda ad oscillare o addirittura aumentare; in alternativa, se tende ad oscillare
già con il primo parametro scelto, si procede scegliendo 0.001, 0.0001 e così via fino a
trovare un valore per cui il costo diminuisce già per le prime epoche. Tramite questa
32
procedura avremo a disposizione un modo per stimare l’ordine di grandezza del nostro tasso;
a questo punto ci tocca “raffinarlo” scegliendo un valore come (0.2, 0.5 ecc); ovviamente
stiamo sempre considerando una stima, quindi in nessun caso dobbiamo essere
particolarmente precisi. Passiamo ora alla scelta del numero di epoche, l’abbiamo già
implicitamente detto quando abbiamo parlato di overfitting, ovvero rilevare quando la
precisione tende ad avere un comportamento costante, aspettare un po’ per verificare che
non sia solo temporaneo e poi terminare l’apprendimento. Invece, per la scelta del parametro
di regolarizzazione conviene: inizialmente impostarlo su zero, per scegliere prima un buon
valore per il tasso di apprendimento, poi procedere aumentando o diminuendo di un fattore
10 in modo analogo a “η”.
33
Capitolo 4: Reti Neurali Convoluzionali
Finora abbiamo utilizzato reti feed-forward in cui gli strati adiacenti sono completamente
collegati tra loro; l’idea delle reti neurali convoluzionali è quello di eliminare questa seconda
ipotesi per ridurre il numero di parametri da apprendere. In questo capitolo andremo prima
ad introdurre le nostre ConvNet, capirne il funzionamento ed infine valuteremo come queste
si comportano con il nostro problema di riconoscimento.
4.1 Idee alla base delle ConvNet
Le operazioni svolte dal dalle ConvNet si basano su tre idee fondamentali: Local Receptive
Fields, Shared Weight and Biases e Pooling Layers.
4.1.1 Local Receptive Fields
Negli strati completamente connessi visti finora, gli ingressi erano descritti come “𝑛”
neuroni immaginati su di un unico layer, avente il compito di rappresentare univocamente
l’intensità di un pixel dell’immagine; questa prima idea si basa sullo
stravolgere questo concetto, dovendo andare ad immaginare i neuroni
organizzati su di una superfice. Come fatto nei precedenti capitoli,
creeremo un collegamento tra gli strati di input e hidden, ma a
Fig. 23: Input Neurons
differenza di quanto visto finora ogni neurone nascosto sarà collegato ad una sola regione
di quelli di input. La regione identificata nello strato
di input prende il nome di “campo recettivo locale”
per il neurone nascosto. Ogni connessione apprende
un peso, nel esempio in figura (24), valutando
regioni 5x5 avremo 25 pesi per area; faremo scorrere
34
Fig. 24: Connessione tra lo strato di ingresso e lo
strato nascosto
il nostro campo recettivo per tutta l’immagine e per ogni spostamento creeremo nuove
connessioni. Nell’immagine abbiamo spostato il campo recettivo locale di un pixel per volta,
in realtà è possibile procedere anche con diverse lunghezze di “passo”. Procedendo in questo
modo avremo uno strato di hidden composto da (28-5+1)2 = 24x24 neuroni.
4.1.2 Shared weights and biases
Abbiamo quindi detto che ogni neurone nascosto avrà un totale di 25 pesi e 1 bias dovuti al
campo recettivo locale; adesso piuttosto che andare a generare separatamente tutti i pesi e
biases, ottenendo così un numero elevatissimo di parametri liberi, valutiamo l’idea di
utilizzare gli stessi valori dei pesi e della polarizzazione per tutti i neuroni appartenenti allo
strato nascosto (da cui il nome “pesi e polarizzazioni condivisi”). Quindi considerando un
generico neurone nascosto in posizione (j,k), l’output sarà:
4
4
𝜎 (𝑏 + ∑ ∑ 𝑤𝑙,𝑚 𝑎𝑗+𝑙,𝑘+𝑚 )
𝑙=0 𝑚=0
Dove “b” sarà il valore condiviso per la polarizzazione, 𝑤𝑙,𝑚 sarà una matrice 5x5 composta
da pesi condivisi, e 𝑎𝑥,𝑦 indicherà l’attivazione di ingresso alla posizione (x,y). Questo
significa che tutti i neuroni appartenenti allo strato nascosto riconosceranno la stessa feature,
collocata in modo diverso nell’immagine di input. Supponiamo un semplice esempio per
carpirne i vantaggi: consideriamo l’immagine di un gatto, andiamo adesso ad allontanarla
causando inevitabilmente la modifica delle intensità dei pixel di ingresso, la rete alla fine
sarà comunque in grado di riconoscere il gatto; diremo quindi che questa godrà delle
proprietà di invarianza di un’immagine a seguito di una traslazione. La mappa delle
connessioni dallo strato di input a quello nascosto prende il nome di “features map”; a
questo punto è ovvio immaginare che una sola
caratteristica non sarà sufficiente per riconoscere
un’immagine,
abbiamo
quindi
bisogno
di
considerare più feature map. Nell’esempio in
Fig. 25: Esempio con più feature map
figura (25) abbiamo considerato 3 mappe, che per
molti problemi reali sono ancora poche, infatti, le più moderne possono tranquillamente
presentarne un minimo di 20 o 40. Nelle reti utilizzate finora dovevamo andare a definire
35
circa 784 ∗ 30 + 30 = 23550 parametri, adesso invece, valutando anche 20 mappe,
abbiamo un totale di soli (5 ∗ 5 + 1) ∗ 20 = 520 parametri da definire; la differenza sarà
quindi tutt’altro che trascurabile. Il nome “convoluzionale” deriverà proprio da questa
seconda idea, perché l’ingresso pesato “z” è ottenuta come somma di convoluzione:
4
4
𝜎 (𝑏 + ∑ ∑ 𝑤𝑙,𝑚 𝑎𝑗+𝑙,𝑘+𝑚 )
↔
𝑎1 = 𝜎(𝑏 + 𝑤 ∗ 𝑎0 )
𝑙=0 𝑚=0
Dove 𝑎1 indica l’insieme di attivazione in uscita da una feature map, 𝑎0 è l’insieme di
attivazione dell’input layer e l’operatore “∗” indica un’operazione di convoluzione.
4.1.3 Pooling Layers
Questa terza idea prevede l’aggiunta di un nuovo strato alle nostre reti, chiamato strato di
raggruppamento; tale inserimento avviene solo dopo gli
strati convoluzionali e andranno a semplificare le
informazioni in uscita da questi; in particolare, per ogni
feature map considerata avremo una feature map
Fig. 26: Operazione di Max-pooling
“condensata”. In figura (26) è mostrato un esempio: ogni max-pooling unit andrà a
semplificare le informazioni trasportate da 4 neuroni.
4.2 Schema di una ConvNet
È l’ora di riunire le tre idee presentate nel paragrafo precedente per ottenere uno schema
generale di una rete neurale convoluzionale. La rete inizia con un totale di 28x28 neuroni
appartenenti allo strato di input, usati per codificare la nostra immagine. Lo strato iniziale è
seguito da uno convoluzionale, ottenuto considerando una campo recettivo di grandezza 5x5
e 3 feature map; otterremo quindi uno strato composto da 3x24x24 neuroni nascosti. Il
prossimo passo è quello di avviare una
procedura di max-pooling applicata a regioni
2x2, ottenendo così un max-pooling layer,
composto da 3x12x12 neuroni nascosti. Lo
Fig. 27: Esempio di architettura per il nostro problema di
riconoscimento delle cifre manoscritte
strato finale che restituirà la cifra riconosciuta sarà ancora una volta uno strato
completamente collegato, ovvero ogni neurone appartenente al max-pooling layer sarà
36
collegato ad ognuno dei 10 neuroni di output. Nonostante la nostra nuova tipologia di rete
presenta un’architettura decisamente diversa rispetto a quelle viste finora, il quadro generale
è simile, ovvero: una rete composta da unità semplici, i cui comportamenti sono determinati
dai pesi e polarizzazioni ed utilizzare una serie di dati di training per permettere
l’apprendimento. Fortunatamente molte cose dette per le MLP saranno adattabili alle
ConvNet, basterà fare le giuste modifiche.
4.3 Test della rete
Ora che abbiamo appreso le idee alla base delle ConvNet possiamo metterle in pratica per
risolvere il problema del riconoscimento delle cifre scritte a mano. Il programma che
andremo ad utilizzare per inizializzare la nostra rete prende il nome di “network3.py”
disponibile sempre “github.com” [2]. Le reti viste finora utilizzavano solo una libreria
chiamata “Numpy” per supportare le diverse operazioni matematiche; per le ConvNet,
invece, verrà utilizzata una libreria specifica per l’apprendimento automatico, nota come
“Theano”. Oltre al garantire una maggiore semplicità di implementazione, consente
l’esecuzione del codice sia su una CPU che se, disponibile, su una GPU. I dati che andrò
utilizzare sono ancora una volta prelevati dal libro online “Neural Networks and Deep
Learning” [1]; i risultati, invece, sono stati generati sul mio calcolatore utilizzando proprio
la GPU impostando il corrispettivo flag. Il programma “network3.py” tra le diverse funzioni
ci mette a disposizione la classe: “FullyConnectedLayer()” questa serve per implementare strati
completamente connessi, allora per fare anche una sorta di paragone, possiamo ottenere una
linea di base, considerando una rete con soli 100 neuroni nello strato di hidden e andremo
ad addestrarla con 60 epoche, 10 minibatch e con un tasso di apprendimento
pari a 0.1; in figura (28) sono
rappresentati i risultati ottenuti: per ogni
epoca è stata restituita la percentuale di
precisione sia sul validation set, che
Fig. 28: Tabella di precisione sul Test Set e Validation Set
sul test set. Notiamo comunque un sostanziale miglioramento rispetto ai test fatti finora, il
37
motivo (oltre al doppio delle epoche) è che network3.py utilizza per l’output un “Softmax
Layer”; similmente ad un semplice strato di neuroni sigma calcola allo stesso modo il valore
degli ingressi pesati, ma invece di usare la funzione sigma, ne utilizzerà una softmax,
ottenuta come segue:
𝐿
𝑎𝑗𝐿
=
𝑒 𝑧𝑗
𝐿
∑𝑘 𝑒 𝑧𝑘
Teniamo presente il risultato ottenuto, ovvero 97.86%, perché sarà interessante vedere come
cambierà con l’aggiunta di uno strato convoluzionale. Manteniamo la stessa rete con i 100
neuroni sigma nascosti e gli stessi parametri usati per l’addestramento ma consideriamo
l’aggiunta di uno strato convoluzionale-pooling antecedente allo strato dei neuroni nascosti
(vedi figura 29). Potremmo immaginare che i primi strati si occupano di andare ad
apprendere la struttura spaziale dell’immagine
in ingresso, mentre lo strato completamente
collegato avrà il compito di apprendere ad un
livello più astratto, integrando le informazioni
Fig. 29: Esempio di architettura per il nostro problema di
riconoscimento delle cifre manoscritte
provenienti da tutta l’immagine. Per l’implementazione di uno strato convoluzionale,
network3.py ci mette a disposizione la classe “ConvPoolLayer()”, a cui passeremo i
parametri della dimensione dell’immagine, del campo recettivo locale, il numero delle
feature
map
ed
infine
la
dimensione della regione per
l’operazione di max-pooling. I
risultati ottenuti sono veramente
interessanti, siamo riusciti ad
aumentare la precisione di un
Fig. 30: Tabella di precisione sul Test Set e Validation Set
altro 0.87%, quindi significa che abbiamo riconosciuto 87 immagini in rispetto al caso
precedente. Una rete più profonda (in particolare con l’utilizzo degli strati convoluzionali)
è in grado di ottenere risultati migliori; a questo punto ci domandiamo se è possibile
“scendere” ancora di più: ovvero considerare ben due strati convoluzionali-pooling, di
38
uguali parametri, per ottenere
risultati
sempre
migliori.
Dovremmo però sciogliere un
dubbio sulla connessione tra
questi due, perché in uscita dal
primo
abbiamo
ben
20
immagini 12x12 e non una
Fig. 31: Tabella di precisione sul Test Set e Validation Set
sola come visto finora. L’idea è quella di dare la possibilità ad ogni neurone del secondo
strato di imparare da tutti i campi recettivi locali di ognuna delle 20 immagini di input, dove
i pixel (dell’immagine condensata) rappresenteranno la presenza o l’assenza di particolari
caratteristiche localizzate nell’immagine originale; la precisione raggiunta sarà del 99.03%.
Nel capitolo 4 abbiamo introdotto diversi modi per migliorare la nostra rete, sarebbe allora
interessante vedere di estenderli anche per le ConvNet. Tra i diversi metodi visti,
sicuramente quello maggiormente adattabile è l’espansione del training set; in particolare
userò il programma “expand_mnist.py” disponibile online [2] per estendere l’insieme
MNIST senza recuperare nuovi campioni, passando quindi da 50000 a 250000 immagini di
apprendimento; in più per il prossimo test considererò anche una L2 Regularization
impostando λ = 0.1; Al termine dell’apprendimento potremmo godere su una percentuale di
precisione del 99.22%; in realtà questo non è il risultato migliore, potremmo ottenere
ulteriori piccoli miglioramenti andando a modificare opportunamente i parametri tramite la
tecnica della “Broad Strategy”, aumentare la profondità aggiungendo un ulteriore strato di
neuroni
completamente
connessi, oppure utilizzando la
tecnica Dropout negli strati
completamente connessi; negli
strati convoluzionali l’utilizzo
di questa è ininfluente perché
Fig. 32: Tabella di precisione sul Test Set e Validation Set
dovendo generare pesi condivisi, sono già costretti a dover imparare da tutta l’immagine,
garantendo, quindi, già un ottimo livello di robustezza.
39
Conclusioni
Scopo dell’elaborato era quello di introdurre diverse tecniche per risolvere artificialmente il
problema del riconoscimento delle cifre scritte a mano; un nodo tutt’altro che semplice da
sciogliere data la natura complessa di come la mente umana opera per effettuare un
riconoscimento. Inizialmente abbiamo introdotto una soluzione più semplice tramite reti
composte da neuroni sigma, ottenendo un riconoscimento corretto di 9495/10000 immagini;
risultato notevole considerato essere una macchina, meno se pensiamo al numero totale di
immagini riconosciute da un essere umano; allora non ci siamo accontentati: abbiamo
introdotto una serie ti tecniche per “raffinare” il nostro studio, permettendo alla rete di
aumentare il numero di cifre correttamente riconosciute, aumentare la velocità di
apprendimento e allo stesso tempo garantire un alto grado di generalizzazione. Tuttavia, non
essendo ancora soddisfatti, abbiamo cercato un modo per sfruttare la componente spaziale
presente nelle immagini, introducendo così le reti neurali convoluzionali (da cui il nome
dell’elaborato), tramite un lavoro combinato tra queste e quelle completamente connesse
abbiamo raggiunto la soglia di 9786/10000 immagini correttamente riconosciute. L’attuale
record del mondo (2013) vede una corretta classificazione di 9979/10000 immagini; questo
attualmente detenuto da Li Wan, Matthew Zeiler, Sixin Zhang, Yann LeCun, e Rob Fergus,
la rete quindi classifica in modo errato solo 21 immagini, e a dover essere sinceri anche io,
essere umano, non sono stato in grado di riconoscerle.
40
Bibliografia
[1]
Michael
Nielsen,
“Neural
Networks
and
Deep
Learning”,
http://neuralnetworksanddeeplearning.com/, 07-01-2017
[2]
Michael Nielsen, “Code samples for my book Neural Networks and Deep Learning”,
https://github.com/mnielsen/neural-networks-and-deep-learning, 07-01-2017
[3]
Stuart Russel, Peter Norving, “Artificial Intelligence: A Modern Approach”, 3rd
Edition, Prentice-Hall, 2010, pages 727 - 736
41