STEP 03 / 18

머리를 여러 개로: Multi-Head Attention

Multi-Head Attention

어텐션 한 번은 토큰마다 "가중 평균 하나"만 만든다. 그런데 한 단어가 동시에 신경 써야 할 관계는 여러 가지다(주어-동사, 수식 관계, 바로 옆 단어...). 그래서 512차원을 8조각(64차원씩)으로 나눠 어텐션 8개를 따로 돌리고 결과를 이어 붙인다.

$$\mathrm{MultiHead}(Q, K, V) = \mathrm{Concat}(\mathrm{head_1}, ..., \mathrm{head_h})W^O,\quad \mathrm{head_i} = \mathrm{Attention}(QW^Q_i, KW^K_i, VW^V_i)$$

이번 단계에서 추가되는 것:

  • clones(module, N): 같은 모듈을 N개 deepcopy한다. 모양은 같지만 가중치는 따로 갖는다. 앞으로 층을 쌓을 때마다 쓴다.
  • MultiHeadedAttention: nn.Linear 4개(W_Q, W_K, W_V, W_O)를 갖는다. head 8개를 반복문으로 돌리지 않고, view + transpose로 head를 배치 차원처럼 앞으로 빼서 한 번의 행렬곱으로 처리하는 게 핵심이다. 계산한 가중치는 self.attn에 남겨 둔다.
  • attention에 dropout 인자 추가: 학습 때 가중치 일부를 0으로 만들어 과적합을 줄인다.

실행 결과에서 볼 것

  • 입력과 출력의 모양이 같다. 그래서 이 블록을 몇 층이든 쌓을 수 있다.
  • (2, 10, 512) → (2, 10, 8, 64) → (2, 8, 10, 64)로 모양이 바뀌는 과정.
  • 같은 가중치를 넣었을 때 PyTorch 내장 nn.MultiheadAttention과 결과 차이가 1e-7 수준이다. 우리가 만든 것이 표준 구현과 같은 계산이라는 뜻이다.
  • W_O 실험: 이어 붙인 head 출력에 W_O를 곱한 값이 ours(y, y, y)와 정확히 같고, head마다 W_O의 자기 몫을 곱해 더한 값과도 같다(차이 1e-7 수준).

코드 변화

+52 −1 · 파일 전체 73줄

이전 단계의 transformer.py와 비교했다. 초록 줄이 새로 생긴 줄, 빨간 줄이 사라진 줄이다.

GitHub에서 보기 ↗
1+import copy
12import math
23
34import torch
5+import torch.nn as nn
46
57
6−def attention(query, key, value, mask=None):
8+def attention(query, key, value, mask=None, dropout=None):
79 "Compute 'Scaled Dot Product Attention'"
810 d_k = query.size(-1)
911 scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
1012 if mask is not None:
1113 scores = scores.masked_fill(mask == 0, -1e9)
1214 p_attn = scores.softmax(dim=-1)
15+ if dropout is not None:
16+ p_attn = dropout(p_attn)
1317 return torch.matmul(p_attn, value), p_attn
1418
1519
2024 torch.uint8
2125 )
2226 return subsequent_mask == 0
27+
28+
29+def clones(module, N):
30+ "Produce N identical layers."
31+ return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
32+
33+
34+class MultiHeadedAttention(nn.Module):
35+ def __init__(self, h, d_model, dropout=0.1):
36+ "Take in model size and number of heads."
37+ super(MultiHeadedAttention, self).__init__()
38+ assert d_model % h == 0
39+ # We assume d_v always equals d_k
40+ self.d_k = d_model // h
41+ self.h = h
42+ self.linears = clones(nn.Linear(d_model, d_model), 4)
43+ self.attn = None
44+ self.dropout = nn.Dropout(p=dropout)
45+
46+ def forward(self, query, key, value, mask=None):
47+ "Implements Figure 2"
48+ if mask is not None:
49+ # Same mask applied to all h heads.
50+ mask = mask.unsqueeze(1)
51+ nbatches = query.size(0)
52+
53+ # 1) Do all the linear projections in batch from d_model => h x d_k
54+ query, key, value = [
55+ lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
56+ for lin, x in zip(self.linears, (query, key, value))
57+ ]
58+
59+ # 2) Apply attention on all the projected vectors in batch.
60+ x, self.attn = attention(
61+ query, key, value, mask=mask, dropout=self.dropout
62+ )
63+
64+ # 3) "Concat" using a view and apply a final linear.
65+ x = (
66+ x.transpose(1, 2)
67+ .contiguous()
68+ .view(nbatches, -1, self.h * self.d_k)
69+ )
70+ del query
71+ del key
72+ del value
73+ return self.linears[-1](x)

실행 결과

실행한 코드 demo.py 보기
1import torch
2import torch.nn as nn
3
4from transformer import MultiHeadedAttention, subsequent_mask
5
6torch.manual_seed(0)
7
8mha = MultiHeadedAttention(h=8, d_model=512)
9x = torch.randn(2, 10, 512) # 문장 2개, 토큰 10개
10out = mha(x, x, x)
11
12print("입력 :", tuple(x.shape))
13print("출력 :", tuple(out.shape), " ← 입력과 같은 모양")
14print("head별 가중치 :", tuple(mha.attn.shape), " (batch, head, query, key)")
15print("head 하나의 d_k:", mha.d_k, "= 512 / 8")
16print()
17
18n = sum(p.numel() for p in mha.parameters())
19print(f"파라미터 수: {n:,} = 4 × (512×512 + 512) (W_Q, W_K, W_V, W_O)")
20print()
21
22print("1) 프로젝션 후 head로 쪼개는 모양 변화")
23q = mha.linears[0](x)
24print(" linear(x) ", tuple(q.shape))
25q = q.view(2, -1, 8, 64)
26print(" .view(B, -1, h, d_k) ", tuple(q.shape))
27q = q.transpose(1, 2)
28print(" .transpose(1, 2) ", tuple(q.shape), " ← head가 batch처럼 앞으로")
29print()
30
31print("2) PyTorch 내장 nn.MultiheadAttention과 같은 계산인가?")
32ours = MultiHeadedAttention(4, 32, dropout=0.0).eval()
33ref = nn.MultiheadAttention(32, 4, dropout=0.0, batch_first=True).eval()
34with torch.no_grad():
35 ref.in_proj_weight.copy_(torch.cat([l.weight for l in ours.linears[:3]]))
36 ref.in_proj_bias.copy_(torch.cat([l.bias for l in ours.linears[:3]]))
37 ref.out_proj.weight.copy_(ours.linears[3].weight)
38 ref.out_proj.bias.copy_(ours.linears[3].bias)
39y = torch.randn(1, 6, 32)
40mask = subsequent_mask(6)
41a = ours(y, y, y, mask=mask)
42b, _ = ref(y, y, y, attn_mask=~mask[0]) # PyTorch는 True가 '가림'
43print(" 최대 차이:", f"{(a - b).abs().max().item():.2e}")
44print()
45
46print("3) W_O(linears[-1])는 head들의 결과를 섞는다")
47# ours의 forward를 단계별로 다시 따라가며 head별 출력을 꺼낸다 (head 4개, d_k 8)
48from transformer import attention
49
50h, d_k = ours.h, ours.d_k
51q, k, v = [lin(y).view(1, -1, h, d_k).transpose(1, 2) for lin in ours.linears[:3]]
52heads, _ = attention(q, k, v) # (1, 4, 6, 8)
53concat = heads.transpose(1, 2).reshape(1, 6, h * d_k) # head를 옆으로 이어 붙인 (1, 6, 32)
54W_O = ours.linears[-1]
55print(" concat 모양:", tuple(concat.shape), " → W_O 통과 →", tuple(W_O(concat).shape))
56print(" ours(y, y, y)와 차이:", f"{(W_O(concat) - ours(y, y, y)).abs().max().item():.1e}")
57# concat @ W_O 는 'head마다 W_O의 자기 몫(8열)을 곱해 더한 것'과 같다
58per_head = [heads[:, i] @ W_O.weight[:, i * d_k:(i + 1) * d_k].T for i in range(h)]
59print(" Σ head_i @ W_O의 i번째 블록 + bias 와 차이:",
60 f"{(sum(per_head) + W_O.bias - W_O(concat)).abs().max().item():.1e}")
61print(" → 출력 한 칸 한 칸이 head 4개 모두의 결과를 섞은 값이다")
입력           : (2, 10, 512)
출력           : (2, 10, 512)  ← 입력과 같은 모양
head별 가중치  : (2, 8, 10, 10)  (batch, head, query, key)
head 하나의 d_k: 64 = 512 / 8

파라미터 수: 1,050,624  = 4 × (512×512 + 512)  (W_Q, W_K, W_V, W_O)

1) 프로젝션 후 head로 쪼개는 모양 변화
   linear(x)               (2, 10, 512)
   .view(B, -1, h, d_k)     (2, 10, 8, 64)
   .transpose(1, 2)         (2, 8, 10, 64)  ← head가 batch처럼 앞으로

2) PyTorch 내장 nn.MultiheadAttention과 같은 계산인가?
   최대 차이: 9.69e-08

3) W_O(linears[-1])는 head들의 결과를 섞는다
   concat 모양: (1, 6, 32)  → W_O 통과 → (1, 6, 32)
   ours(y, y, y)와 차이: 0.0e+00
   Σ head_i @ W_O의 i번째 블록 + bias 와 차이: 6.0e-08
   → 출력 한 칸 한 칸이 head 4개 모두의 결과를 섞은 값이다

참고

forward가 하는 일

forward는 모듈을 함수처럼 부를 때 실행되는 본체다. 데모에서 mha = MultiHeadedAttention(h=8, d_model=512)(mha = Multi-Head Attention)로 만든 뒤 mha(x, x, x)라고 부르면 이 함수가 돈다. 코드 주석의 1) 2) 3) 순서로 읽으면 된다. 모양은 데모 기준(문장 2개, 토큰 10개, d_model 512, head 8개)이다.

  • 0) 마스크에 head 축 추가 mask.unsqueeze(1): (2, 1, 10) → (2, 1, 1, 10). 크기 1인 축은 브로드캐스트되므로 head 8개에 같은 마스크가 걸린다.
  • 1) head로 나누기: W_Q·W_K·W_V(linears[0..2])로 투영한 (2, 10, 512)를 .view(2, -1, 8, 64)로 head 8개 × 64칸으로 자르고 .transpose(1, 2)로 (2, 8, 10, 64)를 만든다. head가 앞으로 나와 "표를 쌓은 장 수"가 되므로, 1단계 attention이 head 8개를 한 번에 계산한다.
  • 2) 어텐션: 출력 (2, 8, 10, 64), 가중치 self.attn (2, 8, 10, 10).
  • 3) 합치기: .transpose(1, 2)로 (2, 10, 8, 64)로 되돌리고 .view(2, -1, 512)로 head를 옆으로 이어 붙인(Concat) 뒤 W_O(linears[-1])를 통과시킨다. 중간의 .contiguous()는 transpose로 흐트러진 메모리 배치를 다시 정리하는 것이다. 이게 없으면 view가 에러를 낸다.

전체 흐름을 모양으로 따라가면 이렇다. 점선 상자 안이 1단계에서 만든 attention 함수다.

attention() 안입력 x2문장10토큰512d_modelW_Q · W_K · W_Vlinears[0..2] → q, k, v 3개2문장10토큰512d_model.view(2, -1, 8, 64)512칸을 head 8 × 64로 자르기2문장10토큰8head64d_k.transpose(1, 2)head를 앞으로 (묶음 축)2문장8head10토큰64d_kq @ kᵀ / √d_kscores: 토큰↔토큰 점수표2문장8head10query10keysoftmaxp_attn (= self.attn)2문장8head10query10keyp_attn @ v비율대로 value 섞기2문장8head10토큰64d_k.transpose(1, 2)head를 다시 뒤로2문장10토큰8head64d_k.contiguous().view(2, -1, 512)head 8개 칸을 나란히 놓기만 함2문장10토큰512head1 … head8W_Olinears[-1]2문장10토큰출력 1칸 ← 512칸 전부512칸마다 다른 비율로 섞임

마지막의 del query, key, value는 중간 텐서를 일찍 놓아 메모리를 아끼려는 줄이고, 결과에는 영향이 없다.

W_O(self.linears[-1])의 역할

head 8개의 결과(64차원씩)를 이어 붙이면 다시 512차원이 되지만, 이 상태에서는 0~63번 칸에 head 1 결과만, 64~127번 칸에 head 2 결과만 들어 있어 head끼리 정보가 섞이지 않는다. 마지막 nn.Linear(512, 512)가 이 512칸 전체를 섞어서 출력 한 칸 한 칸이 모든 head의 결과를 반영하게 만든다. 수식으로는 $\mathrm{Concat}(\mathrm{head_1}, ..., \mathrm{head_h})W^O = \sum_i \mathrm{head_i} W^O_i$, 즉 head마다 W_O의 자기 몫(64행)을 곱해 더하는 것과 같다. 출력 크기를 다시 d_model로 맞춰 다음 층에 넘기는 역할도 한다.

self.linears[-1](x)가 동작하는 방식

self.linears[-1]은 weight(512×512)와 bias(512)를 가진 nn.Linear 객체다. 객체에 (x)를 붙이면 부모 클래스 nn.Module의 __call__이 실행되고, 그 안에서 forward를 부른다. 그래서 우리는 forward만 정의하면 된다(MultiHeadedAttention도 같은 방식이다). nn.Linear.forward는 x @ weight.T + bias이고, 마지막 축만 계산에 쓴다. (2, 10, 512)라면 512짜리 벡터 20개 각각에 같은 W_O를 따로 곱하므로 출력도 (2, 10, 512)다.

x (2, 10, 512)출력 (2, 10, 512)문장 0문장 1문장 0문장 1W_O512 × 512하나뿐 (20번 재사용)x[0, 3]출력[0, 3]512칸 → 512칸, 토큰 하나씩다른 토큰 칸은 쓰지 않음앞 (2, 10) = 20번 반복모양 그대로

head 축을 앞으로 빼는 이유 (transpose(1, 2))

attention은 마지막 두 축을 "표 한 장"으로 보고 계산한다. view 직후 (2, 10, 8, 64)는 마지막 두 축이 (head, d_k)라서, 그대로 넣으면 토큰끼리가 아니라 head끼리 비교한 8×8 표가 나온다. head 8은 비교 대상이 아니라 "같은 계산을 따로 몇 번 하느냐"이므로 문장 수처럼 앞으로 빼서 묶음 축으로 둔다.

view 직후 (2, 10, 8, 64)2문장10토큰8head64d_k마지막 두 축 = (head, d_k) → head끼리 비교 ✗transpose(1, 2)(2, 8, 10, 64)2문장8head10토큰64d_k묶음: 2 × 8 = 16장표 한 장 = (토큰, d_k) ✓

점수표 (2, 8, 10, 10)의 의미

attention 안의 query @ key.transpose(-2, -1) 결과다. 문장 2개 × head 8개 = 16장의 10×10 "토큰 ↔ 토큰" 점수표로, 행은 query 토큰, 열은 key 토큰이다. 데모 출력의 head별 가중치 (2, 8, 10, 10)은 여기에 softmax를 건 것이다.

key 토큰 10 →query 토큰 10 →… 16장맨 앞 = 문장 0 · head 0query (2, 8, 10, 64)@ keyᵀ (2, 8, 64, 10)= scores (2, 8, 10, 10)앞 (2, 8): 문장 2 × head 8 = 16장뒤 (10, 10): 표 한 장칠한 칸 (2, 5): 토큰 2가 토큰 5를얼마나 참고할지의 점수

마스크에 unsqueeze(1)을 하는 이유

PyTorch는 모양이 다른 텐서를 오른쪽 끝부터 맞춘다(브로드캐스트). 3차원 마스크 (2, 1, 10)을 그대로 쓰면 마스크의 문장 축이 점수표의 head 축에 붙는다. 문장 수와 head 수가 다르면 에러가 나고, 같으면(둘 다 8) 에러 없이 엉뚱한 칸이 가려진다. unsqueeze(1)로 head 자리에 크기 1인 축을 끼우면 문장 축끼리 맞고, 모든 head에 같은 마스크가 퍼진다. 원본 주석 "Same mask applied to all h heads"가 이 뜻이다.

문장headquerykeyscores281010mask 그대로2110✗ 문장 축이 head 자리에mask.unsqueeze(1)21110✓ 문장끼리 맞음오른쪽 끝부터 맞춘다크기 1인 축은 상대 크기만큼 복사된다 (head 8개에 같은 마스크)

@ 연산

a @ b는 파이썬의 행렬곱 연산자로, PyTorch에서는 torch.matmul(a, b)와 같다(실제 코드는 torch.matmul로 쓰여 있다). 3차원 이상이면 앞 축은 묶음으로 두고 마지막 두 축끼리만 곱한다. 곱하는 규칙은 왼쪽의 마지막 축과 오른쪽의 앞 축(행렬이면 행)을 곱해서 더한다이다. 그래서 둘의 길이가 같아야 하고, 그 축은 결과에서 사라진다. 예: nn.Linear의 x @ weight.T는 (2, 10, 512) @ (512, 512) → (2, 10, 512)로, x의 마지막 축 512만 섞이고 (2, 10)은 그대로 남는다. (torch.matmul 문서)

주의: a * b는 행렬곱이 아니라 같은 위치끼리 곱하는 원소별 곱이다.