- 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.
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.
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.
Eén verborgen tanh-neuron, een lineaire uitgang en doel 1. Hier is L=½(ŷ−1)²; de lescode gebruikt meerdere klassen en cross-entropy.
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.
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.
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.
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
034. 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.
04Wat 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.