Salta al contenuto
5/30Capitolo 5 di 30

Backpropagation da zero: prima il motore, poi la rete

Scrivi un motore autodiff di 120 righe in Python puro, confrontalo con PyTorch a sedici decimali e scopri cosa fa zero_grad eliminandolo.

In questa pagina

Dopo quattro capitoli, c’è un buco al centro del corso.

Il Capitolo 3 ci ha dato la gradient descent: per migliorare un parametro, trova la pendenza della loss rispetto a quel parametro e fai un passo in discesa. Il Capitolo 4 ci ha dato una loss che vale la pena far scendere. Ma in entrambi, la derivata veniva calcolata a mano: un modello, un parametro, una riga di calcolo, e tutto stava in una pagina.

Ora impila due strati. L’output del primo alimenta il secondo, quindi ogni peso del primo influenza la loss attraverso ogni neurone del secondo. Una rete con due strati nascosti da cento unità ciascuno ha circa ventimila parametri, e ognuno ha bisogno della propria derivata parziale della stessa loss. Farlo a mano non è noioso; è impossibile, e resta impossibile per ogni architettura nel resto di questo corso.

La via d’uscita non è una notazione migliore. È rendersi conto che la derivata di una composizione può essere calcolata meccanicamente, da un programma, a partire dalla struttura del calcolo stesso — e che, se lo fai nella direzione giusta, ottieni tutte le ventimila derivate a un costo simile a quello di calcolare la loss una volta.

Questo meccanismo è la differenziazione automatica in modalità inversa. Applicata a una rete neurale si chiama backpropagation, e alla fine di questo capitolo ne avrai scritta una in circa 120 righe di Python senza librerie, l’avrai confrontata con PyTorch e l’avrai usata per risolvere il problema XOR che aveva ucciso il perceptron nel Capitolo 1.

Prima: perché deve esserci per forza una non linearità

Link alla sezione: Prima: perché deve esserci per forza una non linearità

Prima di costruire la macchina, bisogna chiudere una domanda, perché se la risposta fosse diversa non ci sarebbe nulla da costruire.

Il perceptron ha fallito su XOR perché una sola retta non può separare i quattro punti. La correzione ovvia è impilare: fai passare l’input attraverso uno strato lineare, poi un altro. Aiuta?

No, e la dimostrazione sta in due righe. Uno strato lineare è h=W1x+b1\mathbf{h} = W_1\mathbf{x} + \mathbf{b}_1. Dallo in pasto a un altro, y=W2h+b2\mathbf{y} = W_2\mathbf{h} + \mathbf{b}_2, e sostituisci:

y=W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)\mathbf{y} = W_2(W_1\mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2 = (W_2W_1)\mathbf{x} + (W_2\mathbf{b}_1 + \mathbf{b}_2)

La composizione è Wx+bW\mathbf{x} + \mathbf{b} con W=W2W1W = W_2W_1 e b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2. Una pila di strati lineari è un singolo strato lineare. Dieci, mille: sempre una sola retta, ancora incapace di fare XOR.

Vale la pena vederlo succedere, invece di crederci sulla parola:

linear_is_linear.pyPYTHON
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)

two_layers = W2 @ (W1 @ x + b1) + b2
one_layer  = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))
TEXT
-4.612963371048  -4.612963371048  0.00e+00

Non approssimativamente uguali. Identici bit per bit, perché è la stessa aritmetica riordinata.

Quindi la profondità, da sola, non compra nulla. Quello che compra qualcosa è mettere una funzione non lineare tra gli strati — ed è l’intera ragione per cui esistono le funzioni di attivazione. Non sono un vezzo biologico o un trucco di normalizzazione. Senza una di loro, il secondo strato è decorazione.

La regola della catena, su carta, con un nodo condiviso

Link alla sezione: La regola della catena, su carta, con un nodo condiviso

Ora la matematica, ed è una regola che conosci già applicata in un punto appena meno familiare.

La regola della catena a una variabile dice che se LL dipende da cc e cc dipende da xx, allora dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. Le derivate si moltiplicano lungo una catena.

La parte che conta qui è cosa succede quando una variabile alimenta più di un percorso a valle. Se xx influenza LL tramite aa e anche tramite bb, i contributi si sommano:

dLdx=Laax+Lbbx\frac{dL}{dx} = \frac{\partial L}{\partial a}\frac{\partial a}{\partial x} + \frac{\partial L}{\partial b}\frac{\partial b}{\partial x}

Moltiplica lungo un percorso, somma tra i percorsi. Questa è tutta la backpropagation, e ogni dettaglio implementativo nel resto di questo capitolo — incluso il += nel codice e la chiamata zero_grad() che fa inciampare chiunque scriva il suo primo loop di training — è una conseguenza diretta di quella seconda parola.

Prendi un circuito concreto di cinque operazioni, con x=0.5x = 0.5 e y=1.4y = 1.4:

a=xy,b=x+y,c=ab,d=tanh(c),L=d+xa = xy, \quad b = x + y, \quad c = ab, \quad d = \tanh(c), \quad L = d + x

Nota che xx appare tre volte: in aa, in bb e direttamente in LL. Fai il backward pass su carta, da destra a sinistra, partendo da dLdL=1\frac{dL}{dL} = 1:

L=d+xL = d + x, quindi Ld=1\frac{\partial L}{\partial d} = 1 e il percorso diretto contribuisce Lx=1\frac{\partial L}{\partial x} = 1. L’addizione distribuisce il gradiente in ingresso invariato a entrambi gli input.

d=tanh(c)d = \tanh(c) con c=ab=0.7×1.9=1.33c = ab = 0.7 \times 1.9 = 1.33, quindi dLdc=1tanh2(1.33)=0.2444\frac{dL}{dc} = 1 - \tanh^2(1.33) = 0.2444.

c=abc = ab, quindi dLda=dLdcb=0.2444×1.9=0.4644\frac{dL}{da} = \frac{dL}{dc} \cdot b = 0.2444 \times 1.9 = 0.4644 e dLdb=dLdca=0.2444×0.7=0.1711\frac{dL}{db} = \frac{dL}{dc} \cdot a = 0.2444 \times 0.7 = 0.1711. La moltiplicazione scambia: il gradiente di ogni input è scalato dall’altro input.

Attraverso aa: dLday=0.4644×1.4=0.6501\frac{dL}{da} \cdot y = 0.4644 \times 1.4 = 0.6501. Attraverso bb: dLdb1=0.1711\frac{dL}{db} \cdot 1 = 0.1711. Direttamente: 11.

dLdx=0.6501+0.1711+1.0000=1.8212\frac{dL}{dx} = 0.6501 + 0.1711 + 1.0000 = 1.8212

Tieni a mente quel numero. Tra qualche pagina un programma lo produrrà senza che gli venga detto nulla di tutto questo.

L’intuizione che lo rende programmabile: ognuno di quei passaggi era locale. Per spingere un gradiente attraverso il nodo di moltiplicazione, ti servivano il gradiente in ingresso e i due valori di input salvati — niente del resto del circuito. Ogni operazione sa come differenziare sé stessa.

Quindi crea un numero che ricordi cosa l’ha prodotto.

value.pyPYTHON
class Value:
    """A number that remembers where it came from."""

    def __init__(self, data, _children=(), _op=""):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._prev = set(_children)      
        self._op = _op

Quattro campi. data è il valore. grad accumula Lself\frac{\partial L}{\partial \text{self}}. _prev è l’insieme dei Value da cui questo è stato calcolato — gli archi del grafo. E _backward è una closure che ogni operazione installa: sa come spingere il gradiente di questo nodo un passo indietro verso i suoi input.

Ogni operatore segue la stessa forma: calcola l’output, registra i genitori, installa la regola locale.

value.py (continued)PYTHON
    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), "+")

        def _backward():
            self.grad += out.grad       
            other.grad += out.grad      

        out._backward = _backward
        return out

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), "*")

        def _backward():
            self.grad += other.data * out.grad   
            other.grad += self.data * out.grad   

        out._backward = _backward
        return out

    def tanh(self):
        t = math.tanh(self.data)
        out = Value(t, (self,), "tanh")

        def _backward():
            self.grad += (1 - t * t) * out.grad

        out._backward = _backward
        return out

    def relu(self):
        out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")

        def _backward():
            self.grad += (1.0 if out.data > 0 else 0.0) * out.grad

        out._backward = _backward
        return out

Leggi i quattro corpi _backward come una tabella e gli schemi di flusso della derivazione su carta sono proprio lì:

operazionecosa fa al gradiente
+distribuisce — lo stesso gradiente a ogni input
*scambia — ogni input scalato dal valore dell’altro
reluinstrada — lo lascia passare o lo blocca del tutto
tanhattenua — scala di 1t21 - t^2, che è al massimo 1 e di solito meno

Ognuno usa += e mai =. Questa è la regola «somma tra i percorsi», codificata. Un nodo che alimenta due consumatori viene chiamato due volte, e i due contributi si sommano da soli.

Poi il driver, che è l’unica parte con una qualsiasi conoscenza globale:

value.py (continued)PYTHON
    def backward(self):
        order, seen = [], set()

        def build(v):
            if v in seen:
                return
            seen.add(v)
            for child in v._prev:
                build(child)
            order.append(v)

        build(self)
        self.grad = 1.0
        for v in reversed(order):       
            v._backward()               

build produce un ordinamento topologico del grafo: ogni nodo appare dopo tutti i suoi input. Percorrere quella lista al contrario garantisce che, quando chiami il _backward di un nodo, il suo gradiente sia già completo: ogni consumatore a valle ha già contribuito. Sbaglia l’ordine e spingi all’indietro un gradiente mezzo finito, ottenendo una risposta sbagliata senza nessun messaggio di errore.

check.pyPYTHON
x = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)
TEXT
forward:  a=0.7000  b=1.9000  c=1.3300  d=0.8692  L=1.3692
backward: dL/dd=1.0000  dL/dc=0.2444  dL/da=0.4644  dL/db=0.1711
          dL/dx=1.8212   dL/dy=0.4033

1.8212. Lo stesso numero, da un programma a cui è stata detta la regola per +, la regola per *, la regola per tanh, e nulla su questo circuito.

Due controlli indipendenti, perché «corrisponde a ciò che ho derivato» è un test debole quando la stessa persona ha fatto entrambe le cose.

Differenziazione numerica. Sposta leggermente l’input e misura. La differenza centrata L(x+h)L(xh)2h\frac{L(x+h) - L(x-h)}{2h} stima la derivata senza alcun calcolo simbolico:

TEXT
dL/dx:  analytic=1.821202805  numeric=1.821202805  |diff|=1.80e-10
dL/dy:  analytic=0.403269235  numeric=0.403269235  |diff|=7.64e-12

Contro PyTorch, che ha un motore autodiff industriale scritto da persone che lo fanno di mestiere:

TEXT
torch dL/dx=1.821202805316   ours=1.821202805316   |diff|=2.22e-16
torch dL/dy=0.403269234753   ours=0.403269234753   |diff|=1.11e-16

Accordo a 2×10162 \times 10^{-16}, che è l’epsilon macchina per un float a 64 bit: i due motori stanno eseguendo aritmetica identica. Tieni in tasca il controllo numerico: è lo strumento per fare debug del backward pass di un nuovo layer, ed è il motivo per cui un gradiente sbagliato è trovabile.

Lo stesso circuito, input diversi. Imposta x=2x = 2 e y=3y = -3, il che rende c=6c = 6:

TEXT
x=0.5, y=1.4:  dL/dc = 0.244400     three paths into x:  0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025     three paths into x:  0.0001 + -0.0001 + 1.0000 = 0.9999

Il gradiente che attraversa il nodo tanh\tanh è caduto di un fattore 9.945. Tutto ciò che sta a monte — in una rete reale, ogni layer prima di quel punto — riceve sostanzialmente nulla. I due percorsi attraverso il circuito sono diventati silenziosi; solo la connessione diretta che salta tanh\tanh porta ancora segnale.

Questo è il problema del gradiente che svanisce, in un solo nodo. Impila quaranta layer di tanh\tanh e moltiplica quaranta fattori del genere, e i primi layer smettono del tutto di imparare. È anche, incidentalmente, un argomento a favore delle skip connection che puoi vedere qui in miniatura: il percorso che ha bypassato la non linearità è l’unico sopravvissuto.

Cosa fa davvero zero_grad, e perché il bug si nasconde

Link alla sezione: Cosa fa davvero zero_grad, e perché il bug si nasconde

Ogni _backward usa +=. È corretto: è così che i percorsi si sommano. Ma ha una conseguenza che prende tutti in contropiede: i gradienti si accumulano anche tra chiamate a backward(). Il motore non ha idea che la tua seconda chiamata sia un nuovo step di training invece di un altro percorso nello stesso grafo.

Quindi un loop di training deve azzerarli:

train.pyPYTHON
for step in range(steps):
    ys = [model(x) for x, _ in DATA]
    loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))

    for p in model.parameters():   
        p.grad = 0.0

    loss.backward()
    for p in model.parameters():
        p.data -= lr * p.grad

Questo è optimizer.zero_grad() in PyTorch, e il consiglio abituale è che dimenticarlo rompe il training. Allora cancelliamo quelle due righe e vediamo quanto è rotto. Stessi seed, stesso tutto, 200 step di XOR:

learning rateseedcon resetsenza reset
0.051337loss 3.255088, 3/4loss 0.000000, 4/4
0.057loss 2.144820, 2/4loss 0.000000, 4/4
0.0542loss 2.126074, 2/4loss 0.000000, 4/4
0.11337loss 0.038597, 4/4loss 0.000000, 4/4
0.17loss 2.055048, 2/4loss 0.000000, 4/4
0.142loss 2.049876, 2/4loss 0.000073, 4/4
0.31337loss 4.512310, 2/4loss 8.000000, 2/4
0.37loss 0.015247, 4/4loss 4.000000, 3/4
0.342loss 0.005478, 4/4loss 4.000000, 3/4

Ai learning rate piccoli, la versione con bug vince ogni singola riga. Converge quando la versione corretta si blocca.

Non è un caso, e vale la pena capirlo, perché spiega perché questo bug è così difficile da cogliere. Se non azzeri mai il gradiente, allora allo step kk il parametro viene aggiornato dalla somma di ogni gradiente calcolato fino a quel momento. Su una loss che continua a puntare più o meno nella stessa direzione, quella somma cresce costantemente, e l’effetto è un learning rate che aumenta da solo. A η=0.05\eta = 0.05, dove l’algoritmo corretto avanza a fatica, la dimensione del passo fuori controllo sembra esattamente una soluzione.

Poi guarda le tre righe in fondo. A η=0.3\eta = 0.3 lo stesso meccanismo manda in pezzi il modello: loss 8.0 è il punteggio di un modello collassato a una costante ±1\pm 1 — metà dei 16 che costerebbero quattro risposte massimamente sbagliate — — mentre la versione corretta ora converge in modo pulito.

Quindi la formulazione onesta non è «chiama sempre zero_grad o il tuo modello non farà training». È: senza, non stai più eseguendo gradient descent. Stai eseguendo qualcosa la cui dimensione del passo deriva verso l’alto a una velocità che nessuno ha scelto, e sembrerà funzionare, a volte meglio della cosa vera, fino al momento in cui non funzionerà più — e a quel punto darai la colpa al learning rate, all’inizializzazione o ai dati. Questa è la forma dei bug peggiori nel machine learning: non fanno crash, cambiano l’algoritmo in un algoritmo diverso che ogni tanto ottiene risultati migliori.

Con il motore pronto, una rete neurale è pochissimo codice. Un neurone è un prodotto scalare, un bias e un’attivazione; un layer è una lista di neuroni; una rete è una lista di layer.

nn.pyPYTHON
class Neuron:
    def __init__(self, nin):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
        self.b = Value(0.0)

    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        return act.tanh()

    def parameters(self):
        return self.w + [self.b]


class Layer:
    def __init__(self, nin, nout):
        self.neurons = [Neuron(nin) for _ in range(nout)]

    def __call__(self, x):
        out = [n(x) for n in self.neurons]
        return out[0] if len(out) == 1 else out

    def parameters(self):
        return [p for n in self.neurons for p in n.parameters()]


class MLP:
    def __init__(self, nin, nouts):
        sizes = [nin] + nouts
        self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]

    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x

    def parameters(self):
        return [p for layer in self.layers for p in layer.parameters()]

Non c’è backward pass in nulla di tutto questo. Nemmeno una riga. La classe Value sa già differenziare qualsiasi cosa queste classi si trovino a costruire, ed è questo il punto di averla scritta per prima: un motore autodiff non sa di essere usato per una rete neurale.

Ora il problema del Capitolo 1. Due input, due unità nascoste, un output, nove parametri:

TEXT
step   1: loss 4.156690
step  10: loss 4.005572
step  50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597

[0, 0] -> -0.9081  (target -1)  ok
[0, 1] -> +0.8934  (target +1)  ok
[1, 0] -> +0.8906  (target +1)  ok
[1, 1] -> -0.9207  (target -1)  ok

Quattro su quattro. La funzione che nessun perceptron può calcolare — dimostrato nel Capitolo 1 con quattro disuguaglianze che richiedevano che bb fosse sia positivo sia negativo — viene calcolata da nove numeri trovati automaticamente.

La parte soddisfacente non è che funzioni. È poter vedere come, perché con due unità nascoste la rappresentazione intermedia è un punto in un piano e puoi semplicemente stamparla.

Addestrata fino a una loss di 0.001241, ecco dove finisce ogni input dopo il layer nascosto, e cosa ne fa il neurone di output:

inputoutput del layer nascostopunteggio di outputetichetta
(0,0)(0, 0)(+0.8206, 0.8474)(+0.8206,\ -0.8474)2.4045-2.40451-1
(0,1)(0, 1)(+0.9985, +0.8564)(+0.9985,\ +0.8564)+2.3049+2.3049+1+1
(1,0)(1, 0)(0.8401, 0.9990)(-0.8401,\ -0.9990)+2.3006+2.3006+1+1
(1,1)(1, 1)(+0.8368, 0.8550)(+0.8368,\ -0.8550)2.4786-2.47861-1

Guarda la prima e la quarta riga. Gli input (0,0)(0,0) e (1,1)(1,1) sono angoli diagonalmente opposti del quadrato — tanto lontani quanto possono esserlo due punti in questo problema — e il layer nascosto li mappa in (0.82,0.85)(0.82, -0.85) e (0.84,0.86)(0.84, -0.86). Quasi lo stesso punto. Il layer ha ripiegato il piano in modo che i due angoli rifiutati finiscano uno sopra l’altro, e una volta che sono nello stesso posto, una sola retta li separa dagli altri due.

E il neurone di output è esattamente quella retta. I suoi parametri appresi sono w=(3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893), b=+2.7697b = +2.7697, quindi il suo confine decisionale è

3.1153h1+3.0893h2+2.7697=0-3.1153\,h_1 + 3.0893\,h_2 + 2.7697 = 0

che è una retta: un perceptron, lo stesso oggetto del Capitolo 1, invariato. Allora non poteva risolvere XOR e non può farlo ora. Ciò che è cambiato è che non sta più guardando l’input; sta guardando uno spazio che il primo layer ha costruito per lui, in cui il problema è linearmente separabile.

Questo è ciò che è una rappresentazione appresa, e vale la pena essere precisi perché l’espressione verrà usata con leggerezza per il resto di questo corso, e per il resto del campo. Non è una compressione, un riassunto o un embedding in senso mistico. È un cambio di coordinate, appreso invece che progettato, il cui unico compito è rendere facile il lavoro del layer successivo.

Il teorema di approssimazione universale, e cosa non dice

Link alla sezione: Il teorema di approssimazione universale, e cosa non dice

Qui c’è un teorema, e di solito viene citato male.

Cybenko nel 1989 e Hornik nel 1991 dimostrarono che una rete feedforward con un singolo layer nascosto e una funzione di attivazione adatta può approssimare qualsiasi funzione continua su un insieme compatto, con la precisione che vuoi, dato un numero sufficiente di unità nascoste.34 È un risultato autentico e importante: dice che l’architettura non è il limite.

Ora leggi cosa omette. Non dice quante unità: il limite può essere astronomicamente grande. Non dice che i pesi possano essere trovati; afferma l’esistenza, e gradient descent da un punto di partenza casuale non è un oracolo. E non dice nulla sul comportamento su dati che non hai visto, che è la seconda metà del Capitolo 6.

Il divario tra «esiste» e «trovabile» non è accademico. Ecco lo stesso problema XOR, 50 inizializzazioni casuali ciascuna, 1000 step, con solo la dimensione del layer nascosto cambiata:

unità nascosteinizializzazioni che raggiungono 4/4
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 50 (94 %)

Con l’architettura minima praticabile, una run su quattro non ci arriva mai: si assesta in una configurazione da cui non riesce a scendere, esattamente il minimo locale che il Capitolo 3 ha mostrato su una superficie unidimensionale. Aggiungi un’unità e i fallimenti quasi scompaiono, non perché la rete sia diventata più espressiva (due unità bastano già: 38 run lo dimostrano) ma perché dimensioni extra danno alla discesa più direzioni attraverso cui scappare.

E poi otto unità va leggermente peggio di quattro. A learning rate e budget di step fissi, più capacità non è monotonicamente meglio. Chiunque ti dica che la soluzione per una rete bloccata è sempre una rete più grande sta estrapolando dal centro di quella tabella.

È la stessa lezione del teorema di convergenza nel Capitolo 1, e sarà la stessa lezione nel Capitolo 10 sulle scaling law, nella forma che quel capitolo le dà: una previsione della loss non è una previsione della capability per cui stai pagando, e la distanza tra le due è il luogo in cui vive l’ingegneria.

Mostra dettagli

Opzionale: la forma matriciale, e perché il codice sopra non la usa.

Qui tutto è stato scritto uno scalare alla volta, che è il modo più chiaro per vedere il meccanismo e il più lento per eseguirlo. In pratica un layer è una moltiplicazione di matrici, e il backward pass di y=Wx\mathbf{y} = W\mathbf{x} è

LW=Lyx,Lx=WLy\frac{\partial L}{\partial W} = \frac{\partial L}{\partial \mathbf{y}}\mathbf{x}^\top, \qquad \frac{\partial L}{\partial \mathbf{x}} = W^\top\frac{\partial L}{\partial \mathbf{y}}

Le trasposte non sono un trucco da ricordare; sono l’aspetto che assume la regola della somma sui percorsi quando i percorsi sono indicizzati dalle voci della matrice. L’oggetto generale è la Jacobiana, la matrice di tutte le derivate parziali di tutti gli output rispetto a tutti gli input, e la modalità inversa è precisamente il calcolo di un prodotto vettore-Jacobiana senza mai formare la Jacobiana — cosa che conta, perché per un layer con 4096 input e 4096 output quella matrice ha sedici milioni di elementi e non vale mai la pena costruirla.

Non ti serve nulla di questo per seguire i prossimi capitoli; la versione scalare fa tutto ciò che fa la versione matriciale, più lentamente. Diventa necessaria nel Capitolo 9, dove le forme smettono di essere ovvie.

Ora hai una rete che si addestra. È un risultato più piccolo di quanto sembri, perché la rete che hai si addestra su quattro esempi e viene misurata sugli stessi quattro.

Esegui lo stesso codice su un dataset reale e appare una nuova serie di problemi, nessuno dei quali riguarda i gradienti. La loss scende per un po’ e poi si ferma. Oppure scende sui dati di training e sale su tutto il resto. Oppure non si muove affatto dal primo step, e la causa si rivela essere l’intervallo dei pesi casuali iniziali. Oppure l’input di un’unità è scivolato in negativo su ogni esempio nell’epoca tre ed è rimasto morto da allora, in silenzio, portandosi via un pezzo della capacità del modello.

Non sono fallimenti esotici; sono la condizione normale di una rete appena scritta, e nessuno di loro si annuncia. Il gradiente è corretto — l’hai confrontato con PyTorch fino a sedici cifre decimali — e il modello comunque non impara.

Il Capitolo 6 parla di questo: inizializzazione, normalizzazione, overfitting e regolarizzazione, e dell’abitudine diagnostica di chiedersi quale di queste cose stia succedendo prima di cambiare qualsiasi cosa. È la differenza tra una rete che gira e una rete che funziona.


La classe Value in questo capitolo discende direttamente da micrograd di Andrej Karpathy, e il suo video The spelled-out intro to neural networks and backpropagation: building micrograd è il miglior modo di spendere tre ore su questo materiale se vuoi sentirlo spiegare in un secondo modo da qualcun altro. Il suo post del 2016 Yes you should understand backprop argomenta perché dovresti scriverne una tu stesso ed è lettura assegnata nel CS224n di Stanford. Le note di CS231n sulla backpropagation (cs231n.github.io/optimization-2) sono il trattamento canonico degli schemi di flusso tabulati sopra. Per la matematica come calcolo su un grafo invece che come folklore delle reti neurali, il capitolo 5.6 di Mathematics for Machine Learning di Deisenroth, Faisal e Ong è insolitamente chiaro; e la survey di Baydin, Pearlmutter, Radul e Siskind Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767) è il riferimento per l’intero campo, incluso il compromesso forward/reverse discusso sopra.

  1. Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Tesi di master, University of Helsinki (1970). Accumulo in modalità inversa, sedici anni prima che arrivasse in questo campo e con una motivazione completamente diversa.

  2. Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). Il paper che rese noto il metodo, e la fonte della lettura delle unità nascoste come rappresentazioni apprese su cui la sezione Cosa ha fatto il layer nascosto di questo capitolo basa le sue misurazioni.

  3. Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989).

  4. Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). Generalizza Cybenko: il risultato dipende dal fatto che l’attivazione sia non polinomiale, non dal fatto che sia sigmoide.

Pronto a lasciare scegliere LIA?

Crea con ogni modello AI in un unico posto — inizia gratis oggi.