Naar inhoud springen
5/30Hoofdstuk 5 van 30

Backpropagation vanaf nul: eerst de engine, dan het netwerk

Bouw een autodiff-engine van 120 regels in pure Python, vergelijk hem met PyTorch tot 16 decimalen en leer wat zero_grad doet door het te verwijderen.

Op deze pagina

Vier hoofdstukken verder zit er een gat in het midden van de cursus.

Hoofdstuk 3 gaf ons gradient descent: om een parameter te verbeteren, vind je de helling van de loss ten opzichte van die parameter en zet je een stap bergaf. Hoofdstuk 4 gaf ons een loss die het dalen waard is. Maar in beide gevallen werd de afgeleide met de hand berekend — één model, één parameter, één regel calculus, en het paste op een pagina.

Stapel nu twee lagen. De output van de eerste voedt de tweede, dus elk gewicht in de eerste beïnvloedt de loss via elk neuron in de tweede. Een netwerk met twee verborgen lagen van honderd units elk heeft ongeveer twintigduizend parameters, en elke daarvan heeft zijn eigen partiële afgeleide van dezelfde loss nodig. Dat met de hand doen is niet saai; het is onmogelijk, en het blijft onmogelijk voor elke architectuur in de rest van deze cursus.

De uitweg is geen betere notatie. Het is het inzicht dat de afgeleide van een compositie mechanisch kan worden berekend, door een programma, uit de structuur van de berekening zelf — en dat je, als je het in de juiste richting doet, alle twintigduizend afgeleiden krijgt voor ongeveer de kosten van één keer de loss berekenen.

Dat mechanisme is reverse-mode automatic differentiation. Toegepast op een neural network heet het backpropagation, en aan het eind van dit hoofdstuk heb je er een geschreven in ongeveer 120 regels Python zonder libraries, gecontroleerd tegenover PyTorch, en gebruikt om het XOR-probleem op te lossen dat de perceptron in Hoofdstuk 1 de das omdeed.

Eerst: waarom er überhaupt een non-lineariteit moet zijn

Link naar de sectie: Eerst: waarom er überhaupt een non-lineariteit moet zijn

Voordat we de machine bouwen, moet één vraag worden beantwoord, want als het antwoord anders was geweest, was er niets om te bouwen.

De perceptron faalde op XOR omdat één lijn de vier punten niet kan scheiden. De voor de hand liggende oplossing is stapelen: stuur de input door één lineaire laag, en daarna door nog een. Helpt dat?

Nee, en het bewijs is twee regels. Een lineaire laag is h=W1x+b1\mathbf{h} = W_1\mathbf{x} + \mathbf{b}_1. Voer die aan een andere, y=W2h+b2\mathbf{y} = W_2\mathbf{h} + \mathbf{b}_2, en substitueer:

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)

De compositie is Wx+bW\mathbf{x} + \mathbf{b} met W=W2W1W = W_2W_1 en b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2. Een stapel lineaire lagen is één lineaire laag. Tien ervan, duizend ervan: nog steeds één lijn, nog steeds niet in staat om XOR te doen.

Het is de moeite waard om dat te zien gebeuren in plaats van het te geloven:

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

Niet ongeveer gelijk. Bit-voor-bit identiek, omdat het dezelfde rekenkunde is, alleen herschikt.

Diepte levert dus op zichzelf niets op. Wat wel iets oplevert, is een niet-lineaire functie tussen de lagen plaatsen — en dat is de hele reden dat activation functions bestaan. Ze zijn geen biologische versiering of een normalisatietruc. Zonder zo’n functie is de tweede laag decoratie.

De kettingregel, op papier, met een gedeelde node

Link naar de sectie: De kettingregel, op papier, met een gedeelde node

Nu de wiskunde, en het is één regel die je al kent, toegepast op een plek die net wat minder vertrouwd is.

De kettingregel voor één variabele zegt dat als LL afhangt van cc en cc afhangt van xx, dan dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. Afgeleiden vermenigvuldigen langs een keten.

Het deel dat hier belangrijk is, is wat er gebeurt wanneer een variabele meer dan één downstream pad voedt. Als xx LL beïnvloedt via aa en ook via bb, dan tellen de bijdragen op:

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}

Vermenigvuldig langs een pad, sommeer over paden. Dat is heel backpropagation, en elk implementatiedetail in de rest van dit hoofdstuk — inclusief de += in de code en de zero_grad()-aanroep waar iedereen over struikelt die zijn eerste training loop schrijft — is een direct gevolg van dat tweede woord.

Neem een concreet circuit van vijf operaties, met x=0.5x = 0.5 en 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

Merk op dat xx drie keer voorkomt: in aa, in bb, en rechtstreeks in LL. Doe de backward pass op papier, van rechts naar links, beginnend bij dLdL=1\frac{dL}{dL} = 1:

L=d+xL = d + x, dus Ld=1\frac{\partial L}{\partial d} = 1 en het directe pad draagt Lx=1\frac{\partial L}{\partial x} = 1 bij. Optelling verdeelt de binnenkomende gradient onveranderd naar beide inputs.

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

c=abc = ab, dus dLda=dLdcb=0.2444×1.9=0.4644\frac{dL}{da} = \frac{dL}{dc} \cdot b = 0.2444 \times 1.9 = 0.4644 en dLdb=dLdca=0.2444×0.7=0.1711\frac{dL}{db} = \frac{dL}{dc} \cdot a = 0.2444 \times 0.7 = 0.1711. Vermenigvuldiging wisselt om: de gradient van elke input wordt geschaald door de andere input.

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

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

Onthoud dat getal. Over een paar pagina’s gaat een programma het produceren zonder dat het hiervan iets is verteld.

Het inzicht dat dit programmeerbaar maakt: elke van die stappen was lokaal. Om een gradient door de vermenigvuldigingsnode te duwen, had je de binnenkomende gradient en de twee opgeslagen inputwaarden nodig — niets over de rest van het circuit. Elke operatie weet hoe hij zichzelf moet differentiëren.

Maak dus een getal dat onthoudt wat het heeft geproduceerd.

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

Vier velden. data is de waarde. grad accumuleert Lself\frac{\partial L}{\partial \text{self}}. _prev is de verzameling Values waaruit deze is berekend — de randen van de grafiek. En _backward is een closure die elke operatie installeert: die weet hoe hij de gradient van deze node één stap terug naar zijn inputs moet duwen.

Elke operator volgt dezelfde vorm: bereken de output, noteer de parents, installeer de lokale regel.

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

Lees de vier _backward-bodies als een tabel en de flowpatronen uit de papieren afleiding staan er meteen:

operatiewat die met de gradient doet
+verdeelt — dezelfde gradient naar elke input
*wisselt om — elke input geschaald door de waarde van de andere
reluroutet — laat hem door of blokkeert hem volledig
tanhverzwakt — schaalt met 1t21 - t^2, wat hoogstens 1 is en meestal minder

Elke gebruikt += en nooit =. Dat is de regel „sommeer over paden”, gecodeerd. Een node die twee consumers voedt, wordt twee keer aangeroepen, en de twee bijdragen tellen vanzelf op.

Dan de driver, het enige deel met globale kennis:

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 produceert een topologische ordening van de grafiek: elke node verschijnt na al zijn inputs. Door die lijst omgekeerd te doorlopen, garandeer je dat wanneer je de _backward van een node aanroept, zijn eigen gradient al compleet is — elke consumer downstream ervan heeft al bijgedragen. Doe je de volgorde verkeerd, dan duw je een half afgemaakte gradient achteruit, wat een verkeerd antwoord oplevert zonder foutmelding.

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. Hetzelfde getal, uit een programma dat de regel voor + kreeg, de regel voor *, de regel voor tanh, en niets over dit circuit.

Twee onafhankelijke controles, omdat „het klopt met wat ik heb afgeleid” een zwakke test is wanneer dezelfde persoon beide deed.

Numerieke differentiatie. Geef de input een duwtje en meet. Het centrale verschil L(x+h)L(xh)2h\frac{L(x+h) - L(x-h)}{2h} schat de afgeleide zonder enige calculus:

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

Tegen PyTorch, dat een industriële autodiff-engine heeft, geschreven door mensen die dit voor hun werk doen:

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

Overeenstemming op 2×10162 \times 10^{-16}, wat machine epsilon is voor een 64-bit float: de twee engines voeren identieke rekenkunde uit. Houd de numerieke check bij de hand — het is de tool om de backward pass van een nieuwe laag te debuggen, en het is de reden dat een verkeerde gradient überhaupt vindbaar is.

Hetzelfde circuit, andere inputs. Zet x=2x = 2 en y=3y = -3, wat c=6c = 6 maakt:

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

De gradient die de tanh\tanh-node passeert, viel met een factor 9.945. Alles upstream ervan — in een echt netwerk elke laag ervoor — ontvangt vrijwel niets. De twee paden door het circuit zijn stilgevallen; alleen de directe verbinding die de tanh\tanh overslaat, draagt nog signaal.

Dat is het vanishing gradient-probleem, in één node. Stapel veertig lagen tanh\tanh en vermenigvuldig veertig van zulke factoren met elkaar, en de vroege lagen stoppen volledig met leren. Het is trouwens ook een argument voor skip connections dat je hier in het klein kunt zien: het pad dat de non-lineariteit omzeilde, is het enige dat overleefde.

Wat zero_grad werkelijk doet, en waarom de bug zich verstopt

Link naar de sectie: Wat zero_grad werkelijk doet, en waarom de bug zich verstopt

Elke _backward gebruikt +=. Dat is correct — zo sommeren paden. Maar het heeft een consequentie die iedereen te pakken krijgt: gradients accumuleren ook over aanroepen naar backward() heen. De engine heeft geen idee dat je tweede aanroep een nieuwe training step is in plaats van nog een pad in dezelfde grafiek.

Een training loop moet ze dus wissen:

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

Dit is optimizer.zero_grad() in PyTorch, en het gebruikelijke advies is dat vergeten ervan training breekt. Dus laten we die twee regels verwijderen en kijken hoe kapot het is. Zelfde seeds, alles hetzelfde, 200 stappen XOR:

learning rateseedmet resetzonder 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

Bij de kleine learning rates wint de buggy versie elke rij. Hij convergeert wanneer de correcte versie vastloopt.

Dat is geen toeval en het is de moeite waard om te begrijpen, omdat het verklaart waarom deze bug zo moeilijk te vinden is. Als je de gradient nooit wist, dan wordt de parameter bij stap kk bijgewerkt met de som van elke gradient die tot dan toe is berekend. Op een loss die grofweg dezelfde kant op blijft wijzen, groeit die som gestaag, en het effect is een learning rate die vanzelf toeneemt. Bij η=0.05\eta = 0.05, waar het correcte algoritme kruipt, ziet de weglopende stapgrootte er precies uit als een oplossing.

Kijk dan naar de onderste drie rijen. Bij η=0.3\eta = 0.3 blaast hetzelfde mechanisme het model uit elkaar — loss 8.0 is wat een model scoort dat is ingestort tot een constante ±1\pm 1 — de helft van de 16 die vier maximaal verkeerde antwoorden zouden kosten — — terwijl de correcte versie nu netjes convergeert.

De eerlijke uitspraak is dus niet „roep altijd zero_grad aan of je model traint niet”. Het is: zonder dit voer je geen gradient descent meer uit. Je draait iets waarvan de stapgrootte omhoog drijft met een tempo dat niemand heeft gekozen, en het zal lijken te werken, soms beter dan het echte ding, tot het dat niet meer doet — waarna je de learning rate, de initialisatie of de data de schuld geeft. Dit is de vorm van de ergste bugs in machine learning: ze crashen niet, ze veranderen het algoritme in een ander algoritme dat soms beter scoort.

Nu de engine klaar is, is een neural network nauwelijks nog code. Een neuron is een dot product, een bias en een activatie; een laag is een lijst neuronen; een netwerk is een lijst lagen.

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()]

Er staat nergens een backward pass in. Geen enkele regel. De Value-klasse weet al hoe hij moet differentiëren wat deze klassen ook bouwen, en dat is precies waarom we hem eerst hebben geschreven: een autodiff-engine weet niet dat hij voor een neural network wordt gebruikt.

Nu het probleem uit Hoofdstuk 1. Twee inputs, twee verborgen units, één output, negen parameters:

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

Vier uit vier. De functie die geen perceptron kan berekenen — in Hoofdstuk 1 bewezen met vier ongelijkheden die eisten dat bb zowel positief als negatief was — wordt berekend door negen automatisch gevonden getallen.

Het bevredigende is niet dat het werkt. Het is dat je kunt zien hoe, want met twee verborgen units is de tussenrepresentatie een punt in een vlak en kun je die gewoon printen.

Getraind tot een loss van 0.001241, hier is waar elke input na de verborgen laag terechtkomt, en wat het outputneuron ermee doet:

inputoutput verborgen laagoutputscorelabel
(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

Kijk naar de eerste en vierde rij. De inputs (0,0)(0,0) en (1,1)(1,1) zijn diagonaal tegenoverliggende hoeken van het vierkant — zo ver uit elkaar als twee punten in dit probleem kunnen liggen — en de verborgen laag mapt ze naar (0.82,0.85)(0.82, -0.85) en (0.84,0.86)(0.84, -0.86). Bijna hetzelfde punt. De laag heeft het vlak gevouwen zodat de twee afgewezen hoeken op elkaar landen, en zodra ze op dezelfde plek liggen, scheidt één lijn ze van de andere twee.

En het outputneuron is precies die lijn. Zijn geleerde parameters zijn w=(3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893), b=+2.7697b = +2.7697, dus zijn beslissingsgrens is

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

en dat is een rechte lijn — een perceptron, hetzelfde object uit Hoofdstuk 1, onveranderd. Toen kon hij XOR niet oplossen en nu kan hij dat nog steeds niet. Wat veranderde, is dat hij niet meer naar de input kijkt; hij kijkt naar een ruimte die de eerste laag voor hem heeft gebouwd, waarin het probleem lineair scheidbaar is.

Dat is wat een geleerde representatie is, en het is de moeite waard om precies te zijn, omdat de term in de rest van deze cursus en in de rest van het veld losjes wordt gebruikt. Het is geen compressie, samenvatting of embedding in mystieke zin. Het is een verandering van coördinaten, geleerd in plaats van ontworpen, waarvan de enige taak is om de taak van de volgende laag makkelijk te maken.

Het universele-approximatietheorema, en wat het niet zegt

Link naar de sectie: Het universele-approximatietheorema, en wat het niet zegt

Er is hier een theorema, en dat wordt meestal slecht geciteerd.

Cybenko in 1989 en Hornik in 1991 bewezen dat een feedforward-netwerk met één verborgen laag en een geschikte activation function elke continue functie op een compacte verzameling kan benaderen, tot elke nauwkeurigheid die je wilt, mits er genoeg verborgen units zijn.34 Het is een echt en belangrijk resultaat: het zegt dat de architectuur niet de beperking is.

Lees nu wat het weglaat. Het zegt niet hoeveel units — de grens kan astronomisch groot zijn. Het zegt niet dat de gewichten gevonden kunnen worden; het stelt bestaan, en gradient descent vanaf een willekeurige start is geen orakel. En het zegt niets over gedrag op data die je niet hebt gezien, wat de tweede helft van Hoofdstuk 6 is.

De kloof tussen „bestaat” en „vindbaar” is niet academisch. Hier is hetzelfde XOR-probleem, 50 willekeurige initialisaties elk, 1000 stappen, alleen de grootte van de verborgen laag veranderd:

verborgen unitsinitialisaties die 4/4 halen
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 50 (94 %)

Met de minimaal levensvatbare architectuur komt één run op vier er nooit — hij nestelt zich in een configuratie waar hij niet uit kan afdalen, precies het lokale minimum dat Hoofdstuk 3 liet zien op een eendimensionaal oppervlak. Voeg één unit toe en de mislukkingen verdwijnen bijna, niet omdat het netwerk expressiever werd (twee units zijn al genoeg — 38 runs bewijzen het) maar omdat extra dimensies de afdaling meer richtingen geven om door te ontsnappen.

En dan doet acht units het iets slechter dan vier. Bij een vaste learning rate en een vast stappenbudget is meer capaciteit niet monotoon beter. Iedereen die je vertelt dat de oplossing voor een vastgelopen netwerk altijd een groter netwerk is, extrapoleert vanuit het midden van die tabel.

Dit is dezelfde les als de convergentiestelling in Hoofdstuk 1, en het zal dezelfde les zijn in Hoofdstuk 10 over scaling laws, in de vorm die dat hoofdstuk eraan geeft: een voorspelling van de loss is geen voorspelling van de capability waarvoor je betaalt, en de afstand tussen die twee is waar de engineering leeft.

Details tonen

Optioneel: de matrixvorm, en waarom de code hierboven die niet gebruikt.

Alles hier is één scalar tegelijk geschreven, wat de duidelijkste manier is om het mechanisme te zien en de langzaamste manier om het uit te voeren. In de praktijk is een laag een matrixvermenigvuldiging, en de backward pass van y=Wx\mathbf{y} = W\mathbf{x} is

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}}

De transposes zijn geen truc om te onthouden; ze zijn hoe de som-over-paden-regel eruitziet wanneer de paden worden geïndexeerd door matrixelementen. Het algemene object is de Jacobian, de matrix van alle partiële afgeleiden van alle outputs ten opzichte van alle inputs, en reverse mode is precies de berekening van een vector-Jacobian product zonder ooit de Jacobian te vormen — wat belangrijk is, want voor een laag met 4096 inputs en 4096 outputs heeft die matrix zestien miljoen elementen en is hij het nooit waard om te bouwen.

Je hebt dit allemaal niet nodig om de volgende hoofdstukken te volgen; de scalar-versie doet alles wat de matrixversie doet, alleen langzamer. Het wordt nodig in Hoofdstuk 9, waar de shapes niet meer vanzelfsprekend zijn.

Je hebt nu een netwerk dat traint. Dat is een kleinere prestatie dan het voelt, omdat het netwerk dat je hebt traint op vier voorbeelden en op dezelfde vier wordt gemeten.

Draai dezelfde code op een echte dataset en er verschijnt een nieuwe reeks problemen, waarvan geen enkele over gradients gaat. De loss daalt een tijdje en stopt dan. Of hij daalt op de trainingsdata en stijgt op al het andere. Of hij beweegt vanaf de eerste stap helemaal niet, en de oorzaak blijkt het bereik van de initiële willekeurige gewichten te zijn. Of de input van één unit is in epoch drie bij elk voorbeeld negatief geworden en sindsdien is die unit dood, stilletjes, terwijl hij een stuk van de capaciteit van het model meeneemt.

Dit zijn geen exotische failures; ze zijn de normale toestand van een netwerk dat net is geschreven, en geen ervan kondigt zichzelf aan. De gradient is correct — je hebt hem tegenover PyTorch tot zestien decimalen gecontroleerd — en het model leert nog steeds niet.

Hoofdstuk 6 gaat daarover: initialisatie, normalisatie, overfitting en regularisatie, en de diagnostische gewoonte om te vragen welke daarvan gebeurt voordat je iets verandert. Het is het verschil tussen een netwerk dat draait en een netwerk dat werkt.


De Value-klasse in dit hoofdstuk stamt rechtstreeks af van Andrej Karpathy’s micrograd, en zijn video The spelled-out intro to neural networks and backpropagation: building micrograd is de beste drie uur die je aan dit materiaal kunt besteden als je het nog een keer door iemand anders uitgelegd wilt krijgen. Zijn post uit 2016 Yes you should understand backprop bepleit waarom je er zelf een moet schrijven en is verplichte literatuur in Stanford’s CS224n. De CS231n-notities over backpropagation (cs231n.github.io/optimization-2) zijn de canonieke behandeling van de flowpatronen die hierboven in de tabel staan. Voor de wiskunde als calculus op een grafiek in plaats van neural-network-folklore is hoofdstuk 5.6 van Mathematics for Machine Learning van Deisenroth, Faisal en Ong ongewoon helder; en de survey Automatic Differentiation in Machine Learning: a Survey van Baydin, Pearlmutter, Radul en Siskind (arXiv:1502.05767) is de referentie voor het veld als geheel, inclusief de hierboven besproken forward/reverse-afweging.

  1. Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Masterscriptie, University of Helsinki (1970). Reverse-mode accumulatie, zestien jaar voordat die dit veld bereikte en vanuit een compleet andere motivatie.

  2. Rumelhart, D. E., Hinton, G. E. en Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). Het artikel dat de methode bekend maakte, en de bron van het lezen van verborgen units als geleerde representaties waaraan de sectie Wat de verborgen laag deed in dit hoofdstuk zijn metingen besteedt.

  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). Generaliseert Cybenko: het resultaat hangt af van het feit dat de activatie niet-polynomiaal is, niet van het feit dat die sigmoïdaal is.

Klaar om LIA te laten kiezen?

Bouw met elk AI-model op één plek — begin vandaag nog gratis.