Ok Google, quanto traffico c’è per raggiungere l’ufficio?”
“Al momento il traffico è scorrevole dal luogo in cui ti trovi al tuo posto di lavoro, il tragitto dura 57 minuti in auto”

Questo è quello che l’assistente vocale del nostro smartphone riesce a fare  con un “semplice” comando vocale. In questo post cercheremo di capire qualcosa (lo 0,000…001%) sulla storia, sul funzionamento e, in particolare, sul ruolo che le reti neurali hanno in piattaforme di riconoscimento vocale come Google Assistant, Siri, Viv, Cortana e molte altre.

Un po’ di storia.

Tutto cominciò tra gli anni ’50 e ’60, quando, presso i Bell Labs, venne sviluppato il primo sistema in grado di distinguere singoli numeri detti a voce. Basato sulla teoria della fonetica acustica, cioè la decomposizione di segnali acustici complessi nelle loro componenti più semplici, questo sistema era in grado di individuare le vocali del segnale ricevuto e capire che numero fosse.

Nei primi anni ’70 la DARPA, Defense Advanced Research Projects Agency del Dipartimento della Difesa statunitense, finanziò un concorso per lo sviluppo di sistemi di riconoscimento vocale ad alte prestazioni: il vincitore fu HARPY della Carnegie Mellon University. HARPY era grado di interpretare con buona accuratezza un vocabolario di poco più di un migliaio di parole, l’equivalente di un bambino di tre anni per intenderci. Oltre a rappresentare una svolta in termini di prestazioni, HARPY fu innovativo nelle tecniche utilizzate per “costruirlo”: infatti, dopo aver adeguatamente processato i segnali ricevuti, cerca su un grafo la frase che più somiglia al segnale ricevuto.

Negli anni ’80 queste tecnologie continuano a crescere, riuscendo ad elaborare varie migliaia di parole e si intravedono le prime applicazioni commerciali.

Dal punto di vista teorico assistiamo alla rivoluzionaria entrata in gioco del modello a catene di Markov nascoste (In inglese Hidden Markov Model — HMM) di cui trovate una breve introduzione in questo post. In poche parole il sistema riesce ad interpretare suoni sconosciuti tramite considerazioni statistiche sulle diverse parti che compongono il segnale.

Negli anni ’90 venne superato il grande problema di dover dettare al computer parola per parola prendendo una piccola pausa tra una e l’altra: d’altronde un discorso ha un carattere continuo, non discreto. Grazie alla nascita di processori più veloci, i sistemi di riconoscimento vocale cominciano a bussare prepotentemente alle porte della vita quotidiana. Un esempio è il VAL, Voice Activated Link progettato dalla BellSouth, un centralino automatico in grado di dare diverse informazioni in base alle richieste poste telefonicamente dall’utente. La vera novità di VAL fu quella di abbattere l’ostacolo del training del sistema per ogni singolo utente: chiunque poteva chiamare e ricevere una risposta pressoché immediata.

Nei primi anni del nuovo millennio le cose cominciano a farsi nettamente più interessanti! I sistemi di riconoscimento vocale sono ormai una realtà consolidata e utilizzati su larga scala: il Nokia 6630 fu tra i primi cellulari ad avere inclusa questa tecnologia.

Google elimina il problema del reperimento dei dati in maniera efficace e veloce andandoseli a prendere letteralmente sulle “nuvole”.

Il resto poi è il presente, fatto dalle offese e le domande no sense che almeno una volta abbiamo fatto a Siri, un “Ok Google” detto troppo forte e lo smartphone che si attiva!

Un mare di problemi.

Siamo andati sulla luna, abbiamo rilevato le onde gravitazionali, la tecnologia ci permette di fare cose impensabili fino a ieri e ancora non riusciamo a sviluppare un sistema di riconoscimento vocale “quasi-perfetto”? Ebbene, ci sono problematiche di fondo enormemente complesse nell’interpretazione di un discorso, tra cui:

  • il rumore di fondo che può essere causato dall’ambiente circostante o dalla stessa persona che parla;
  • il carattere continuo del discorso: quando parliamo emettiamo un segnale sostanzialmente continuo che rende complicato l’isolamento di ogni parola emessa;
  • la dipendenza da chi emette il suono: la stessa parola può essere pronunciata differentemente a seconda dell’età, del sesso, della velocità del parlato, delle condizioni emotive e di molti altri fattori legati all’utente.

Come viene “rappresentato” il discorso?

Per cominciare bisogna creare una sorta di ponte tra il parlato e il computer, tradurre cioè i nostri suoni in segnali comprensibili dalla macchina. Cosa sia un segnale è ben spiegato qui, quindi non ci ripetiamo ma accenniamo soltanto a dei concetti fondamentali che useremo in questo post.

Un modo comune per rappresentare un segnale è tramite la sua forma d’onda (waveform): un grafico bidimensionale con il tempo sulle ascisse e l’ampiezza del segnale stesso sulle ordinate. Il problema di questa rappresentazione balza subito all’occhio confrontando i due segnali qui sotto: parole completamente diverse hanno una forma d’onda molto simile. Questo fatto non ci piace per niente!

waveformUn’altra rappresentazione è lo spettrogramma: un grafico a tre dimensioni in cui sull’asse $$x$$ troviamo il tempo, sull’asse $$y$$ la frequenza del segnale e sull’asse $$z$$ l’intensità dello stesso. Solitamente negli spettrogrammi si preferisce tenere una vista $$2D$$: a ciascuna coppia sul piano tempo – frequenza viene associato un colore rappresentante l’intensità del suono. Lo spettrogramma non è altro che la messa in pratica della trasformata di Fourier.

useless

Come si vede dai grafici qui sotto, questa rappresentazione sembra migliore rispetto al grafico precedente tempo-frequenza: almeno distingue due parole differenti!

Spettrogramma

Ma ancora non ci siamo. Lo spettrogramma non è la soluzione ideale, infatti non riesce  a catturare caratteristiche fondamentali di un segnale: la stessa parola può essere pronunciata più o meno velocemente, avere intensità diverse in vari istanti temporali o semplicemente non iniziare nello stesso momento. Purtroppo segnali così processati rimangono scomodi ed è quindi necessario introdurre un nuovo oggetto: il Cepstrum. La definizione di questo oggetto è intrinseca nella parola: il ceps-trum è l’inverso dello spec-trum (chiaro no?!). Prendiamo un segnale $$x(t)$$, applichiamo la trasformata di Fourier ($$F$$), poi il logaritmo e infine antitrasformiamo il tutto ($$F^{-1}$$): otteniamo il cepstrum $$\tilde x$$ del segnale $$x$$

\begin{equation*}\tilde x(t)=F^{-1}[\ln(F[x(t)])].\end{equation*}

Per capire la potenza di questo strumento osserviamo le immagini sotto. A sinistra vediamo gli spettrogrammi della stessa parola relativi a due esperimenti diversi: ci sono troppe differenze! In quelle di destra, invece, notiamo come la rappresentazione tramite cepstrum sia molto simile nei due casi.

spettvsceps

Sebbene la pulizia e pre-elaborazione dei dati sia fondamentale per le prestazioni e l’affidabilità di qualsiasi modello matematico, non ci dilungheremo su questo aspetto. Una volta puliti i dati e capito come tradurre i segnali in linguaggio macchina, bisogna mettere in piedi un algoritmo che sia in grado di apprendere e auto-migliorarsi con l’esperienza. È a questo punto che entrano in gioco le reti neurali.

Le Reti Neurali Artificiali: intro.

L’interesse verso le reti neurali nacque una settantina di anni fa quando si cercava una rappresentazione matematica per il funzionamento del cervello umano. Con il tempo sono state applicate ad una moltitudine di fenomeni non “predicibili” analiticamente: dalla finanza ai videogiochi, dalle automobili allo sport…

Un piccolo excursus biologico

Per poter introdurre concettualmente una rete neurale artificiale è necessario un piccolo richiamo di biologia e alle reti naturali “biologiche”.

Cos’è un neurone?

Un neurone è una unità cellulare che, insieme con altri elementi, compone il sistema nervoso. Esso è formato da un corpo cellulare, detto soma, da cui si dirama un assone. Al soma sono attaccati una sorta di filamenti detti dendriti e a sua volta contiene il nucleo. L’assone e i dendriti terminano con delle strutture di connessione dette sinapsi.

Neurone

Un meccanismo di polarizzazione e depolarizzazione della membrana cellulare genera degli impulsi elettrici che trasportano le informazioni lungo l’assone alla velocità di qualche decina di metri al secondo. Questo aspetto può essere descritto analiticamente per mezzo delle equazioni alle derivate parziali, ma è tutta un’altra storia e magari lo approfondiremo in futuro.

Cos’è una rete neurale?

Dopo aver descritto vagamente la forma di un neurone è facile immaginare cosa sia una rete neurale biologica: un insieme di neuroni interconnessi. Il ruolo di ogni neurone all’interno della rete può essere schematizzato in tre passaggi:

  1. ricevere in input segnali del tipo acceso/spento dai neuroni adiacenti tramite i dendriti;
  2. elaborare la risposta nel soma;
  3. trasmettere la risposta ai neuroni collegati attraverso l’assone.

Sebbene ogni neurone svolga operazioni piuttosto “elementari”, il comportamento estremamente intelligente della rete emerge dal grande numero di elementi e di connessioni tra le cellule. Ci sono infatti circa 100 miliardi di neuroni e 100 bilioni di sinapsi con una media di 1000 connessioni per neurone.

L’apprendimento e la memoria sono rese possibili dalla plasticità delle sinapsi che modula la forza dei collegamenti tra i neuroni, e dalla permeabilità della membrana cellulare che influenza la soglia di reazione. Una rete neurale è dunque una struttura complessa capace di modificarsi in base agli stimoli esterni.

reteneurale

Come funziona una rete artificiale?

Se siete fedeli lettori di questo blog avete già incontrato, nel post sugli algoritmi di classificazione, l’esempio più elementare possibile di rete neurale formata da un singolo neurone: il Percettrone. Immaginamiamo quindi un neurone postsinaptico in collegamento con altri $$n$$ neuroni presinaptici che funzionano  da input per il primo. Gli elementi che entrano in gioco sono:

  1. un vettore di pesi $$\theta=\left(\theta_0,…,\theta_n\right)\in \mathbb{R}^{n+1}$$ che rappresenta la “forza” delle connessioni tra il neurone e gli altri $$n$$ adiacenti (il parametro $$\theta_0$$ è detto bias, vedremo a breve in che modo sia legato alla soglia di attivazione del neurone);
  2. un vettore di segnali-input $$\left(x_0,…,x_n\right)\in \mathbb R^{n+1}$$ con $$x_0=1$$;
  3. una funzione di attivazione $$\sigma$$ che opera sulla somma pesata delle componenti di $$x$$ con pesi $$\theta$$ fornendo l’output $$y\in\{1,0\}$$ (on/off) del neurone in questione.

 Come viene elaborata la risposta agli input ricevuti dal neurone?

Se la somma pesata dei segnali in entrata supera una certa soglia di attivazione $$h$$ peculiare  del neurone, esso si attiva e invia uno spike, altrimenti rimane in stato passivo:
\begin{equation*} z=
\begin{cases}
1 \mbox{ (attivo) se } \sum_{i=1}^n \theta_i x_i\ge h\\
0 \mbox{ (passivo) se } \sum_{i=1}^n \theta_i x_i< h;
\end{cases}
\end{equation*}
che può essere riscritta, posti $$\theta_0=-h$$ e $$x_0=1$$,  come
\begin{equation*}z=
\begin{cases}
1 \mbox{ se } \sum_{i=0}^n \theta_i x_i\ge 0\\
0 \mbox{ se } \sum_{i=0}^n \theta_i x_i< 0.
\end{cases}
\end{equation*}
Cioè a dire
\begin{equation*}z=H(\theta\cdot x)\end{equation*}
dove $$H$$ rappresenta la funzione di Heaviside
\begin{equation*}H(x)=
\begin{cases}
1 \mbox{ se } x\ge 0\\
0 \mbox{ se } x< 0.
\end{cases}
\end{equation*}
Questo ragionamento ci porta a definire una funzione di attivazione come una funzione $$\sigma: \mathbb{R}\to [0,1]$$ tale che:

  1. sia continua;
  2. sia derivabile con derivata $$\sigma’$$ calcolabile;
  3. sia non decrescente ($$\sigma’(x)\ge 0$$ per ogni $$x$$);
  4. abbia asintoti orizzontali a $$0$$ e $$1$$, ossia $$\lim_{x\to -\infty}\sigma(x)=0$$ e $$\lim_{x\to \infty}\sigma(x)=1$$.

Notiamo che la funzione di Heaviside non è una vera e propria funzione di attivazione in quanto non è né derivabile in senso classico né continua. Nel post sugli algoritmi di classificazione citato poco sopra avevamo scelto come un buon esempio di funzione di attivazione la sigmoide
\begin{equation*}\sigma(x)=\frac 1{1+e^{-x}};\end{equation*}
ma non è l’unica possibilità, anzi, la scelta di questa funzione è un elemento chiave nella progettazione del modello. In generale la risposta all’impulso sarà data dal valore
\begin{equation*}f_\theta(x)=\sigma\left(\sum_{i=0}^n \theta_i x_i\right)=\sigma\left(\theta\cdot x\right).\end{equation*}

Una rete neurale artificiale, dunque, non è altro che un insieme di neuroni collegati tra di loro in modo da non formare cicli, ossia un grafo diretto aciclico, in cui l’output di un neurone funziona da input per i neuroni post-sinaptici cui è collegato.

Vediamo un esempio di rete neurale a tre strati con un totale di otto neuroni: 3 nel primo strato (o layer), 4 nel secondo e 1 nel terzo.

1

I componenti $$a_0^{(1)}$$ e $$a_0^{(2)}$$ rappresentati da un cerchio bianco con il bordo blu non sono neuroni, ma le unità di bias introdotte precedentemente e valgono sempre 1. Discorso analogo per la componente $$x_0$$ del vettore in input.

Osserviamo che, essendo l’ultimo layer composto da un singolo neurone, l’output di questa rete può essere soltanto un segnale del tipo  acceso/spento. Per un discorso più generale dovremmo considerare un totale di $$N$$ neuroni nell’ultimo strato: questa struttura permetterebbe di avere un algoritmo di classificazione tra $$N+1$$ classi tramite un vettore di output $$z\in \mathbb R^N$$. Al fine di tenere le notazioni più semplici possibili, nel seguito del post continueremo a pensare $$N=1$$.

Il training della rete.

Supponiamo di avere a disposizione un insieme, detto training set, abbastanza ricco di coppie $$(x_i,y_i)$$ dove $$y_i\in\mathbb{R}$$ rappresenta l’output noto relativo al vettore $$x_i\in\mathbb{R}^{n+1}$$. Tale insieme permette di allenare la rete e fare in modo che predica risultati accettabili su nuovi input $$x$$ non appartenenti al training set.

Nel caso di una rete formata da un solo neurone è molto semplice definire una funzione costo $$J(\theta)$$ e cercare quel valore $$\theta_{min}$$ che minimizzi l’errore sulle coppie $$(x_i,y_i)$$ nel training set.

Nel caso di una rete “vera e propria”, non avendo a disposizione ouput di riferimento intermedi, è più complicato calcolare l’errore su ogni neurone e sfruttarlo per scegliere le giuste connessioni sinaptiche. Tuttavia conosciamo l’errore sull’output finale e in qualche modo ce lo facciamo bastare!

Possiamo quindi immaginare una rete neurale come una black box: un meccanismo di cui ignoriamo il funzionamento interno e vediamo soltanto il risultato finale. È a questo punto che entra in gioco l’algoritmo di backpropagation.

Come procediamo?

In due parole portiamo l’input avanti lungo tutta la rete, calcoliamo l’errore, e infine portiamo l’errore indietro. L’errore calcolato sull’ultimo nodo della rete è causato da una combinazione di quelli sui neuroni interni: tanto più un neurone ha un collegamento sinaptico forte con il suo successivo, tanto più sarà responsabile dell’errore finale. In questo senso è lecito scrivere l’errore finale come una media pesata di tutti gli errori nei layer interni con pesi i coefficienti delle connessioni sinaptiche.

In avanti…

Nei disegni che seguono, i coefficienti $$a_i^{j}$$ rappresentano la risposta del neurone $$i$$-esimo nel $$j$$-esimo layer; calcolato applicando alla somma pesata degli input relativi, con pesi i coefficienti $$\theta_{lk}^m$$,  la funzione di attivazione $$\sigma$$. Definiamo $$\theta_{lk}^m$$ come il peso del collegamento sinaptico tra il neurone $$l$$ del layer $$m$$ e il neurone $$k$$ del layer $$m+1$$.

Il punto di partenza è calcolare la risposta dei neuroni nel primo strato, $$a_i^{(1)}$$, $$i=1, 2, 3$$ al vettore di input $$x=(x_0,x_1,x_2)$$ con $$x_0=1$$. Ad esempio avremo:

\begin{equation} a_1^{(1)}=\sigma(x_0 \theta_{01}^{0}+x_1 \theta_{11}^{0}+x_2 \theta_{21}^{0}) \end{equation}

Con una formula analoga saranno calcolati anche i coefficienti $$a_2^{1}$$ e $$a_3^{1}$$. Riassumendo

\begin{equation} a_j^{(1)}=\sigma\left(\sum_{i=0}^2 x_i\theta_{ij}^0\right) \end{equation}

per $$ j=1,2,3$$.

2

Procediamo calcolando le risposte dei neuroni nel secondo strato:
\begin{equation*} a_j^{(2)}=\sigma\left(\sum_{i=0}^3 a_i^{(1)}\theta_{ij}^1\right) \end{equation*}

per ogni $$j=1,…,4$$.

3

Infine, come ultimo passaggio della prima parte dell’algoritmo, abbiamo l’output

\begin{equation*} z=a_1^3=\sigma\left(\sum_{i=0}^4 a_i^{(2)}\theta_{i1}^2\right)  \end{equation*}

4

…all’indietro.

Per prima cosa definiamo l’errore come la differenza tra l’output noto $$y$$ e quello predetto dalla rete $$z$$:
\begin{equation*} \delta=\delta_1^{(3)}=z-y. \end{equation*}

Propaghiamo tale errore indietro lungo tutta la rete: come vediamo nell’immagine si ha

\begin{equation*} \delta_1^{(2)}=\theta_{11}^2 \delta_1^{(3)}. \end{equation*}

5

Con formule analoghe alle precedenti riusciamo a calcolare di conseguenza tutti i $$\delta_i^{(j)}$$.

6

Avendo ora tutti gli errori su tutti i nodi della rete siamo in grado di poter aggiornare i parametri $$\theta_{ij}^k$$ in modo tale da minimizzare l’errore “complessivo” $$J(\theta)$$ della rete sul training set,  e quindi rendere la rete più efficace nel fare nuove previsioni.

Approfondimenti per i più curiosi:

1. Un articolo divulgativo sull’algoritmo di backpropagation e sua implementazione in Python;

2.  Un approfondimento sul cepstrum;

3. Uno sguardo più ampio (e complicato) sull’argomento da cui partire per studiare seriamente;

4. Un bel libro per approfondire ed uno sul deep learning (di cui non abbiamo mai parlato).

Qualsiasi domanda abbiate non esitate, sarà un piacere scambiare quattro chiacchiere con voi!

PS: Lungo tutto l’articolo ho tralasciato tanti dettagli, soprattutto quelli legati ai tecnicismi degli assistenti vocali (di cui non sono un esperto) e me ne scuso con i lettori, ma l’argomento è stato preso principalmente per contestualizzare ed introdurre concetti più astratti. Ogni post vuole essere soltanto un punto di partenza e non uno di arrivo, nella speranza di attirare la vostra curiosità!

CC BY-NC-SA 4.0
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.