Vai al contenuto
Machine Learning e Reti Neurali

Sequenze: reti ricorrenti, gradiente che svanisce, LSTM, attenzione e transformer

Le reti ricorrenti e la retropropagazione nel tempo, il gradiente che svanisce dimostrato e misurato, la LSTM, l'attenzione con la dimostrazione della divisione per la radice di d_k, il transformer e la codifica della posizione.

Testi, segnali audio, serie temporali di sensori, traiettorie: molti dati sono sequenze, in cui conta l’ordine e la lunghezza varia. Questa lezione presenta le due famiglie di reti per le sequenze. Le reti ricorrenti (RNN) leggono un elemento alla volta e portano con sé uno stato; si dimostra perché il loro gradiente svanisce sulle sequenze lunghe e come le LSTM lo evitano. I transformer (2017) guardano tutta la sequenza insieme con il meccanismo dell’attenzione; si dimostra perché il prodotto scalare va diviso per \sqrt{d_k}. I transformer sono oggi la base dei modelli linguistici e si usano sempre più anche per immagini e segnali.

Le reti ricorrenti

Una RNN aggiorna uno stato nascosto \mathbf h_t a ogni elemento \mathbf x_t della sequenza, con gli stessi pesi a ogni passo:

    \[ \mathbf h_t = \tanh(W\mathbf h_{t-1} + U\mathbf x_t + \mathbf b) \]

È un sistema dinamico non lineare a tempo discreto, come quelli di Teoria dei sistemi, con i parametri imparati. Per addestrarla si “srotola” la rete nel tempo (una copia per passo) e si applica la retropropagazione: è la retropropagazione nel tempo (BPTT).

Teorema (il gradiente che svanisce o esplode). \dfrac{\partial\mathbf h_T}{\partial\mathbf h_0} = \prod_{t=1}^{T}\text{diag}\big(\tanh'(\mathbf z_t)\big)\,W, quindi \Big\|\dfrac{\partial\mathbf h_T}{\partial\mathbf h_0}\Big\| \le \big(\|W\|\cdot\max|\tanh'|\big)^T.

Dimostrazione. Per la regola della catena \frac{\partial\mathbf h_T}{\partial\mathbf h_0} = \prod_t\frac{\partial\mathbf h_t}{\partial\mathbf h_{t-1}}, e derivando \mathbf h_t = \tanh(\mathbf z_t) con \mathbf z_t = W\mathbf h_{t-1} + \dots si ottiene \frac{\partial\mathbf h_t}{\partial\mathbf h_{t-1}} = \text{diag}(\tanh'(\mathbf z_t))\,W. La norma di un prodotto è al più il prodotto delle norme, e |\tanh'| \le 1. ∎

Se \|W\|\cdot\max|\tanh'| \lt  1 il gradiente cala esponenzialmente con la distanza: la rete non riesce a imparare che un ingresso lontano nel passato determina l’uscita di oggi. Se è maggiore di 1 può esplodere (lo si limita tagliando la norma del gradiente, come nel PPO).

La LSTM

La LSTM (long short-term memory, Hochreiter e Schmidhuber, 1997) aggiunge una cella di memoria \mathbf c_t controllata da tre porte (valori tra 0 e 1 dati da sigmoidi):

    \[ \mathbf f_t,\ \mathbf i_t,\ \mathbf o_t = \sigma(\dots),\qquad \mathbf c_t = \mathbf f_t\odot\mathbf c_{t-1} + \mathbf i_t\odot\tanh(\dots),\qquad \mathbf h_t = \mathbf o_t\odot\tanh(\mathbf c_t) \]

La porta di oblio \mathbf f decide quanto della memoria tenere, quella d’ingresso \mathbf i quanto scriverci, quella d’uscita \mathbf o quanto mostrarne. Il punto chiave è che la cella si aggiorna per somma: \partial\mathbf c_t/\partial\mathbf c_{t-1} = \text{diag}(\mathbf f_t), senza moltiplicazioni per W né derivate di funzioni che saturano. Se la rete impara \mathbf f \approx 1, il gradiente attraversa molti passi quasi intatto. La GRU (2014) è una variante più semplice con due porte.

La misura. Per reti appena inizializzate (stato di 32 neuroni, media su 10 inizializzazioni) si calcola con la differenziazione automatica quanto l’uscita all’ultimo passo dipende dal primo ingresso:

lunghezza TRNNLSTM
14{,}6\cdot 10^{-1}8{,}1\cdot 10^{-2}
102{,}0\cdot 10^{-3}1{,}9\cdot 10^{-2}
201{,}4\cdot 10^{-5}2{,}9\cdot 10^{-3}
501{,}4\cdot 10^{-11}3{,}3\cdot 10^{-5}
1003{,}6\cdot 10^{-21}4{,}6\cdot 10^{-8}
Sensibilità dell'uscita finale al primo ingresso in funzione della lunghezza della sequenza
In scala logaritmica la RNN, rossa, cala come una retta ripida fino a 10 alla meno 21 dopo 100 passi; la LSTM, blu, cala molto più lentamente e dopo 100 passi è ancora a 10 alla meno 8.

Nella RNN il segnale si riduce di circa un fattore 0,6 a ogni passo (la retta in scala logaritmica); dopo 100 passi è 10^{13} volte più debole che nella LSTM.

L’attenzione

Anche la LSTM fatica oltre qualche centinaio di passi, e soprattutto lavora in sequenza: il passo t aspetta il passo t - 1, e non si può parallelizzare sulla GPU. L’attenzione collega direttamente ogni posizione con tutte le altre. Ogni elemento produce tre vettori: una domanda (query) \mathbf q, una chiave (key) \mathbf k e un valore (value) \mathbf v, con matrici di pesi imparate. L’uscita per una posizione è una media dei valori di tutte le posizioni, pesata da quanto la sua domanda “somiglia” a ciascuna chiave:

    \[ \text{Attention}(Q, K, V) = \text{softmax}\Big(\frac{QK^T}{\sqrt{d_k}}\Big)V \]

dove le righe di Q, K, V sono le domande, le chiavi e i valori delle n posizioni, e d_k è la dimensione delle chiavi.

Teorema (perché si divide per \sqrt{d_k}). Se le componenti di \mathbf q e \mathbf k sono indipendenti con media 0 e varianza 1, il prodotto scalare \mathbf q^T\mathbf k ha media 0 e varianza d_k.

Dimostrazione. \mathbf q^T\mathbf k = \sum_{i=1}^{d_k}q_ik_i. Ogni termine ha media \mathbb E[q_i]\mathbb E[k_i] = 0 e varianza \mathbb E[q_i^2]\mathbb E[k_i^2] = 1; i termini sono indipendenti, quindi le varianze si sommano: d_k. ∎

Con d_k = 512 i punteggi avrebbero deviazione standard circa 23: la softmax di numeri così grandi è quasi un “uno solo, tutti gli altri zero”, e il suo gradiente è quasi nullo. Dividendo per \sqrt{d_k} la varianza torna 1. In simulazione (codice in fondo): varianza 4,0, 64,0 e 509 per d_k = 4, 64, 512, e 1,00, 1,00, 0,99 dopo la divisione.

Una matrice di attenzione
Ogni riga è una posizione che “chiede”, ogni colonna una posizione che “risponde”; il colore indica il peso dato dalla softmax. Le righe sommano a 1: ogni uscita è una media pesata dei valori di tutte le posizioni.

Il transformer

Il transformer (Vaswani e altri, 2017, “Attention is all you need”) impila blocchi uguali, ognuno con:

  • attenzione a più teste: diverse attenzioni in parallelo, con matrici diverse, che possono specializzarsi (una segue la sintassi, un’altra i riferimenti);
  • una piccola rete densa applicata a ogni posizione separatamente;
  • connessioni residue e normalizzazione (layer norm) intorno a entrambi, come nelle ResNet.

L’attenzione non sa niente dell’ordine (permutando le posizioni le uscite si permutano allo stesso modo), quindi si aggiunge agli ingressi una codifica della posizione: nel lavoro originale sinusoidi di frequenze diverse, oggi spesso rotazioni (RoPE). Il costo dell’attenzione cresce come n^2 con la lunghezza n della sequenza, ed è il limite principale dei modelli con contesti lunghi. In cambio tutte le posizioni si calcolano in parallelo, ed è questo, più di ogni altra cosa, che ha permesso di addestrare modelli con centinaia di miliardi di parametri. Per generare testo si usa una maschera causale: ogni posizione può guardare solo quelle precedenti, e il modello impara a prevedere la parola successiva.

Il codice

import numpy as np
import torch, torch.nn as nn

# 1) attenzione in NumPy
def attenzione(Q, K, V):
    punteggi = Q @ K.T / np.sqrt(K.shape[1])
    pesi = np.exp(punteggi - punteggi.max(1, keepdims=True))
    pesi /= pesi.sum(1, keepdims=True)                     # softmax per righe
    return pesi @ V, pesi

g = np.random.default_rng(0)
X = g.normal(0, 1, (6, 16))                                # 6 posizioni, 16 dimensioni
Wq, Wk, Wv = (g.normal(0, 0.25, (16, 8)) for _ in range(3))
uscita, pesi = attenzione(X @ Wq, X @ Wk, X @ Wv)
print("uscita", uscita.shape, "- le righe dei pesi sommano a", np.round(pesi.sum(1), 6))

# 2) varianza del prodotto scalare prima e dopo la divisione per sqrt(d_k)
for dk in (4, 64, 512):
    q, k = g.normal(0, 1, (20000, dk)), g.normal(0, 1, (20000, dk))
    s = (q * k).sum(1)
    print(f"d_k = {dk:3d}: varianza {s.var():6.1f}, dopo la divisione {(s / np.sqrt(dk)).var():.3f}")

# 3) sensibilità dell'ultima uscita al primo ingresso, RNN contro LSTM appena inizializzate
def sensibilita(tipo, T, H=32, inizializzazioni=10):
    valori = []
    for s in range(inizializzazioni):
        torch.manual_seed(s)
        rete = (nn.RNN if tipo == "rnn" else nn.LSTM)(1, H, batch_first=True)
        if tipo == "lstm":                                 # porta di oblio inizialmente aperta (bias 1)
            for nome, p in rete.named_parameters():
                if "bias_ih" in nome:
                    p.data[H:2 * H] = 1.0
        x = torch.randn(1, T, 1, requires_grad=True)
        uscite, _ = rete(x)
        uscite[0, -1].sum().backward()
        valori.append(x.grad[0, 0].abs().item())
    return sum(valori) / len(valori)

for T in (1, 10, 20, 50, 100):
    print(f"T = {T:3d}: RNN {sensibilita('rnn', T):.1e}, LSTM {sensibilita('lstm', T):.1e}")
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x
()
x