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

Verdeel de representatie over meerdere attention-heads

Bouw de headtransformaties, vier geleerde projecties en een volledige terugweg die de bijdragen aan X optelt.

75 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je houdt batch-, tijd-, head- en featureassen uit elkaar.
  • Je berekent de gradiënten van Q/K/V- en uitvoerprojecties met gedeelde invoer.

Eén gewogen mengsel kan verschillende signalen samenpersen. Multi-head attention maakt meerdere mengsels naast elkaar, elk met geleerde projecties van dezelfde invoer. Vergelijk het met meerdere lezers die hetzelfde kaartje bekijken door verschillende rekenfilters.

De vergelijking heeft een grens: een head hoeft geen menselijk benoembare taak te leren. Bij vaste modelbreedte C krijgen H heads elk d=C/H kenmerken; meer heads maken het model hier niet automatisch breder. Je bouwt zowel de asindeling als de vier projecties en hun gradiënten.

De attentionhulpfuncties uit de vorige les zijn gegeven. De voorbeelden gebruiken NumPy en de head- en projectiefuncties die je hier maakt. We laten biases weg, zodat iedere parameterroute zichtbaar blijft.

1. Geef iedere head zijn eigen tijdreeks

Een invoer heeft vorm (B,T,C): batch, tijd en kenmerken. Reshape eerst naar (B,T,H,d) en verwissel dan tijd en heads, zodat je (B,H,T,d) krijgt. Een directe reshape naar die eindvorm bewaart wel het aantal getallen, maar verdeelt ze verkeerd over de tijd.

Neem één batch met twee tokens: [0,1,2,3] en [4,5,6,7]. Bij twee heads krijgt head 0 de vectoren [0,1] en [4,5]; head 1 krijgt [2,3] en [6,7]. De head leest dus beide tijdstappen, met een eigen deel van de kenmerken.

import numpy as np
x = np.arange(8).reshape(1,2,4)
h = split_heads(x,2)
print(h[0,0].tolist())
# [[0,1],[4,5]]
print(h[0,1].tolist())
# [[2,3],[6,7]]
print(np.array_equal(merge_heads(h),x))
# True

Maak split_heads en zijn inverse merge_heads. Eis voor split een 3D-array en een positieve exacte integer H die C deelt; merge ontvangt vier assen. De inverse wisselt eerst de assen terug en voegt daarna H en d samen.

C BLIJFT 8, DE HEADBREEDTE VERANDERTVolg kanalen naar hun eigen attention-head

Hier zijn de vier projectiematrices bewust identiteitsmatrices: Q=K=V=X. Zo zie je de assen en schaling zonder verborgen training. In een echt model worden die projecties geleerd.

(B,T,C)(1,3,8)→ (B,H,T,d)(1,2,3,4)
Drie tokenvectoren vóór het splitsen
Token01234567
110010.50-10
2011000.50-1
311-100.50.511

Gemarkeerd: kanalen 0 t/m 3, head 1. Deze head deelt zijn dot-products door √4.

Key 1
0.231
Key 2
0.140
Key 3
0.629

Headuitvoer: 0.860, 0.769, -0.488, 0.231

Headbreedte4Scorecellen BHT²184C² projectieparameters256

Na concatenatie heeft iedere token weer acht waarden. De algemene projecties houden hetzelfde parameteraantal bij vaste C; de expliciete scoreopslag groeit hier wel met H.

01
CHECKPOINTReshape alleen is niet genoeg

2. Leer eerst welke kenmerken elke head krijgt

De vier matrices WQ, WK, WV en WO hebben hier allemaal vorm (C,C). Bereken Q=XWQ, K=XWK en V=XWV en split deze resultaten in heads. Iedere head voert attention uit met schaal √d; daarna voeg je de uitvoer samen tot J en bereken je Y=JWO.

Je verdeelt de kanalen dus ná de projecties. Een algemene projectiematrix kan voor iedere head informatie uit alle oorspronkelijke invoerkanalen combineren; heads zitten niet voorgoed vast aan een stukje van de ruwe X.

Y=JWO

Gebruik voor een handproef één token [1,2] en één head. Laat WQ en WK de identiteit zijn en WV tweemaal de identiteit; de enige toegestane key heeft gewicht één, dus J=[2,4]. Met WO=[[1,1],[0,1]] wordt Y=[2,6].

x = np.array([[[1.,2.]]])
params = {'wq':np.eye(2),
          'wk':np.eye(2),
          'wv':2*np.eye(2),
          'wo':np.array([[1.,1.],
                         [0.,1.]])}
allowed = np.ones((1,1),dtype=bool)
y,cache = multihead(x,params,1,allowed)
print(y)  # [[[2. 6.]]]

Maak multihead en bewaar x,heads,attention,joined in de cache. De attentioncache komt uit de gegeven helper. De vier matrices hebben samen 4C² parameters bij vaste C, onafhankelijk van H; de scoretensor heeft wel vorm (B,H,T,T).

02
CHECKPOINTLeer Q, K, V en de terugprojectie

3. Volg de uitvoerprojectie terug

Uit Y=JWO volgen dJ=dYWOᵀ en dWO als de som van outer(J,dY) over batch en tijd. Bij dY=[3,5] geeft ons handvoorbeeld dJ=[8,5]. Daarom is dWO=[[6,10],[12,20]].

Nu is er maar één key, dus het attentiongewicht is altijd één. De scoregradiënt is nul: Q en K hebben in déze handproef geen invloed op Y. V krijgt dV=[8,5], zodat WV een gradiënt [[8,5],[16,10]] ontvangt en de invoergradiënt [16,10] wordt.

dy = np.array([[[3.,5.]]])
dx,grads = multihead_backward(
    cache,params,dy)
print(grads['wo'].tolist())
# [[6.,10.],[12.,20.]]
print(dx)  # [[[16. 10.]]]

Een proef met één key kan dus niet bewijzen dat de Q- en K-projecties werken. Gebruik ook meerdere verschillende tijdstappen; dan verandert het gewogen mengsel wanneer hun vergelijkingsscores veranderen. Vermijd dezelfde onbedoelde zwakte bij je eigen tests.

4. Tel de drie routes naar X op

Na de uitvoerprojectie gebruik je split_heads op dJ, attention_backward op de cache en merge_heads op de drie teruggegeven headgradiënten. Elke projectiematrix ontvangt de som van outer(X,dPart) over batch en tijd. Naar dezelfde X lopen drie routes die je moet optellen.

dX=dQWQT+dKWKT+dVWVT

Deze formule gebruikt dPart na het samenvoegen van de heads. Een route overschrijven in plaats van optellen laat een deel van de invoerinvloed verdwijnen. Reken vóór dot-products en accumulatie met floats; de invoer kan uit een integerarray komen, maar zijn gradiënt hoeft geen integer te zijn.

x = np.array([[[1.,2.],[0.,1.]]])
allowed = np.ones((2,2),dtype=bool)
y,cache = multihead(x,params,1,allowed)
dx,_ = multihead_backward(
    cache,params,np.ones_like(y))
eps = 1e-5
plus=x.copy();minus=x.copy()
plus[0,0,0]+=eps;minus[0,0,0]-=eps
def loss(z):
    return multihead(
        z,params,1,allowed)[0].sum()
numeric=(loss(plus)-loss(minus))/(2*eps)
print(np.allclose(dx[0,0,0],numeric))
# True

Maak multihead_backward met dx en alle vier parametergradiënten. Middel niet opnieuw over batch of tijd: een eventueel gemiddelde zit al in dy. Een residualverbinding komt pas in de volgende module boven op deze attentionroutes.

Bij de tweestapsproef hierboven zijn de bijdragen aan het eerste invoergetal ongeveer 0.4055 via Q, 0.4055 via K en 6.2509 via V. Samen geven ze 7.0619. Deze kleine getallen maken zichtbaar wat verdwijnen zou wanneer je alleen de value-route terugstuurt.

dj = np.ones_like(y)@params['wo'].T
dh = split_heads(dj,1)
raw = attention_backward(
    cache['attention'],dh)
routes = []
names = [('wq','q'),('wk','k'),
         ('wv','v')]
for name,part in names:
    back = merge_heads(raw[part])
    route = back@params[name].T
    routes.append(route[0,0,0])
print(np.round(routes,4))
# [0.4055 0.4055 6.2509]
print(round(sum(routes),4))  # 7.0619
03
CHECKPOINTTel drie routes naar dezelfde invoer op

Wat neem je mee?

  • Split en merge bewaren iedere head én iedere tijdstap op de juiste as.
  • Q, K, V en de uitvoerprojectie zijn afzonderlijke geleerde routes.
  • Hun bijdragen aan dezelfde invoer worden opgeteld, zonder extra middeling.

Hierna bouw je deze laag in een transformerblok met een residualroute en normalisatie.

DE VOLGENDE STAPMaak van tekst een reeks volgende-tokenopdrachten
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 / 3
01
Reshape alleen is niet genoeg

Schrijf split_heads(x,heads) en merge_heads(x). split vereist een 3D-array en een positieve exacte integer H die C deelt. merge vereist vier assen. Ongeldige vormen geven ValueError.

Een hint

Gebruik reshape(...).transpose(0,2,1,3); voor de inverse eerst transpose, dan reshape.

02
Leer Q, K, V en de terugprojectie

Schrijf multihead(x,params,heads,allowed): retourneer (y,cache) met x,heads,attention,joined. attention is de cache van de attentionhulpfunctie.

Een hint

De schaal in attention moet de headbreedte d gebruiken, niet de totale C.

03
Tel drie routes naar dezelfde invoer op

Schrijf multihead_backward(cache,params,dy): retourneer (dx,gradients), met wq,wk,wv,wo. Gebruik de bestaande head- en attentionfuncties.

Een hint

np.einsum("btc,btd->cd",x,dpart) telt gedeelde matrixbijdragen over batch en tijd op.

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

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