Werkplaats / De werkplaats
In jouw eigen tempo
← Neurale netwerken
MODULE 02 · LES 07

Stuur het foutsignaal terug door een heel netwerk

Bouw de voorwaartse en achterwaartse pass van een MLP en controleer ieder parametergradient met eindige verschillen.

110 min4 checkpoints
DIT GA JE ONTDEKKEN
  • Je berekent de gradiënten van beide gewichtsmatrices en biases van een MLP.
  • Je controleert backpropagation met numerieke verschillen en voert een consistente update uit.

Je netwerk maakt een verkeerde voorspelling. Welke verbinding droeg hoeveel bij aan die fout? Backpropagation bewaart eerst de tussenresultaten en loopt daarna met de kettingregel terug, zodat ieder gewicht een eigen correctierichting krijgt.

We bouwen een klein netwerk met tanh tussen twee dense-lagen en softmax aan de uitgang. De visualisatie hieronder gebruikt een eenvoudiger scalar netwerk met kwadratisch verlies; zij toont dezelfde teruglopende kettingregel. Onze code gebruikt het gemiddelde multiclass-verlies uit de vorige les.

1. Eerst vooruit, met een cache

Een cache is hier een dictionary met tussenresultaten van één berekening. Bewaar invoer X, verborgen activaties H, logits Z en kansen P onder de sleutels x,h,z,p. Gebruik de oude parameters gedurende de hele terugweg.

H=tanh(XW1+b1)Z=HW2+b2

X heeft N rijen en D invoerkenmerken. Met K verborgen eenheden en C klassen zijn W₁ en W₂ respectievelijk (D,K) en (K,C); de biasvectoren hebben K en C waarden. H heeft vorm (N,K), Z en P hebben vorm (N,C).

Neem één voorbeeld [1,2] en één verborgen eenheid. Kies het eerste gewicht ln(3)/2 en het tweede nul: de gewogen som is ongeveer 0.549306 en tanh daarvan is ½. Met uitgangsgewichten [0,2ln(2)] worden de logits [0,ln(2)] en de kansen [⅓,⅔].

import numpy as np
x = np.array([[1., 2.]])
y = np.array([1])
params = {
    'w1': np.array([[np.log(3)/2], [0.]]),
    'b1': np.zeros(1),
    'w2': np.array([[0., 2*np.log(2)]]),
    'b2': np.zeros(2),
}
h = np.tanh(x@params['w1']+params['b1'])
z = h@params['w2'] + params['b2']
s = z - z.max(axis=1, keepdims=True)
e = np.exp(s)
p = e / e.sum(axis=1, keepdims=True)
cache = dict(x=x, h=h, z=z, p=p)
print(h.tolist())  # [[0.5]]
print(np.round(p, 6))
# [[0.333333 0.666667]]

Maak voorwaarts(x,params) en netwerkverlies(x,y,params). De eerste geeft de cache; de tweede berekent stabiele gemiddelde cross-entropy rechtstreeks uit Z. Voor label 1 geeft dit voorbeeld −ln(⅔), ongeveer 0.405465.

01
CHECKPOINTBewaar wat de terugweg nodig heeft

2. Volg iedere vermenigvuldiging terug

Begin bij de uitgang: dZ is kansen min one-hot-labels, gedeeld door N. Hier is dat [⅓,−⅓]. De notatie dZ betekent de afgeleide van het uiteindelijke verlies naar iedere logit; alle volgende d-symbolen hebben dezelfde betekenis voor hun eigen tussenresultaat of parameter.

DEZELFDE KETTINGREGEL IN HET KLEINVolg het foutsignaal naar twee gewichten

Eén verborgen tanh-neuron, een lineaire uitgang en doel 1. Hier is L=½(ŷ−1)²; de lescode gebruikt meerdere klassen en cross-entropy.

x1.500tanh(xu)0.834ŷ=hw0.417L0.170u=0.80w=0.50
Verlies0.1700dL/du—dL/dw—

De stap gebruikt voor beide gewichten dezelfde oude voorwaartse berekening. De getallen zijn afgerond; schuiven en leerstap behouden de volledige precisie. Het schuifbereik groeit mee als een leerstap erbuiten komt en blijft daarna gelijk tot Reset.

Bij één gewicht in W₂ loopt iedere bijdrage via de verborgen activatie. Vermenigvuldig die activatie met dZ en tel over voorbeelden op: als matrixproduct is dat HᵀdZ. De T transposeert rijen en kolommen; een bias heeft geen invoerfactor en krijgt alleen de som.

dW₂=HTdZdb₂=∑idZidH=dZW2TdA=dH⊙(1−H2)

A is de gewogen som vóór tanh; ⊙ betekent elementgewijs vermenigvuldigen. Na deze lokale tanh-afgeleide volg je de eerste dense-laag terug. Vermenigvuldig opnieuw met de getransponeerde invoer en sommeer de biasbijdragen over de voorbeelden i.

dW₁=XTdAdb₁=∑idAi

In ons voorbeeld is dW₂ [⅙,−⅙]. Het signaal dH is −2ln(2)/3; vermenigvuldigen met 1−(½)² geeft dA = −ln(2)/2, ongeveer −0.346574. De eerste gewichtskolom krijgt daardoor [−0.346574,−0.693147], met de invoerwaarden 1 en 2 als factoren.

dz = p.copy()
dz[np.arange(len(y)), y] -= 1
dz /= len(y)
dw2 = h.T @ dz
dh = dz @ params['w2'].T
da = dh * (1 - h*h)
g = dict(w1=x.T@da, b1=da.sum(axis=0),
         w2=dw2, b2=dz.sum(axis=0))
print(np.round(g['w2'], 6))
# [[ 0.166667 -0.166667]]
print(np.round(g['w1'], 6))
# [[-0.346574]
#  [-0.693147]]

Maak achterwaarts(cache,y,params) als nieuwe dictionary met vier gelijknamige gradiëntarrays. Elke gradiënt heeft precies de vorm van zijn parameter. Sommeer biases over axis=0; deel nergens opnieuw door N en verander geen cache- of parameterarray.

02
CHECKPOINTVermenigvuldig, transposeer en sommeer

3. Controleer zonder de terugweg te vertrouwen

Een verkeerde transpose kan toevallig passen bij vierkante matrices. Controleer daarom ook numeriek: verander één parameterpositie met een klein plusje en minnetje, bereken beide verliezen en deel het verschil door tweemaal die verandering. Dit is het centrale verschil uit de afgeleidenles.

Voor de eenvoudige controlefunctie a[0]²+a[1]² bij a = [2,−1] verwacht je [4,−2]. Maak perturberingen als floatkopieën: in een integerarray zou het kleine verschil worden afgerond, waardoor de meetlat zelf fout is.

def numerieke_gradients(params, loss_fn,
                        epsilon=1e-5):
    basis = {k: np.array(v, dtype=float)
             for k, v in params.items()}
    result = {}
    for name, value in basis.items():
        g = np.zeros_like(value)
        for ix in np.ndindex(value.shape):
            plus = {k:v.copy()
                for k,v in basis.items()}
            minus = {k:v.copy()
                for k,v in basis.items()}
            plus[name][ix] += epsilon
            minus[name][ix] -= epsilon
            d = loss_fn(plus)-loss_fn(minus)
            g[ix] = d / (2*epsilon)
        result[name] = g
    return result

def kwadraatsom(q):
    return float((q['a']**2).sum())

check = numerieke_gradients(
    {'a': np.array([2, -1])}, kwadraatsom)
print(np.round(check['a'], 6))  # [ 4. -2.]

loss_fn is een meegegeven functie die een parameterdictionary ontvangt. .items() levert naam-arrayparen; np.ndindex doorloopt alle posities, ook in matrices. Iedere plus/minus-dictionary krijgt eigen arrays, zodat de oorspronkelijke parameters intact blijven.

Gebruik vervolgens het netwerkverlies als meetfunctie en vergelijk alle vier arrays met de analytische terugweg. De controle is traag en alleen bedoeld voor kleine netwerken; epsilon = 10⁻⁵ balanceert hier meetafstand en afronding. Tanh is glad; bij een ReLU-knik is deze vergelijking minder eenduidig.

def meet(q):
    h = np.tanh(x@q['w1'] + q['b1'])
    z = h@q['w2'] + q['b2']
    s = z-z.max(axis=1, keepdims=True)
    lse = np.log(np.exp(s).sum(axis=1))
    juist = s[np.arange(len(y)), y]
    return float((lse-juist).mean())

gemeten = numerieke_gradients(params, meet)
print(all(np.allclose(g[k], gemeten[k])
          for k in params))  # True
03
CHECKPOINTGebruik een onafhankelijke meetlat

4. Pas daarna alle parameters aan

Maak netwerkstap(x,y,params,eta) met één voorwaartse pass, alle gradiënten en daarna een nieuwe dictionary. Gebruik voor elke parameter de regel oude waarde min eta maal gradiënt. Zo hoort iedere correctie bij dezelfde oude berekening.

nieuw = {k:v - .05*g[k]
         for k,v in params.items()}
print(np.round(nieuw['w2'], 6))
# [[-0.008333  1.394628]]
print(np.round(params['w2'], 6))
# [[0.       1.386294]]
print(meet(nieuw) < meet(params))  # True

Verander je W₂ al vóór dH, dan gebruik je onderweg andere gewichten dan op de heenweg. Een kleine stap verlaagt in dit voorbeeld de fout; een grote stap kan het doel voorbijschieten.

04
CHECKPOINTZet één gelijktijdige stap

Wat neem je mee?

  • Bewaar de heenweg en gebruik één consistente set oude parameters.
  • Elke teruggaande matrix volgt uit de kettingregel en heeft een controleerbare vorm.
  • Vergelijk de terugweg met centrale verschillen en werk daarna alles gelijktijdig bij.

Hierna laat je iedere berekening haar herkomst onthouden, zodat een eigen autograd-engine de terugweg voor je kan uitvoeren.

Verder lezen & bronnen

Inhoud gecontroleerd op 2026-10-28.
DE VOLGENDE STAPLaat iedere berekening haar herkomst onthouden
JOUW EXPERIMENT

Probeer het zelf

Python
main.py

Python start zodra jij code uitvoert.

UITVOER

Je uitvoer verschijnt hier.
Alles rekent in je eigen browser.

Jouw checkpoints0 / 4
01
Bewaar wat de terugweg nodig heeft

Schrijf voorwaarts(x,params) met sleutels x,h,z,p en netwerkverlies(x,y,params) met stabiele gemiddelde cross-entropy.

Een hint

Bereken H met np.tanh, daarna Z, en normaliseer verschoven exponenten per rij. Gebruik log-sum-exp voor het verlies.

02
Vermenigvuldig, transposeer en sommeer

Schrijf achterwaarts(cache,y,params) met vier gradiënten onder dezelfde parameternamen.

Een hint

Werk van de uitgang naar de ingang. Sommeer biases over axis=0 en gebruik p.copy() zodat de cache intact blijft.

03
Gebruik een onafhankelijke meetlat

Schrijf numerieke_gradients(params,loss_fn,epsilon=1e-5). Verander de invoerparameters niet.

Een hint

Maak voor elke positie twee diepe dictionaries met gekopieerde arrays en roep loss_fn op beide aan.

04
Zet één gelijktijdige stap

Schrijf netwerkstap(x,y,params,eta) als nieuwe parameterdictionary zonder de oude arrays te wijzigen.

Een hint

Roep voorwaarts en achterwaarts aan voordat je de dictionary met bijgewerkte arrays maakt.

Vastgelopen? Bekijk de voorbeeldoplossing
import numpy as np

def voorwaarts(x,params):
    x=np.asarray(x,dtype=float)
    h=np.tanh(x@params["w1"]+params["b1"])
    z=h@params["w2"]+params["b2"]
    shifted=z-z.max(axis=1,keepdims=True)
    e=np.exp(shifted)
    return {"x":x,"h":h,"z":z,"p":e/e.sum(axis=1,keepdims=True)}

def netwerkverlies(x,y,params):
    z=voorwaarts(x,params)["z"]
    s=z-z.max(axis=1,keepdims=True)
    return float(np.mean(np.log(np.exp(s).sum(axis=1))-s[np.arange(len(s)),y]))

def achterwaarts(cache,y,params):
    x,h,p=cache["x"],cache["h"],cache["p"]
    dz=p.copy()
    dz[np.arange(len(dz)),y]-=1
    dz/=len(dz)
    dw2=h.T@dz
    db2=dz.sum(axis=0)
    dh=dz@params["w2"].T
    da=dh*(1-h*h)
    return {"w1":x.T@da,"b1":da.sum(axis=0),"w2":dw2,"b2":db2}

def numerieke_gradients(params,loss_fn,epsilon=1e-5):
    basis={k:np.asarray(v,dtype=float).copy() for k,v in params.items()}
    result={}
    for name,value in basis.items():
        g=np.zeros_like(value,dtype=float)
        for index in np.ndindex(value.shape):
            plus={k:v.copy() for k,v in basis.items()}
            minus={k:v.copy() for k,v in basis.items()}
            plus[name][index]+=epsilon
            minus[name][index]-=epsilon
            g[index]=(loss_fn(plus)-loss_fn(minus))/(2*epsilon)
        result[name]=g
    return result

def netwerkstap(x,y,params,eta):
    g=achterwaarts(voorwaarts(x,params),y,params)
    return {name:value-eta*g[name] for name,value in params.items()}

Voer de code uit en verklaar voor jezelf waarom elke stap werkt.