트랜스포머·PEFT 2일 과정3장 · Attention과 Encoder14 / 18← 이전목차다음 →MetaContents

3장. Attention과 Encoder

실습 3 — Multi-Head 로 나누고 합치기

낱말 4개, d_model 8 인 입력을 head 2개로 나눠 Attention 을 따로 계산하고, 이어 붙인 뒤 W_O 로 섞습니다. 원본 노트북에는 코드가 없어 새로 쓴 코드입니다. 가중치는 np.random.seed(1) 로 만든 무작위 값입니다.

코드를 단계별로 따라가기 전에 실행 흐름을 한 장으로 봅니다.

실습 3 코드의 실행 흐름도. n, d_model, h 를 4, 8, 2 로 정해 d_head 4 를 구하고, X 와 W_Q, W_K, W_V, W_O 를 만들어 Q, K, V 를 (4, 8) 로 계산한다. split_heads 로 (2, 4, 4) 로 나눈 뒤, head 마다 √4 = 2 로 나눈 점수를 softmax 해 heads_out 을 만들고, head 를 하나씩 도는 반복으로 가중치표를 찍는다. 끝으로 이어 붙여 (4, 8) 로 되돌리고 W_O 를 곱한 뒤 가중치 수 256 을 찍는다
단계하는 일반복과 조건핵심 한 줄
1입력 X 와 규칙표 네 장 만들고 Q, K, V 계산없음(규칙표 네 장은 for _ in range(4) 로 한 줄에 만든다)Q, K, V = X @ W_Q, X @ W_K, X @ W_V
28칸을 4칸씩 두 head 로 나누기없음M.reshape(n, h, d_head).transpose(1, 0, 2)
3head 마다 가중치표 만들고 찍기head 2개를 하나씩 도는 반복(for i in range(h)), 찍기만 반복한다weights = softmax(scores)
4이어 붙이고 W_O 로 섞기없음output = concat @ W_O
  • 반복은 찍을 때만: 3단계의 계산 Qh @ Kh.transpose(0, 2, 1) 은 3차원 배열의 @ 라서 두 head 를 한꺼번에 계산합니다. 마름모 "head 남았나?"의 반복은 계산이 끝난 표를 head 마다 하나씩 찍을 뿐입니다.
  • 모양의 흐름: 네모에 적은 shape 를 따라가면 (4, 8) → (2, 4, 4) → (4, 8) 입니다. 나눴다가 다시 붙여 들어간 모양 그대로 나옵니다.
  • 끝부분: 가중치 수 256 은 4 * d_model * d_model 로, head 수와 상관이 없습니다.

1단계. 입력과 가중치 준비하기

회의 자료(X)와, 전문가들이 함께 쓸 규칙표 네 장(W_Q, W_K, W_V, W_O)을 준비합니다.

Python
import numpy as np
import pandas as pd

np.set_printoptions(precision=2, suppress=True)
np.random.seed(1)

words = ["나는", "학교에", "버스로", "갔다"]
n, d_model, h = 4, 8, 2          # 낱말 4개, 벡터 폭 8, head 2개
d_head = d_model // h            # head 하나의 폭 = 8 ÷ 2 = 4

X = np.random.randn(n, d_model)  # 입력 (4, 8): 무작위 값으로 대신한다
# 가중치 네 장: 모두 (8, 8). × 0.5 로 값을 작게
W_Q, W_K, W_V, W_O = (np.random.randn(d_model, d_model) * 0.5 for _ in range(4))

Q, K, V = X @ W_Q, X @ W_K, X @ W_V
print("X:", X.shape, " Q, K, V:", Q.shape, K.shape, V.shape)
# 결과: X: (4, 8)  Q, K, V: (4, 8) (4, 8) (4, 8)
print("head 수:", h, " head 하나의 폭:", d_head)
# 결과: head 수: 2  head 하나의 폭: 4
출력
X: (4, 8)  Q, K, V: (4, 8) (4, 8) (4, 8)
head 수: 2  head 하나의 폭: 4

2단계. head 마다 조각 나누기

8칸짜리 자료를 4칸씩 두 전문가에게 나눠 줍니다.

Python
def split_heads(M):
    # (낱말, d_model) → (낱말, h, d_head) → (h, 낱말, d_head)
    return M.reshape(n, h, d_head).transpose(1, 0, 2)

Qh, Kh, Vh = split_heads(Q), split_heads(K), split_heads(V)
print("나눈 Q:", Qh.shape)
# 결과: 나눈 Q: (2, 4, 4) → head 2개, 낱말 4개, 칸 4개
print("head 0 이 받은 '나는' 의 Q:", Qh[0, 0])
print("Q 의 '나는' 앞 4칸     :", Q[0, :4])
# 결과: 두 줄이 같다 → head 0 은 앞 4칸, head 1 은 뒤 4칸을 맡는다
출력
나눈 Q: (2, 4, 4)
head 0 이 받은 '나는' 의 Q: [-1.19 -2.72 -0.72  2.36]
Q 의 '나는' 앞 4칸     : [-1.19 -2.72 -0.72  2.36]

3단계. head 마다 Attention 계산하기

두 전문가가 각자 자기 몫으로 가중치표를 만듭니다. 같은 문장인데 표의 무늬가 다릅니다.

Python
def softmax(x):
    e = np.exp(x - x.max(axis=-1, keepdims=True))
    return e / e.sum(axis=-1, keepdims=True)

# Kh.transpose(0, 2, 1): head 마다 K 를 전치 (2, 4, 4) → (2, 4, 4)
# (2, 4, 4) @ (2, 4, 4) → head 마다 점수표 (4, 4)
scores = Qh @ Kh.transpose(0, 2, 1) / np.sqrt(d_head)   # √4 = 2 로 나눈다
weights = softmax(scores)                               # (2, 4, 4)
heads_out = weights @ Vh                                # (2, 4, 4)

for i in range(h):
    table = pd.DataFrame(weights[i].round(2), index=words, columns=words)
    print(f"--- head {i} 가중치표 ---")
    print(table)
    print("가장 많이 참고:", table.idxmax(axis=1).to_dict())
# 결과: head 마다 다른 가중치표. '나는' 은 head 0 에서 버스로, head 1 에서 학교에를 가장 많이 참고
출력
--- head 0 가중치표 ---
       나는   학교에   버스로    갔다
나는   0.13  0.04  0.57  0.26
학교에  0.12  0.69  0.01  0.18
버스로  0.16  0.71  0.02  0.11
갔다   0.08  0.80  0.01  0.11
가장 많이 참고: {'나는': '버스로', '학교에': '학교에', '버스로': '학교에', '갔다': '학교에'}
--- head 1 가중치표 ---
       나는   학교에   버스로    갔다
나는   0.05  0.89  0.03  0.03
학교에  0.06  0.49  0.10  0.34
버스로  0.56  0.01  0.34  0.10
갔다   0.22  0.09  0.46  0.22
가장 많이 참고: {'나는': '학교에', '학교에': '학교에', '버스로': '나는', '갔다': '버스로'}

같은 문장인데 두 head 의 표가 다릅니다. "나는" 행을 보면 head 0 은 "버스로"를 0.57, head 1 은 "학교에"를 0.89 만큼 참고합니다. 지금은 무작위 W 라 어느 쪽에도 뜻은 없지만, 학습된 모델에서는 이렇게 head 마다 다른 관계(주어와 동사, 대명사와 대상, 바로 옆 낱말)를 맡습니다.

4단계. 이어 붙이고 W_O 로 섞기

두 전문가의 의견을 회의록에 나란히 적고(Concat), 팀장이 하나로 정리합니다(W_O).

Python
# (2, 4, 4) → (4, 2, 4) → (4, 8): head 결과를 옆으로 이어 붙인다
concat = heads_out.transpose(1, 0, 2).reshape(n, d_model)
output = concat @ W_O            # (4, 8) @ (8, 8) → (4, 8)

print("head 출력:", heads_out.shape, "→ Concat:", concat.shape, "→ W_O 뒤:", output.shape)
# 결과: head 출력: (2, 4, 4) → Concat: (4, 8) → W_O 뒤: (4, 8)

# 파라미터 수: W_Q, W_K, W_V, W_O 네 장 × 8 × 8
print("Multi-Head 가중치 수:", 4 * d_model * d_model)
# 결과: Multi-Head 가중치 수: 256 → head 를 몇 개로 나누든 같다
출력
head 출력: (2, 4, 4) → Concat: (4, 8) → W_O 뒤: (4, 8)
Multi-Head 가중치 수: 256

이 실습이 뜻하는 것

회의 자료를 두 전문가에게 반씩 나눠 주고(1~2단계), 전문가마다 따로 의견(가중치표)을 내게 한 뒤(3단계), 회의록에 나란히 적어 팀장이 하나로 정리한(4단계) 과정입니다. 여기서 전문가가 head, 각자의 몫이 Qh[i], Kh[i], Vh[i], 회의록이 concat, 팀장이 W_O 입니다.

단계출력뜻
1. 준비Q, K, V 모두 (4, 8)head 로 나누기 전
2. 나누기(2, 4, 4)head 0 은 앞 4칸, head 1 은 뒤 4칸
3. head 별 Attention"나는" 행: head 0 은 버스로 0.57, head 1 은 학교에 0.89같은 문장, 다른 관점
4. 합치기(4, 8) 로 돌아옴입력과 같은 모양

한 줄로 줄이면, Multi-Head 는 폭을 h 조각으로 나눠 Attention 을 h 번 계산하고, 이어 붙여 원래 폭으로 되돌린 뒤 W_O 로 섞습니다.

코드 설명

  • split_heads 의 reshape(n, h, d_head) 는 8칸을 4칸씩 자르고, transpose(1, 0, 2) 는 head 를 맨 앞 축으로 보냅니다. 그래야 @ 가 head 마다 따로 계산됩니다.
  • 3차원 배열의 @ 는 맨 앞 축(head)마다 행렬곱을 합니다. for 문 없이 두 head 를 한꺼번에 계산한 것입니다.
  • head 마다 √d_head = √4 = 2 로 나눕니다. 원 논문도 head 하나의 폭 64 를 써서 8 로 나눕니다.
  • 실제 구현은 head 마다 W 를 따로 두는 대신 큰 W_Q 하나를 곱한 뒤 잘라 씁니다. 이 실습도 그 방식이고, 강사자료 그림의 "head 마다 다른 W" 와 계산이 같습니다.
  • 가중치 수 256 은 head 수와 상관이 없습니다. 나누기는 계산을 여러 관점으로 나누는 것이지 파라미터를 늘리지 않습니다.
← 이전 절3장 · 실습 2 — √d_k 로 나누면 무엇이 달라질까다음 절 →3장 · 실습 4 — Add & Norm 과 FFN 으로 Encoder 블록 완성하기