Werkplaats / De werkplaats
In jouw eigen tempo
← Taalmodellen
MODULE 03 · LES 09

Bouw een volledig pre-norm transformerblok

Combineer LayerNorm, causale multi-head attention, feedforward en twee residualverbindingen met een volledige numeriek gecontroleerde backward-pass.

90 min4 checkpoints
DIT GA JE ONTDEKKEN
  • Je combineert normalisatie, attention, feedforward en twee residualverbindingen in één blok.
  • Je berekent alle parametergradiënten en beide directe residualroutes.

Attention mengt informatie tussen posities; een feedforwardnetwerk bewerkt daarna iedere positie. Een transformerblok verbindt die bewerkingen met een doorgaande residualstroom. Je kunt een nieuwe bijdrage toevoegen zonder eerst alle bestaande informatie door de sublaag te persen.

Voorspel in de visualisatie eerst wat er gebeurt als beide factoren nul zijn: Y blijft gelijk aan X. Zet daarna alleen attention aan en vervolgens alleen feedforward. Deze factoren zijn onderzoeksschakelaars; het programmeerblok gebruikt beide bijdragen zonder zulke extra factoren.

EEN DOORGAANDE RESIDUALSTROOMVolg één token door een pre-norm blok

Drie tokens, vier kanalen en één causale head. Q=K=V=LN1(X), Wₒ=0.25I; FF(z)=0.3ReLU(z). Alle tussenwaarden worden opnieuw berekend.

X+ α × attentionH+ β × feedforwardY
Tussenwaarden voor token 3
Stap0123
X · invoer1.00-1.000.201.00
LN1(X)0.86-1.59-0.120.86
Attention · vóór factor0.23-0.30-0.120.18
H = X + αA1.23-1.300.081.18
LN2(H)0.90-1.55-0.210.86
Feedforward · vóór factor0.270.000.000.26
Y = H + βF1.50-1.300.081.44

Uitvoer token 3: 1.5007, -1.2952, 0.0824, 1.4406

α en β zijn alleen schakelaars voor deze proef. Bij α=β=0 blijft Y=X. Verander token 3 terwijl je token 1 inspecteert: het causale masker houdt zijn uitvoer gelijk.

Wij bouwen pre-norm: normaliseren vóór elke sublaag, terwijl de directe stroom ongenormaliseerd blijft. Het oorspronkelijke transformerpaper gebruikte post-norm. Deze onderwijsvariant gebruikt ReLU en geen dropout; hij kopieert geen specifiek productiemodel.

De attentionhelpers en init_block zijn gegeven. De voorbeelden hieronder gebruiken daarnaast jouw normalisatie-, feedforward- en blokfuncties. Lees iedere terugweg als het verdelen en weer optellen van invloed.

1. Normaliseer één tokenrij

X heeft vorm (B,T,C): batch, tijd en kanalen. LayerNorm berekent per token gemiddelde μ en populatievariantie σ² over C, dus met ddof=0. De inverse schaal r is 1/√(σ²+ε); de genormaliseerde rij N wordt daarna per kanaal geschaald met γ en verschoven met β.

N=r(X−μ),Y=Nγ+β

Neem X=[1,3], γ=[2,1] en β=[1,−1]. Dan zijn μ=2 en σ²=1. Voor deze handproef kiezen we ε=1: r=1/√2 en N=[−.7071,.7071], zodat Y≈[−.4142,−.2929]; het echte blok gebruikt standaard ε=1e−5.

import numpy as np
x = np.array([[[1.,3.]]])
gamma = np.array([2.,1.])
beta = np.array([1.,-1.])
y,lc = layernorm_forward(
    x,gamma,beta,epsilon=1.)
print(np.round(y,4))
# [[[-.4142 -.2929]]]

Voor backward schrijf je D=dY×γ. Alleen rD gebruiken vergeet dat gemiddelde en schaal zelf van X afhangen. Trek eerst de kanaalgemiddelde bijdrage af en corrigeer daarna voor de invloed op de schaal; alle gemiddelden hieronder lopen over de laatste as.

U=D−mean(D)dX=r(U−Nmean(DN))

Bij dY=[1,0] is D=[2,0], U=[1,−1] en mean(DN)=−.7071. De schaalcorrectie maakt daarvan [.5,−.5]; vermenigvuldigen met r geeft dX≈[.3536,−.3536]. Verzamel daarnaast dγ uit dY×N en dβ uit dY, gesommeerd over batch en tijd.

dy = np.array([[[1.,0.]]])
dx,dg,db = layernorm_backward(lc,dy)
print(np.round(dx,4))
# [[[.3536 -.3536]]]
print(np.round(dg,4),db)
# [-.7071 0.] [1. 0.]

Maak beide LayerNormfuncties met cache norm,inv,gamma. Gebruik floats vóór producten en gradiëntsommen, ook bij integerinvoer.

01
CHECKPOINTNormaliseer per token en behoud de volledige terugweg

2. Dezelfde kleine MLP op iedere plek

De feedforwardlaag berekent Z=XW1+b1, vervolgens hidden=ReLU(Z) en F=hiddenW2+b2. W1 verbreedt van C naar M en W2 gaat terug naar C. Parameters zijn gedeeld over posities; de berekening mengt hun invoer niet.

Neem X=[1,2], de matrices hieronder en b1=[−2,0]. Dan is Z=[1,−1], hidden=[1,0] en F=[2,1]. De negatieve route verdwijnt bij ReLU.

x = np.array([[[1.,2.]]])
p = {'w1':np.array([[1.,-1.],[1.,0.]]),
     'b1':np.array([-2.,0.]),
     'w2':np.array([[2.,0.],[0.,1.]]),
     'b2':np.array([0.,1.])}
y,fc = feedforward(x,p)
print(y)  # [[[2. 1.]]]

Bij dY=[1,3] komt vóór ReLU [2,3] terug; het tekenmasker laat dZ=[2,0] over. Daardoor is dX=[2,2] en de bijdrage aan W1 het outer-product [[2,0],[4,0]]. Meerdere batch- of tijdposities tellen zulke bijdragen op; biases verzamelen de kanaalbijdragen.

dx,g = feedforward_backward(
    fc,p,np.array([[[1.,3.]]]))
print(dx)  # [[[2. 2.]]]
print(g['w1'])
# [[2. 0.]
#  [4. 0.]]

Maak feedforward en zijn backward met cache x,z,hidden en gradiënten w1,b1,w2,b2. Bij preactivatie exact nul kiezen we ReLU-subgradiënt nul. Een zuiver lineaire vervanger kan een consistente backward hebben, maar berekent een ander model.

02
CHECKPOINTBewerk iedere positie met hetzelfde kleine MLP

3. Verbind de sublagen met twee directe routes

Bereken H=X+MHA(LN1(X)) en daarna Y=H+FF(LN2(H)). De directe X- en H-routes worden niet genormaliseerd. LN1 gebruikt g1,b1n en LN2 g2,b2n; die namen onderscheiden normalisatiebiases van de FF-biases b1,b2.

H=X+MHA(LN1(X))Y=H+FF(LN2(H))
p = init_block(4)
x = np.array([[[0.,1.,2.,3.],
               [1.,0.,-1.,2.]]])
allowed = np.tril(np.ones((2,2),bool))
y,bc = transformer_block(x,p,2,allowed)
print(np.round(y[0,0],4))
# [-.1256 .8200 2.2928 3.0412]

Maak transformer_block met cache norm1,attention,residual,norm2,ff. Het causale masker moet binnen attention blijven gelden. Controleer toekomstinvloed door één toekomstkanaal te veranderen; een gelijke verschuiving van alle kanalen verdwijnt al door LayerNorm en bewijst dus niets over causaliteit.

03
CHECKPOINTHoud een directe residualstroom langs beide sublagen

4. Gebruik de opgetelde upstreamgradiënt

Uit Y=H+FF(LN2(H)) ontvangt H dY rechtstreeks plus de FF/LN2-bijdrage. Noem de som dH; attention ontvangt deze dH, niet alleen dY. Naar X tel je daarna dH op bij de attention/LN1-bijdrage.

In dezelfde numerieke proef is de eerste dY hieronder 1. De FF-route levert −.1319, dus dH=.8681; de attentionroute levert vervolgens .0159 en samen wordt dX=.8840. Dit zijn bijdragen uit de echte caches, geen verzonnen vervangingsgradiënten.

dy = np.arange(1.,9.).reshape(1,2,4)
ff_back = feedforward_backward
ln_back = layernorm_backward
mh_back = multihead_backward
dn2,_ = ff_back(bc['ff'],p,dy)
ff,_,_ = ln_back(bc['norm2'],dn2)
dh = dy+ff
dn1,_ = mh_back(bc['attention'],p,dh)
att,_,_ = ln_back(bc['norm1'],dn1)
print(round(ff[0,0,0],4))  # -.1319
print(round(dh[0,0,0],4))  # .8681
print(round(att[0,0,0],4))  # .0159
print(round((dh+att)[0,0,0],4))  # .8840

Maak block_backward met dx en exact de twaalf parametergradiënten uit init_block. Hergebruik beide residualoptellingen en alle eerdere backwards. Voeg geen tweede verliesmiddeling toe.

04
CHECKPOINTBeide residuals leveren een directe gradiëntbijdrage

Wat neem je mee?

  • Normalisatie staat vóór de sublaag; de directe stroom blijft bestaan.
  • Iedere gedeelde parameter verzamelt alle toepasselijke bijdragen.
  • De tweede residual verandert welke gradiënt attention ontvangt.

Hierna gebruik je dit blok in een eigen mini-GPT met echte training en generatie.

DE VOLGENDE STAPTrain een mini-GPT dat zelf tekst vervolgt
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
Normaliseer per token en behoud de volledige terugweg

Schrijf layernorm_forward(x,gamma,beta,epsilon=1e-5) met (y,cache), cache norm,inv,gamma; en layernorm_backward(cache,dy) met (dx,dgamma,dbeta). Gebruik de laatste as.

Een hint

keepdims=True bewaart de broadcastvorm. dGamma en dBeta krijgen vorm (C,).

02
Bewerk iedere positie met hetzelfde kleine MLP

Schrijf feedforward(x,params) met (y,cache), cache x,z,hidden; en feedforward_backward(cache,params,dy) met (dx,grads). grads heeft w1,b1,w2,b2.

Een hint

De ReLU-terugweg is (dy@w2.T)×(z>0). Een einsum over b en t verzamelt gedeelde gewichten.

03
Houd een directe residualstroom langs beide sublagen

Schrijf transformer_block(x,params,heads,allowed) met (y,cache). Cache norm1,attention,residual,norm2,ff voor de terugweg. Gebruik het meegegeven allowed-masker, ook als dit eerdere keys uitsluit.

Een hint

Bewaar de cache van iedere helper. H heet residual in de cache.

04
Beide residuals leveren een directe gradiëntbijdrage

Schrijf block_backward(cache,params,dy) met (dx,grads). grads heeft exact dezelfde twaalf parameters als init_block. Hergebruik de eerdere backwards en tel beide residualroutes op.

Een hint

Begin bij feedforward_backward. Pas daarna layernorm_backward toe en tel de directe dy erbij. Herhaal dit patroon voor attention en LN1.

Vastgelopen? Bekijk de voorbeeldoplossing
import numpy as np

def masked_softmax(scores,allowed):
    scores=np.asarray(scores,float)
    allowed=np.asarray(allowed)
    if scores.ndim<2 or scores.shape[-1]==0 or allowed.dtype.kind!='b' or not np.isfinite(scores).all():
        raise ValueError('Gebruik eindige scores en een booleaans masker')
    allowed=np.broadcast_to(allowed,scores.shape)
    masked=np.where(allowed,scores,-np.inf)
    maximum=np.max(masked,axis=-1,keepdims=True)
    maximum=np.where(np.isfinite(maximum),maximum,0.)
    weights=np.exp(masked-maximum)
    total=weights.sum(axis=-1,keepdims=True)
    return np.divide(weights,total,out=np.zeros_like(weights),where=total>0)

def attention(q,k,v,allowed):
    q,k,v=(np.asarray(value,float) for value in (q,k,v))
    if min(q.ndim,k.ndim,v.ndim)<2 or q.shape[:-2]!=k.shape[:-2] or q.shape[:-2]!=v.shape[:-2] or q.shape[-1]!=k.shape[-1] or k.shape[-2]!=v.shape[-2] or q.shape[-1]==0:
        raise ValueError('Vormen van Q, K en V passen niet')
    scores=q@np.swapaxes(k,-2,-1)/np.sqrt(q.shape[-1])
    weights=masked_softmax(scores,allowed)
    return weights@v,{'q':q,'k':k,'v':v,'weights':weights}

def attention_backward(cache,dy):
    dy=np.asarray(dy,float)
    q,k,v,p=(cache[name] for name in ['q','k','v','weights'])
    dv=np.swapaxes(p,-2,-1)@dy
    dp=dy@np.swapaxes(v,-2,-1)
    ds=p*(dp-(dp*p).sum(axis=-1,keepdims=True))
    scale=np.sqrt(q.shape[-1])
    return {'q':ds@k/scale,'k':np.swapaxes(ds,-2,-1)@q/scale,'v':dv}

def split_heads(x,heads):
    if x.ndim!=3 or type(heads) is not int or heads<=0 or x.shape[-1]%heads:
        raise ValueError('Aantal heads moet de modelbreedte delen')
    b,t,c=x.shape
    return x.reshape(b,t,heads,c//heads).transpose(0,2,1,3)

def merge_heads(x):
    if x.ndim!=4:
        raise ValueError('Verwacht (batch,heads,tijd,headbreedte)')
    b,h,t,d=x.shape
    return x.transpose(0,2,1,3).reshape(b,t,h*d)

def multihead(x,params,heads,allowed):
    x=np.asarray(x,float)
    params={name:np.asarray(value,float) for name,value in params.items()}
    q,k,v=(split_heads(x@params[name],heads) for name in ['wq','wk','wv'])
    out,acache=attention(q,k,v,allowed)
    joined=merge_heads(out)
    return joined@params['wo'],{'x':x,'heads':heads,'attention':acache,'joined':joined}

def multihead_backward(cache,params,dy):
    x=np.asarray(cache['x'],float)
    dy=np.asarray(dy,float)
    params={name:np.asarray(value,float) for name,value in params.items()}
    gradients={'wo':np.einsum('btc,btd->cd',cache['joined'],dy)}
    raw=attention_backward(cache['attention'],split_heads(dy@params['wo'].T,cache['heads']))
    dx=np.zeros_like(x)
    for name,part in [('wq','q'),('wk','k'),('wv','v')]:
        dpart=merge_heads(raw[part])
        gradients[name]=np.einsum('btc,btd->cd',x,dpart)
        dx+=dpart@params[name].T
    return dx,gradients

def init_block(width=4,seed=7):
    rng=np.random.default_rng(seed)
    params={name:rng.normal(0,.2,(width,width)) for name in ['wq','wk','wv','wo']}
    params.update({'g1':np.ones(width),'b1n':np.zeros(width),'g2':np.ones(width),'b2n':np.zeros(width),
                   'w1':rng.normal(0,.2,(width,2*width)),'b1':rng.normal(0,.2,2*width),
                   'w2':rng.normal(0,.2,(2*width,width)),'b2':np.zeros(width)})
    return params

def layernorm_forward(x,gamma,beta,epsilon=1e-5):
    x=np.asarray(x,float);gamma=np.asarray(gamma,float);beta=np.asarray(beta,float)
    inv=1/np.sqrt(x.var(axis=-1,keepdims=True)+epsilon)
    norm=(x-x.mean(axis=-1,keepdims=True))*inv
    return norm*gamma+beta,{'norm':norm,'inv':inv,'gamma':gamma}

def layernorm_backward(cache,dy):
    dy=np.asarray(dy,float)
    norm,inv,gamma=(cache[k] for k in ['norm','inv','gamma'])
    dn=dy*gamma
    dx=inv*(dn-dn.mean(axis=-1,keepdims=True)-norm*(dn*norm).mean(axis=-1,keepdims=True))
    axes=tuple(range(dy.ndim-1))
    return dx,(dy*norm).sum(axis=axes),dy.sum(axis=axes)

def feedforward(x,params):
    x=np.asarray(x,float)
    params={name:np.asarray(value,float) for name,value in params.items()}
    z=x@params['w1']+params['b1']
    hidden=np.maximum(z,0)
    return hidden@params['w2']+params['b2'],{'x':x,'z':z,'hidden':hidden}

def feedforward_backward(cache,params,dy):
    dy=np.asarray(dy,float)
    params={name:np.asarray(value,float) for name,value in params.items()}
    dz=(dy@params['w2'].T)*(cache['z']>0)
    gradients={'w2':np.einsum('btc,btd->cd',cache['hidden'],dy),'b2':dy.sum(axis=(0,1)),
               'w1':np.einsum('btc,btd->cd',cache['x'],dz),'b1':dz.sum(axis=(0,1))}
    return dz@params['w1'].T,gradients

def transformer_block(x,params,heads,allowed):
    x=np.asarray(x,float)
    n1,c1=layernorm_forward(x,params['g1'],params['b1n'])
    a,ca=multihead(n1,params,heads,allowed)
    residual=x+a
    n2,c2=layernorm_forward(residual,params['g2'],params['b2n'])
    f,cf=feedforward(n2,params)
    return residual+f,{'norm1':c1,'attention':ca,'residual':residual,'norm2':c2,'ff':cf}

def block_backward(cache,params,dy):
    dy=np.asarray(dy,float)
    dn2,g=feedforward_backward(cache['ff'],params,dy)
    dr,g['g2'],g['b2n']=layernorm_backward(cache['norm2'],dn2)
    dr+=dy
    dn1,ag=multihead_backward(cache['attention'],params,dr)
    g.update(ag)
    dx,g['g1'],g['b1n']=layernorm_backward(cache['norm1'],dn1)
    return dx+dr,g

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