- 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.
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.
| Token | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 1 | 1 | 0 | 0 | 1 | 0.5 | 0 | -1 | 0 |
| 2 | 0 | 1 | 1 | 0 | 0 | 0.5 | 0 | -1 |
| 3 | 1 | 1 | -1 | 0 | 0.5 | 0.5 | 1 | 1 |
Gemarkeerd: kanalen 0 t/m 3, head 1. Deze head deelt zijn dot-products door √4.
Headuitvoer: 0.860, 0.769, -0.488, 0.231
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.
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.
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).
023. 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.
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
03Wat 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.