LLM 25일 코스 - Day 5: Attention 메커니즘 심화

Day 5: Attention 메커니즘 심화

Transformer의 핵심은 Attention입니다. 오늘은 Query/Key/Value의 의미부터 Multi-Head Attention, 그리고 위치 인코딩까지 numpy로 직접 구현하며 완전히 이해합니다.

Query, Key, Value의 직관적 이해

도서관 비유로 설명하면:

  • Query(Q): 내가 찾고 싶은 것 (검색어)
  • Key(K): 각 책의 제목/태그 (색인)
  • Value(V): 실제 책의 내용 (콘텐츠)

Q와 K의 유사도를 계산하고, 그 유사도에 비례하여 V의 내용을 가져옵니다.

Scaled Dot-Product Attention 구현

import numpy as np

def scaled_dot_product_attention(Q, K, V, mask=None):
    """
    Q: (seq_len, d_k) - 쿼리
    K: (seq_len, d_k) - 키
    V: (seq_len, d_v) - 값
    mask: 디코더에서 미래 토큰을 가리는 용도
    """
    d_k = K.shape[-1]

    # 내적 후 스케일링 (d_k가 크면 내적값도 커져서 softmax가 극단적으로 됨)
    scores = np.matmul(Q, K.T) / np.sqrt(d_k)

    # 마스킹: 디코더에서 미래 토큰을 보지 못하게
    if mask is not None:
        scores = np.where(mask == 0, -1e9, scores)

    # Softmax로 확률 분포 생성
    exp_scores = np.exp(scores - np.max(scores, axis=-1, keepdims=True))
    weights = exp_scores / np.sum(exp_scores, axis=-1, keepdims=True)

    return np.matmul(weights, V), weights

# 4개 토큰, 8차원
seq_len, d_k = 4, 8
Q = np.random.randn(seq_len, d_k)
K = np.random.randn(seq_len, d_k)
V = np.random.randn(seq_len, d_k)

# 인과적 마스크 (GPT 스타일: 이전 토큰만 참조)
causal_mask = np.tril(np.ones((seq_len, seq_len)))
print(f"인과적 마스크:\n{causal_mask.astype(int)}")

output, weights = scaled_dot_product_attention(Q, K, V, mask=causal_mask)
print(f"어텐션 가중치:\n{weights.round(3)}")

Multi-Head Attention 구현

def multi_head_attention(x, num_heads, d_model):
    """
    여러 개의 어텐션 헤드를 병렬로 실행
    각 헤드가 서로 다른 관계 패턴을 학습
    """
    d_k = d_model // num_heads
    seq_len = x.shape[0]
    outputs = []

    for head in range(num_heads):
        # 각 헤드마다 별도의 Q, K, V 투영 가중치
        W_q = np.random.randn(d_model, d_k) * 0.1
        W_k = np.random.randn(d_model, d_k) * 0.1
        W_v = np.random.randn(d_model, d_k) * 0.1

        Q = np.matmul(x, W_q)
        K = np.matmul(x, W_k)
        V = np.matmul(x, W_v)

        head_output, _ = scaled_dot_product_attention(Q, K, V)
        outputs.append(head_output)

    # 모든 헤드의 출력을 연결
    concatenated = np.concatenate(outputs, axis=-1)

    # 최종 선형 투영
    W_o = np.random.randn(d_model, d_model) * 0.1
    return np.matmul(concatenated, W_o)

d_model = 64
num_heads = 8  # 64 / 8 = 8차원씩 각 헤드가 담당
x = np.random.randn(4, d_model)

output = multi_head_attention(x, num_heads, d_model)
print(f"Multi-Head Attention 출력: {output.shape}")
# 헤드 1: 주어-동사 관계, 헤드 2: 형용사-명사 관계, ...

위치 인코딩: Sinusoidal vs RoPE

import numpy as np

def sinusoidal_position_encoding(max_len, d_model):
    """원본 Transformer의 위치 인코딩"""
    pe = np.zeros((max_len, d_model))
    position = np.arange(max_len)[:, np.newaxis]
    div_term = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model))

    pe[:, 0::2] = np.sin(position * div_term)  # 짝수 차원: sin
    pe[:, 1::2] = np.cos(position * div_term)  # 홀수 차원: cos
    return pe

def apply_rope(x, position):
    """RoPE (Rotary Position Embedding) - Llama, GPT-NeoX 등에서 사용"""
    d = x.shape[-1]
    freqs = 1.0 / (10000 ** (np.arange(0, d, 2) / d))
    angles = position * freqs

    # 짝수/홀수 차원을 회전
    cos_vals = np.cos(angles)
    sin_vals = np.sin(angles)
    x_even, x_odd = x[..., 0::2], x[..., 1::2]
    rotated_even = x_even * cos_vals - x_odd * sin_vals
    rotated_odd = x_even * sin_vals + x_odd * cos_vals

    result = np.zeros_like(x)
    result[..., 0::2] = rotated_even
    result[..., 1::2] = rotated_odd
    return result

# Sinusoidal 위치 인코딩 확인
pe = sinusoidal_position_encoding(max_len=10, d_model=16)
print(f"위치 인코딩 형태: {pe.shape}")
print(f"위치 0과 1의 차이: {np.linalg.norm(pe[0] - pe[1]):.3f}")
print(f"위치 0과 9의 차이: {np.linalg.norm(pe[0] - pe[9]):.3f}")
# 가까운 위치일수록 벡터가 유사

Attention은 “어떤 토큰이 어떤 토큰에 집중해야 하는가”를 학습합니다. Multi-Head는 여러 관점에서 동시에 이를 수행하여 더 풍부한 표현을 만듭니다.

오늘의 연습문제

  1. np.sqrt(d_k)로 스케일링하는 이유를 수학적으로 설명해보세요. d_k가 64일 때와 스케일링을 하지 않을 때 softmax 출력이 어떻게 달라지는지 실험하세요.
  2. Multi-Head Attention에서 헤드 수를 1, 4, 8, 16으로 바꿔보고 d_k가 어떻게 달라지는지 확인하세요. 헤드 수가 너무 많으면 어떤 문제가 생길까요?
  3. Sinusoidal 위치 인코딩과 RoPE의 가장 큰 차이점을 정리하고, 왜 최신 모델들이 RoPE를 선호하는지 조사해보세요.

이 글이 도움이 되었나요?