이미지 로딩 중...

LLM 구현 4편 - Self-Attention 메커니즘 구현 - 슬라이드 1/9
A

AI Generated

2025. 11. 8. · 3 Views

LLM 구현 4편 - Self-Attention 메커니즘 구현

Transformer의 핵심인 Self-Attention 메커니즘을 처음부터 직접 구현해봅니다. Query, Key, Value의 개념부터 Scaled Dot-Product Attention, Multi-Head Attention까지 단계별로 구현하며 LLM의 작동 원리를 깊이 이해합니다.


목차

  1. Query, Key, Value 개념 이해
  2. Scaled Dot-Product Attention 구현
  3. Multi-Head Attention 메커니즘
  4. Positional Encoding 추가
  5. Attention Masking 구현
  6. 완전한 Self-Attention 레이어 구현
  7. Self-Attention vs Cross-Attention
  8. Attention 시각화와 해석

1. Query, Key, Value 개념 이해

시작하며

여러분이 대화할 때를 떠올려보세요. 상대방의 말 중에서 자신에게 중요한 부분에만 집중하게 되죠?

"오늘 회의는 3시에 2층 회의실에서"라는 문장에서 시간과 장소에 자연스럽게 주목하게 됩니다. 바로 이런 선택적 집중이 Self-Attention의 핵심 아이디어입니다.

Transformer 모델이 문장의 각 단어를 처리할 때, 다른 모든 단어들과의 관계를 계산하여 어디에 집중해야 할지 스스로 결정합니다. 이를 가능하게 하는 것이 바로 Query, Key, Value라는 세 가지 벡터입니다.

이 세 가지는 각각 다른 역할을 하면서 함께 작동하여 문맥을 이해하고 중요한 정보를 추출합니다.

개요

간단히 말해서, Query는 "나는 무엇을 찾고 있는가?", Key는 "나는 어떤 정보를 가지고 있는가?", Value는 "실제로 전달할 정보는 무엇인가?"를 나타냅니다. 실무에서 LLM을 구현하거나 커스터마이징할 때, 이 세 벡터의 역할을 정확히 이해하는 것이 필수적입니다.

예를 들어, 문서 요약 모델을 만들 때 어떤 단어가 다른 단어들과 어떻게 상호작용하는지 분석하려면 이 개념이 필요합니다. 전통적인 RNN이나 LSTM은 순차적으로 단어를 처리했다면, Self-Attention은 모든 단어 쌍 사이의 관계를 동시에 계산할 수 있습니다.

이것이 Transformer가 훨씬 빠르고 효과적인 이유입니다. 각 입력 임베딩 벡터는 세 개의 서로 다른 가중치 행렬(Wq, Wk, Wv)과 곱해져서 Query, Key, Value 벡터로 변환됩니다.

Query와 Key의 내적으로 attention score를 계산하고, 이를 가중치로 사용하여 Value들의 가중합을 구합니다.

코드 예제

import numpy as np

# 입력 임베딩 (4개 단어, 각 512차원)
d_model = 512
seq_len = 4
X = np.random.randn(seq_len, d_model)

# Query, Key, Value 변환 행렬 초기화
Wq = np.random.randn(d_model, d_model) / np.sqrt(d_model)
Wk = np.random.randn(d_model, d_model) / np.sqrt(d_model)
Wv = np.random.randn(d_model, d_model) / np.sqrt(d_model)

# Query, Key, Value 벡터 생성
Q = X @ Wq  # (4, 512) - "무엇을 찾고 있나?"
K = X @ Wk  # (4, 512) - "어떤 정보를 가지고 있나?"
V = X @ Wv  # (4, 512) - "실제 전달할 정보"

print(f"Query shape: {Q.shape}")
print(f"Key shape: {K.shape}")
print(f"Value shape: {V.shape}")

설명

이것이 하는 일: 각 입력 토큰을 세 가지 다른 관점으로 변환하여, 문장 내에서 어떤 단어가 다른 단어와 얼마나 관련이 있는지 계산할 준비를 합니다. 첫 번째 단계에서, 입력 임베딩 벡터 X를 세 개의 서로 다른 가중치 행렬과 곱합니다.

각 행렬은 독립적으로 학습되며, 입력을 서로 다른 "공간"으로 투영합니다. Wq는 "질문하는 관점", Wk는 "답변하는 관점", Wv는 "정보 전달 관점"으로 생각할 수 있습니다.

Query 벡터는 현재 토큰이 다른 토큰들로부터 필요로 하는 정보를 나타냅니다. "The cat sat on the mat"에서 "sat"의 Query는 "누가 앉았지?"라는 질문을 인코딩하고, "cat"의 Key와 높은 유사도를 가지게 됩니다.

Key 벡터는 각 토큰이 제공할 수 있는 정보의 종류를 나타냅니다. 다른 토큰들의 Query와 비교되어 attention score를 계산하는 데 사용됩니다.

Key와 Query의 내적이 클수록 두 토큰이 서로 관련성이 높다는 의미입니다. Value 벡터는 실제로 전달될 정보를 담고 있습니다.

Attention score가 계산되면, 이를 가중치로 사용하여 Value 벡터들의 가중합을 구합니다. 이렇게 하면 관련성 높은 토큰의 정보가 더 많이 반영됩니다.

가중치 행렬을 np.sqrt(d_model)로 나누는 것은 Xavier 초기화 기법으로, 학습 초기 gradient의 크기를 안정화시킵니다. 이는 깊은 네트워크에서 학습이 잘 되도록 하는 중요한 테크닉입니다.

실전 팁

💡 Query와 Key의 차원은 같아야 하지만, Value는 다른 차원을 가질 수 있습니다. 다만 실무에서는 구현 단순화를 위해 모두 같은 차원(d_model)을 사용합니다.

💡 가중치 행렬 초기화 시 작은 값을 사용하세요. 너무 큰 초기값은 학습 초기에 gradient vanishing/exploding 문제를 일으킬 수 있습니다.

💡 배치 처리 시 차원은 (batch_size, seq_len, d_model)이 됩니다. 행렬 곱셈 순서에 주의하여 broadcasting이 올바르게 작동하도록 하세요.

💡 디버깅할 때는 작은 차원(예: d_model=8, seq_len=3)으로 시작하여 수동으로 계산한 값과 비교해보세요. 이해도가 크게 향상됩니다.

💡 PyTorch나 TensorFlow로 구현할 때는 nn.Linear 레이어를 사용하면 가중치 초기화와 bias 처리가 자동으로 됩니다.


2. Scaled Dot-Product Attention 구현

시작하며

여러분이 검색 엔진을 사용할 때를 생각해보세요. 검색어와 각 문서의 관련성을 점수로 매기고, 가장 관련 있는 문서들을 상위에 보여주죠?

Self-Attention도 정확히 같은 방식으로 작동합니다. 문장의 각 단어는 다른 모든 단어와의 "관련성 점수"를 계산합니다.

"The cat sat on the mat"에서 "cat"은 "sat"과 높은 관련성을, "the"와는 낮은 관련성을 가집니다. 이 점수들을 사용하여 문맥 정보를 통합합니다.

하지만 여기에는 중요한 수학적 트릭이 숨어있습니다. 바로 "Scaling"인데, 이것 없이는 학습이 불안정해집니다.

왜 그런지, 어떻게 해결하는지 함께 알아보겠습니다.

개요

간단히 말해서, Scaled Dot-Product Attention은 Query와 Key의 유사도를 계산하고, 이를 확률 분포로 변환한 뒤, Value의 가중합을 구하는 메커니ズ입니다. 실무에서 이것은 Transformer의 가장 핵심적인 연산입니다.

GPT, BERT, T5 등 모든 주요 LLM이 이 메커니즘을 수천 번 반복하여 텍스트를 이해합니다. Attention 가중치를 시각화하면 모델이 어디에 집중하는지 볼 수 있어 해석 가능성을 높입니다.

기존의 Additive Attention이나 Location-based Attention과 달리, Dot-Product Attention은 계산이 매우 효율적입니다. 행렬 곱셈만으로 구현되어 GPU에서 최적화하기 쉽고, 병렬 처리가 가능합니다.

핵심 특징은 세 가지입니다. 첫째, sqrt(d_k)로 나누는 스케일링이 gradient를 안정화시킵니다.

둘째, Softmax가 attention을 확률 분포로 만들어 해석 가능하게 합니다. 셋째, 모든 토큰 쌍을 동시에 계산하여 병렬성이 뛰어납니다.

왜 sqrt(d_k)로 나누는지 궁금하실 겁니다. 차원이 클수록 내적 값이 커지고, Softmax가 극단적인 값(0 또는 1)으로 수렴하여 gradient가 거의 0이 됩니다.

스케일링은 이를 방지합니다.

코드 예제

import numpy as np

def scaled_dot_product_attention(Q, K, V, mask=None):
    """
    Args:
        Q: Query 행렬 (seq_len, d_k)
        K: Key 행렬 (seq_len, d_k)
        V: Value 행렬 (seq_len, d_v)
        mask: 마스킹 행렬 (선택적)
    """
    d_k = Q.shape[-1]

    # 1. Query와 Key의 내적 계산
    scores = Q @ K.T / np.sqrt(d_k)  # (seq_len, seq_len)

    # 2. 마스킹 적용 (선택적, 예: 미래 토큰 보지 못하게)
    if mask is not None:
        scores = scores + (mask * -1e9)

    # 3. Softmax로 확률 분포 변환
    attention_weights = np.exp(scores) / np.sum(np.exp(scores), axis=-1, keepdims=True)

    # 4. Value의 가중합 계산
    output = attention_weights @ V  # (seq_len, d_v)

    return output, attention_weights

# 사용 예시
seq_len, d_k = 4, 64
Q = np.random.randn(seq_len, d_k)
K = np.random.randn(seq_len, d_k)
V = np.random.randn(seq_len, d_k)

output, weights = scaled_dot_product_attention(Q, K, V)
print(f"Output shape: {output.shape}")
print(f"Attention weights shape: {weights.shape}")
print(f"Attention weights sum: {weights.sum(axis=-1)}")  # 각 행의 합은 1

설명

이것이 하는 일: 문장의 각 위치에서 다른 모든 위치와의 관련성을 계산하고, 관련성 높은 정보를 더 많이 반영한 새로운 표현을 만듭니다. 첫 번째 단계에서, Query와 Key의 전치 행렬을 곱하여 attention score 행렬을 만듭니다.

결과는 (seq_len, seq_len) 크기의 행렬로, (i, j) 위치의 값은 i번째 토큰이 j번째 토큰과 얼마나 관련 있는지를 나타냅니다. 여기서 sqrt(d_k)로 나누는 것이 "Scaled"의 의미입니다.

두 번째 단계에서는 선택적으로 마스킹을 적용합니다. 디코더의 경우 미래 토큰을 볼 수 없어야 하므로, 상삼각 행렬에 매우 작은 값(-1e9)을 더합니다.

Softmax를 거치면 이 부분이 거의 0이 되어 미래 정보가 차단됩니다. 패딩 토큰도 같은 방식으로 마스킹합니다.

세 번째 단계에서 Softmax를 적용하여 각 행을 확률 분포로 변환합니다. 이제 각 토큰이 다른 토큰들에게 얼마나 "주목"해야 하는지 0과 1 사이의 가중치로 표현됩니다.

모든 가중치의 합은 1이 되어 해석하기 쉽습니다. 마지막 단계에서 attention 가중치와 Value 행렬을 곱합니다.

이는 가중 평균을 계산하는 것으로, 관련성 높은 토큰의 Value가 더 많이 반영됩니다. 결과는 원래 입력과 같은 차원을 유지하면서도 문맥 정보가 통합된 새로운 표현입니다.

실무에서 이 함수는 수천 번 호출되므로 최적화가 중요합니다. NumPy 대신 GPU 연산이 가능한 PyTorch나 JAX를 사용하고, torch.nn.functional.scaled_dot_product_attention 같은 최적화된 커널을 활용하면 속도가 크게 향상됩니다.

Attention weights를 저장하면 모델의 해석에 유용합니다. 어떤 단어가 어떤 단어에 집중하는지 히트맵으로 시각화하여, 모델이 문법적 관계나 의미적 유사성을 학습했는지 확인할 수 있습니다.

실전 팁

💡 Softmax 계산 시 수치 안정성을 위해 최댓값을 빼주세요: scores - np.max(scores, axis=-1, keepdims=True). 이렇게 하면 overflow를 방지할 수 있습니다.

💡 배치 처리 시 차원은 (batch_size, num_heads, seq_len, seq_len)이 됩니다. einsum이나 적절한 reshape을 사용하여 broadcasting을 올바르게 처리하세요.

💡 긴 시퀀스에서는 메모리가 O(n²)로 증가합니다. 실무에서는 Sparse Attention, Linformer, Performer 같은 효율적인 변형을 고려하세요.

💡 Attention dropout을 추가하면 regularization 효과가 있습니다. Softmax 직후에 dropout을 적용하여 일부 attention을 무작위로 0으로 만듭니다.

💡 디버깅할 때는 attention weights의 엔트로피를 확인하세요. 너무 균등하면 정보가 희석되고, 너무 집중되면 overfitting의 신호일 수 있습니다.


3. Multi-Head Attention 메커니즘

시작하며

여러분이 책을 읽을 때 동시에 여러 관점에서 생각하게 되죠? 문법적 구조, 의미적 관계, 감정적 뉘앙스 등을 동시에 파악합니다.

Multi-Head Attention도 같은 아이디어에서 출발합니다. 하나의 Attention만 사용하면 모델이 한 가지 패턴만 포착하는 경향이 있습니다.

예를 들어 "주어-동사" 관계에만 집중하고 "수식어-피수식어" 관계는 놓칠 수 있습니다. 이는 언어의 복잡성을 제대로 모델링하지 못합니다.

바로 이럴 때 필요한 것이 Multi-Head Attention입니다. 여러 개의 독립적인 Attention을 병렬로 실행하여, 각각 다른 종류의 관계를 학습하게 만듭니다.

이를 통해 훨씬 풍부한 문맥 이해가 가능해집니다.

개요

간단히 말해서, Multi-Head Attention은 입력을 여러 부분공간(subspace)으로 나누고, 각각에서 독립적으로 Attention을 계산한 뒤, 결과를 합치는 메커니즘입니다. 실무에서 이것은 모델의 표현력을 크게 향상시킵니다.

GPT-3는 96개의 head를, BERT는 12개의 head를 사용합니다. 각 head는 서로 다른 언어적 패턴을 포착하도록 학습되며, 문법, 의미, 위치 관계 등을 동시에 모델링합니다.

기존의 단일 Attention이 모든 정보를 하나의 공간에서 처리했다면, Multi-Head는 정보를 여러 "전문가"에게 분산시킵니다. 각 전문가는 자신의 관점에서 중요한 패턴을 찾고, 마지막에 이들의 의견을 통합합니다.

핵심 특징은 다음과 같습니다. 첫째, 각 head가 d_model/num_heads 차원으로 작동하여 계산량이 크게 증가하지 않습니다.

둘째, head들이 독립적으로 학습되어 다양한 패턴을 포착합니다. 셋째, 최종 선형 변환이 head들의 출력을 효과적으로 통합합니다.

여러 연구에서 각 head가 실제로 다른 역할을 학습함을 보였습니다. 어떤 head는 구문 구조를, 어떤 head는 공지시어 해결을, 어떤 head는 명명된 개체 간 관계를 학습합니다.

코드 예제

import numpy as np

class MultiHeadAttention:
    def __init__(self, d_model, num_heads):
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads  # 각 head의 차원

        # 각 head를 위한 Query, Key, Value 변환 행렬
        self.Wq = np.random.randn(d_model, d_model) / np.sqrt(d_model)
        self.Wk = np.random.randn(d_model, d_model) / np.sqrt(d_model)
        self.Wv = np.random.randn(d_model, d_model) / np.sqrt(d_model)

        # 최종 출력 변환 행렬
        self.Wo = np.random.randn(d_model, d_model) / np.sqrt(d_model)

    def split_heads(self, x):
        """(seq_len, d_model) -> (num_heads, seq_len, d_k)"""
        seq_len = x.shape[0]
        x = x.reshape(seq_len, self.num_heads, self.d_k)
        return x.transpose(1, 0, 2)  # (num_heads, seq_len, d_k)

    def forward(self, X):
        # 1. 선형 변환
        Q = X @ self.Wq
        K = X @ self.Wk
        V = X @ self.Wv

        # 2. Head로 분할
        Q = self.split_heads(Q)  # (num_heads, seq_len, d_k)
        K = self.split_heads(K)
        V = self.split_heads(V)

        # 3. 각 head에서 Scaled Dot-Product Attention
        outputs = []
        for i in range(self.num_heads):
            scores = Q[i] @ K[i].T / np.sqrt(self.d_k)
            attention = np.exp(scores) / np.sum(np.exp(scores), axis=-1, keepdims=True)
            output = attention @ V[i]
            outputs.append(output)

        # 4. Head들을 concatenate
        concat = np.concatenate(outputs, axis=-1)  # (seq_len, d_model)

        # 5. 최종 선형 변환
        final_output = concat @ self.Wo
        return final_output

# 사용 예시
d_model, num_heads = 512, 8
seq_len = 10
X = np.random.randn(seq_len, d_model)

mha = MultiHeadAttention(d_model, num_heads)
output = mha.forward(X)
print(f"Output shape: {output.shape}")  # (10, 512)

설명

이것이 하는 일: 하나의 거대한 Attention 대신 여러 개의 작은 Attention을 병렬로 실행하여, 입력의 다양한 측면을 동시에 포착합니다. 첫 번째 단계에서 입력을 세 개의 가중치 행렬(Wq, Wk, Wv)과 곱하여 Query, Key, Value를 생성합니다.

이 행렬들의 크기는 (d_model, d_model)로, 단일 Attention과 동일합니다. 차이는 다음 단계에서 이들을 여러 head로 분할한다는 점입니다.

두 번째 단계에서 split_heads 함수를 사용하여 각 벡터를 num_heads개의 작은 벡터로 나눕니다. 예를 들어 d_model=512, num_heads=8이면, 각 head는 d_k=64 차원으로 작동합니다.

이렇게 하면 전체 계산량은 유지하면서도 여러 관점을 확보할 수 있습니다. 세 번째 단계에서 각 head에 대해 독립적으로 Scaled Dot-Product Attention을 수행합니다.

8개의 head가 있다면 8번의 Attention 계산이 병렬로 이루어집니다. 각 head는 자신만의 부분공간에서 Query, Key, Value를 사용하여 서로 다른 패턴을 학습합니다.

네 번째 단계에서 모든 head의 출력을 concatenate합니다. 8개의 (seq_len, 64) 텐서가 하나의 (seq_len, 512) 텐서로 합쳐집니다.

이제 각 head가 발견한 정보가 모두 하나의 벡터에 담기게 됩니다. 마지막 단계에서 최종 선형 변환 Wo를 적용합니다.

이는 단순히 concatenation한 것보다 head들 간의 상호작용을 학습할 수 있게 해줍니다. 예를 들어 어떤 head의 출력이 다른 head의 출력과 결합될 때 더 유용할 수 있습니다.

실무에서 PyTorch를 사용하면 이 과정을 더 효율적으로 구현할 수 있습니다. 특히 torch.nn.MultiheadAttention 모듈은 고도로 최적화되어 있으며, 배치 처리와 마스킹을 자동으로 처리합니다.

Flash Attention 같은 최신 구현은 메모리 효율성도 크게 개선했습니다.

실전 팁

💡 num_heads는 d_model의 약수여야 합니다. 일반적으로 8, 12, 16 같은 값을 사용하며, d_k = d_model / num_heads가 너무 작지 않도록 조정하세요.

💡 실제 구현에서는 for 루프 대신 einsum이나 reshape을 사용하여 모든 head를 한 번에 계산합니다. 이렇게 하면 GPU 병렬화가 훨씬 효율적입니다.

💡 각 head의 attention weights를 시각화하면 매우 흥미로운 패턴을 발견할 수 있습니다. BERT의 경우 일부 head는 다음 단어에, 일부는 구문 구조에 집중합니다.

💡 Residual connection과 Layer Normalization을 함께 사용하세요: output = LayerNorm(X + MultiHeadAttention(X)). 이것이 깊은 Transformer를 학습 가능하게 만듭니다.

💡 Head 수를 늘린다고 항상 성능이 좋아지는 것은 아닙니다. 데이터셋 크기와 모델 용량에 맞게 조정해야 하며, 너무 많으면 오히려 각 head가 중복된 정보를 학습할 수 있습니다.


4. Positional Encoding 추가

시작하며

여러분이 "I love you"와 "You love I"를 읽으면 전혀 다른 의미로 받아들이시죠? 단어의 순서가 의미를 결정하기 때문입니다.

하지만 Self-Attention에는 큰 문제가 하나 있습니다. 바로 단어의 위치 정보가 전혀 없다는 것입니다.

"The cat chased the dog"와 "The dog chased the cat"을 같은 단어들의 집합으로 처리합니다. Attention 메커니즘 자체는 순서를 고려하지 않는 permutation-invariant 연산이기 때문입니다.

이 문제를 해결하는 것이 Positional Encoding입니다. 각 위치에 고유한 벡터를 할당하여 입력 임베딩에 더해줌으로써, 모델이 단어의 순서를 인식할 수 있게 만듭니다.

개요

간단히 말해서, Positional Encoding은 각 토큰의 위치를 나타내는 벡터를 생성하여 입력 임베딩에 더하는 기법입니다. 이를 통해 순서 정보를 모델에 주입합니다.

실무에서 이것은 Transformer가 언어를 제대로 이해하는 데 필수적입니다. Positional Encoding 없이는 "John gave Mary a book"과 "Mary gave John a book"을 구분할 수 없습니다.

기계 번역, 텍스트 생성, 질의응답 등 모든 작업에서 순서 정보가 중요합니다. 전통적인 RNN이나 LSTM은 순차적으로 처리하기 때문에 자연스럽게 순서 정보를 가졌지만, Transformer는 모든 토큰을 동시에 처리하므로 명시적으로 위치를 알려줘야 합니다.

원 논문에서 제안한 방법은 sine과 cosine 함수를 사용하는 것입니다. 각 위치와 차원에 대해 서로 다른 주파수의 사인파를 생성합니다.

이 방법의 장점은 학습이 필요 없고, 학습 시 본 것보다 긴 시퀀스도 처리할 수 있다는 것입니다. 최근에는 학습 가능한(learnable) Positional Embedding을 사용하는 경우도 많습니다.

BERT나 GPT 같은 모델들은 각 위치에 대한 임베딩 벡터를 학습합니다. 이는 고정된 시퀀스 길이에서는 더 유연할 수 있습니다.

코드 예제

import numpy as np

def get_positional_encoding(seq_len, d_model):
    """
    Sinusoidal Positional Encoding 생성
    Args:
        seq_len: 시퀀스 길이
        d_model: 모델 차원
    Returns:
        (seq_len, d_model) 크기의 positional encoding
    """
    # 위치 인덱스 생성: [0, 1, 2, ..., seq_len-1]
    position = np.arange(seq_len)[:, np.newaxis]  # (seq_len, 1)

    # 차원 인덱스 생성: [0, 2, 4, ..., d_model-2]
    div_term = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model))

    # Positional Encoding 행렬 초기화
    pos_encoding = np.zeros((seq_len, d_model))

    # 짝수 인덱스에는 sine, 홀수 인덱스에는 cosine 적용
    pos_encoding[:, 0::2] = np.sin(position * div_term)
    pos_encoding[:, 1::2] = np.cos(position * div_term)

    return pos_encoding

# 사용 예시
seq_len, d_model = 10, 512
pos_encoding = get_positional_encoding(seq_len, d_model)

# 입력 임베딩에 더하기
X = np.random.randn(seq_len, d_model)  # 원래 임베딩
X_with_pos = X + pos_encoding  # 위치 정보가 추가된 임베딩

print(f"Positional Encoding shape: {pos_encoding.shape}")
print(f"First position encoding (처음 8개 차원):\n{pos_encoding[0, :8]}")
print(f"Second position encoding (처음 8개 차원):\n{pos_encoding[1, :8]}")

설명

이것이 하는 일: 시퀀스의 각 위치에 고유한 패턴을 가진 벡터를 생성하여, 모델이 "첫 번째 단어", "두 번째 단어" 같은 위치 개념을 이해할 수 있게 만듭니다. 첫 번째 단계에서 위치 인덱스 배열을 생성합니다.

0부터 seq_len-1까지의 정수로, 각 토큰의 절대적 위치를 나타냅니다. 이를 (seq_len, 1) 형태로 reshape하여 broadcasting이 가능하게 만듭니다.

두 번째 단계에서 각 차원에 대한 주파수를 계산합니다. div_term은 기하급수적으로 감소하는 값들로, 낮은 차원은 빠르게 변하는 고주파를, 높은 차원은 천천히 변하는 저주파를 가집니다.

이렇게 하면 각 차원이 서로 다른 스케일의 위치 정보를 인코딩합니다. 세 번째 단계에서 실제 인코딩을 계산합니다.

짝수 인덱스 차원에는 sine 함수를, 홀수 인덱스 차원에는 cosine 함수를 적용합니다. position * div_term은 각 위치와 차원에 대해 서로 다른 각도를 생성하고, sin/cos는 이를 -1과 1 사이의 값으로 변환합니다.

왜 sine과 cosine을 사용할까요? 첫째, 주기 함수이므로 서로 다른 위치에 고유한 패턴을 부여합니다.

둘째, 임의의 고정된 offset k에 대해 PE(pos+k)를 PE(pos)의 선형 함수로 표현할 수 있어, 모델이 상대적 위치를 학습하기 쉽습니다. 마지막으로 이 positional encoding을 입력 임베딩에 더합니다.

곱하는 것이 아니라 더하는 이유는, 단어의 의미와 위치 정보를 동시에 보존하기 위함입니다. 더하기 연산은 두 정보를 손실 없이 결합합니다.

실무에서는 이 인코딩을 한 번 계산하여 캐싱해두고 재사용합니다. 시퀀스 길이가 달라질 수 있으므로, 예상되는 최대 길이에 대해 미리 계산해두고 필요한 부분만 슬라이싱하여 사용하는 것이 효율적입니다.

실전 팁

💡 Positional Encoding은 학습되지 않으므로 처음 한 번만 계산하면 됩니다. 클래스 초기화 시 생성하여 self.pos_encoding으로 저장하세요.

💡 매우 긴 시퀀스를 다룰 때는 Relative Positional Encoding이나 ALiBi 같은 대안을 고려하세요. 이들은 길이에 더 robust합니다.

💡 학습 가능한 positional embedding을 사용하려면 nn.Embedding(max_seq_len, d_model)을 만들고, 각 위치의 임베딩을 학습시킵니다. 고정된 길이에서는 종종 더 좋은 성능을 보입니다.

💡 시각화할 때 positional encoding을 히트맵으로 그려보세요. 아름다운 웨이브 패턴이 나타나며, 각 차원이 서로 다른 주기를 가짐을 확인할 수 있습니다.

💡 일부 모델(예: RoPE in LLaMA)은 positional encoding을 더하는 대신 attention 계산에 직접 통합합니다. 이는 외삽(extrapolation) 능력을 향상시킬 수 있습니다.


5. Attention Masking 구현

시작하며

여러분이 시험 문제를 풀 때 답안을 미리 보면 안 되죠? 언어 모델도 마찬가지입니다.

디코더에서 다음 단어를 예측할 때, 미래의 단어들을 보면 안 됩니다. 그것은 일종의 "컨닝"입니다.

또 다른 상황을 생각해보세요. 배치 처리할 때 문장 길이가 다르면 짧은 문장에 패딩을 추가합니다.

하지만 이 패딩 토큰들은 실제 의미가 없으므로 attention 계산에서 제외해야 합니다. 바로 이럴 때 필요한 것이 Attention Masking입니다.

특정 위치들 간의 attention을 선택적으로 차단하여, 모델이 보지 말아야 할 정보를 보지 못하게 만듭니다.

개요

간단히 말해서, Attention Masking은 attention score 행렬의 특정 위치에 매우 작은 값을 할당하여, Softmax 후에 해당 attention weight가 거의 0이 되도록 만드는 기법입니다. 실무에서 이것은 두 가지 주요 용도가 있습니다.

첫째, 디코더의 causal masking(인과적 마스킹)으로 미래 토큰을 가립니다. GPT 같은 자기회귀 모델은 이것이 필수입니다.

둘째, 패딩 마스킹으로 무의미한 패딩 토큰을 무시합니다. 전통적인 RNN은 순차적으로 처리하므로 자연스럽게 미래를 볼 수 없었지만, Transformer는 모든 위치를 동시에 볼 수 있으므로 명시적으로 막아야 합니다.

마스킹의 핵심은 -∞ (실제로는 -1e9 같은 매우 큰 음수)를 사용하는 것입니다. Softmax 함수의 특성상 exp(-∞) ≈ 0이 되어, 마스킹된 위치의 attention weight가 사실상 0이 됩니다.

이렇게 하면 해당 정보가 Value의 가중합에 전혀 기여하지 않습니다. 실제 구현에서는 boolean 마스크를 만들고, 이를 사용하여 attention score에 조건부로 -1e9를 더합니다.

PyTorch의 masked_fill 같은 함수가 이를 효율적으로 처리합니다.

코드 예제

import numpy as np

def create_causal_mask(seq_len):
    """
    미래 토큰을 가리는 삼각 마스크 생성 (디코더용)
    상삼각 부분이 True (마스킹됨)
    """
    mask = np.triu(np.ones((seq_len, seq_len)), k=1).astype(bool)
    return mask

def create_padding_mask(seq, pad_token=0):
    """
    패딩 토큰을 가리는 마스크 생성
    Args:
        seq: 토큰 시퀀스 (seq_len,)
        pad_token: 패딩 토큰 ID
    """
    return (seq == pad_token).astype(bool)  # (seq_len,)

def masked_attention(Q, K, V, mask=None):
    """마스킹이 적용된 Scaled Dot-Product Attention"""
    d_k = Q.shape[-1]

    # Attention scores 계산
    scores = Q @ K.T / np.sqrt(d_k)  # (seq_len, seq_len)

    # 마스크 적용: True인 위치를 -inf로
    if mask is not None:
        scores = np.where(mask, -1e9, scores)

    # Softmax로 attention weights 계산
    attention_weights = np.exp(scores - np.max(scores, axis=-1, keepdims=True))
    attention_weights = attention_weights / np.sum(attention_weights, axis=-1, keepdims=True)

    # Value의 가중합
    output = attention_weights @ V
    return output, attention_weights

# 사용 예시
seq_len, d_k = 5, 64
Q = np.random.randn(seq_len, d_k)
K = np.random.randn(seq_len, d_k)
V = np.random.randn(seq_len, d_k)

# Causal masking (디코더)
causal_mask = create_causal_mask(seq_len)
print("Causal Mask:\n", causal_mask.astype(int))

output, weights = masked_attention(Q, K, V, mask=causal_mask)
print(f"\nAttention weights (causal):\n{weights}")
print("각 행에서 뒤쪽(미래)은 0에 가까움을 확인")

설명

이것이 하는 일: 모델이 특정 토큰들을 attention 계산에서 무시하도록 만들어, 학습과 추론의 정확성을 보장합니다. 첫 번째로 create_causal_mask 함수는 상삼각 행렬을 생성합니다.

numpy.triu는 대각선 위쪽(k=1이므로 대각선 제외)을 1로, 나머지를 0으로 만듭니다. 이는 i번째 토큰이 j번째 토큰을 볼 수 있는지(i >= j일 때만)를 나타냅니다.

예를 들어 5개 토큰이면 [[0,1,1,1,1], [0,0,1,1,1], ..., [0,0,0,0,0]] 형태입니다. 두 번째로 create_padding_mask 함수는 입력 시퀀스에서 패딩 토큰의 위치를 찾습니다.

예를 들어 [2, 5, 7, 0, 0] (0이 패딩)이면 [False, False, False, True, True]를 반환합니다. 배치 처리 시에는 (batch_size, seq_len) 형태가 되며, broadcasting으로 attention score에 적용됩니다.

세 번째로 masked_attention 함수에서 실제 마스킹이 일어납니다. np.where(mask, -1e9, scores)는 mask가 True인 위치를 -1e9로, False인 위치는 원래 score를 유지합니다.

-1e9는 실질적으로 -∞로 작동하여, Softmax 후에 거의 0이 됩니다. Softmax 계산 시 수치 안정성을 위해 최댓값을 빼줍니다.

마스킹된 위치가 -1e9이어도, 빼기 연산 후 여전히 매우 큰 음수이므로 exp() 후에 0에 가깝습니다. 정규화하면 마스킹되지 않은 위치들의 합이 1이 됩니다.

실무에서 causal mask와 padding mask를 동시에 사용할 때는 논리 OR로 결합합니다: combined_mask = causal_mask | padding_mask. 이렇게 하면 미래 토큰과 패딩 토큰 모두 가려집니다.

PyTorch에서는 torch.nn.functional.scaled_dot_product_attention 함수가 attn_mask와 is_causal 파라미터를 제공하여 마스킹을 자동 처리합니다. Flash Attention 같은 최적화된 구현도 마스킹을 효율적으로 지원합니다.

실전 팁

💡 마스크의 dtype을 bool로 유지하면 메모리를 절약할 수 있습니다. float mask보다 8배 작습니다. np.where나 torch.masked_fill이 bool을 잘 처리합니다.

💡 배치 처리 시 padding mask는 (batch, seq_len) 형태이고, attention score는 (batch, seq_len, seq_len)이므로, broadcasting을 위해 unsqueeze가 필요합니다: mask[:, None, :].

💡 인코더-디코더 구조에서 cross-attention은 다른 마스킹이 필요합니다. 디코더의 각 위치는 인코더의 모든 위치(패딩 제외)를 볼 수 있습니다.

💡 -1e9 대신 -1e4나 float('-inf')를 사용할 수도 있습니다. 너무 큰 값은 수치 불안정을 일으킬 수 있고, float('-inf')는 NaN을 유발할 수 있으니 주의하세요.

💡 디버깅 시 attention weights를 시각화하여 마스킹이 올바르게 적용되었는지 확인하세요. 마스킹된 위치가 흰색(0)으로 나타나야 합니다.


6. 완전한 Self-Attention 레이어 구현

시작하며

여러분이 지금까지 배운 모든 조각들을 떠올려보세요. Query, Key, Value, Scaled Dot-Product, Multi-Head, Positional Encoding, Masking...

각각은 강력하지만, 진짜 마법은 이들을 올바르게 조합할 때 일어납니다. 실제 Transformer에서 사용되는 Self-Attention 레이어는 단순히 attention만 계산하는 것이 아닙니다.

Residual connection, Layer Normalization, Dropout 등 여러 기법들이 함께 작동하여 깊은 네트워크의 학습을 가능하게 만듭니다. 바로 이제 우리가 만들 것이 프로덕션 수준의 완전한 Self-Attention 레이어입니다.

BERT, GPT, T5 같은 모든 주요 모델에서 사용되는 바로 그 구조입니다.

개요

간단히 말해서, 완전한 Self-Attention 레이어는 Multi-Head Attention에 Residual Connection과 Layer Normalization을 결합한 구조입니다. 이는 Transformer 블록의 첫 번째 서브레이어를 구성합니다.

실무에서 이 레이어는 Transformer의 핵심 빌딩 블록입니다. GPT-3는 96개의 이런 레이어를 쌓았고, BERT-Large는 24개를 사용합니다.

각 레이어가 점점 더 추상적인 표현을 학습하여, 깊은 언어 이해를 가능하게 합니다. 단순히 레이어를 쌓기만 하면 gradient vanishing 문제로 학습이 불가능합니다.

Residual connection(x + Attention(x))은 gradient가 skip connection을 통해 직접 흐를 수 있게 하여, 100개 이상의 레이어도 학습 가능하게 만듭니다. Layer Normalization은 각 레이어의 출력을 정규화하여 학습을 안정화시킵니다.

Batch Normalization과 달리 배치 크기에 독립적이고, 시퀀스의 각 위치를 독립적으로 정규화합니다. Pre-LN(Normalization을 먼저)과 Post-LN(Normalization을 나중에) 두 가지 배치가 있으며, 최근에는 Pre-LN이 선호됩니다.

Dropout은 attention weights와 출력에 적용되어 overfitting을 방지합니다. 학습 시 무작위로 일부 연결을 끊어, 모델이 특정 패턴에 과도하게 의존하지 않도록 합니다.

코드 예제

import numpy as np

class LayerNorm:
    def __init__(self, d_model, eps=1e-6):
        self.gamma = np.ones(d_model)  # 스케일 파라미터
        self.beta = np.zeros(d_model)  # 시프트 파라미터
        self.eps = eps

    def forward(self, x):
        mean = x.mean(axis=-1, keepdims=True)
        std = x.std(axis=-1, keepdims=True)
        return self.gamma * (x - mean) / (std + self.eps) + self.beta

class SelfAttentionLayer:
    def __init__(self, d_model, num_heads, dropout_rate=0.1):
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        self.dropout_rate = dropout_rate

        # Multi-Head Attention 파라미터
        self.Wq = np.random.randn(d_model, d_model) / np.sqrt(d_model)
        self.Wk = np.random.randn(d_model, d_model) / np.sqrt(d_model)
        self.Wv = np.random.randn(d_model, d_model) / np.sqrt(d_model)
        self.Wo = np.random.randn(d_model, d_model) / np.sqrt(d_model)

        # Layer Normalization
        self.layer_norm = LayerNorm(d_model)

    def forward(self, x, mask=None, training=True):
        # 1. 입력 저장 (residual connection용)
        residual = x

        # 2. Layer Normalization (Pre-LN)
        x = self.layer_norm.forward(x)

        # 3. Multi-Head Attention
        Q, K, V = x @ self.Wq, x @ self.Wk, x @ self.Wv

        # Head로 분할
        seq_len = x.shape[0]
        Q = Q.reshape(seq_len, self.num_heads, self.d_k).transpose(1, 0, 2)
        K = K.reshape(seq_len, self.num_heads, self.d_k).transpose(1, 0, 2)
        V = V.reshape(seq_len, self.num_heads, self.d_k).transpose(1, 0, 2)

        # Scaled Dot-Product Attention (각 head)
        outputs = []
        for i in range(self.num_heads):
            scores = Q[i] @ K[i].T / np.sqrt(self.d_k)
            if mask is not None:
                scores = np.where(mask, -1e9, scores)
            attention = np.exp(scores - np.max(scores, axis=-1, keepdims=True))
            attention = attention / attention.sum(axis=-1, keepdims=True)

            # Dropout (training 시에만)
            if training and self.dropout_rate > 0:
                dropout_mask = (np.random.rand(*attention.shape) > self.dropout_rate)
                attention = attention * dropout_mask / (1 - self.dropout_rate)

            outputs.append(attention @ V[i])

        # 4. Concatenate and project
        concat = np.concatenate(outputs, axis=-1)
        output = concat @ self.Wo

        # 5. Dropout
        if training and self.dropout_rate > 0:
            dropout_mask = (np.random.rand(*output.shape) > self.dropout_rate)
            output = output * dropout_mask / (1 - self.dropout_rate)

        # 6. Residual connection
        return residual + output

# 사용 예시
d_model, num_heads, seq_len = 512, 8, 10
layer = SelfAttentionLayer(d_model, num_heads)

x = np.random.randn(seq_len, d_model)
mask = np.triu(np.ones((seq_len, seq_len)), k=1).astype(bool)

output = layer.forward(x, mask=mask, training=True)
print(f"Output shape: {output.shape}")  # (10, 512)

설명

이것이 하는 일: Transformer 모델의 핵심 빌딩 블록으로, 문맥 정보를 통합하면서도 깊은 네트워크에서 안정적으로 학습할 수 있는 구조를 제공합니다. 첫 번째로 입력을 저장합니다(residual = x).

이는 나중에 Residual Connection을 위해 필요합니다. 원래 입력을 건너뛰어 더할 수 있게 하여, gradient가 직접 흐를 수 있는 경로를 만듭니다.

이것이 100개 이상의 레이어를 쌓을 수 있는 비결입니다. 두 번째로 Layer Normalization을 적용합니다.

Pre-LN 방식은 attention 이전에 정규화하여 학습을 더 안정적으로 만듭니다. 각 토큰의 d_model 차원에 대해 평균을 0, 분산을 1로 만들고, 학습 가능한 gamma와 beta로 스케일과 시프트를 조정합니다.

세 번째로 Multi-Head Attention을 수행합니다. 앞서 배운 모든 것이 여기 들어갑니다: Query/Key/Value 생성, head 분할, Scaled Dot-Product, 마스킹, Softmax.

각 head가 독립적으로 다른 패턴을 학습하며, training 모드에서는 attention weights에 dropout을 적용합니다. 네 번째로 모든 head의 출력을 concatenate하고 최종 선형 변환(Wo)을 적용합니다.

이는 head들의 정보를 효과적으로 통합하는 역할을 합니다. 이후 출력에도 dropout을 적용하여 regularization을 강화합니다.

마지막으로 Residual Connection을 수행합니다(residual + output). 이는 매우 중요한 단계로, 원래 입력을 보존하면서 attention의 정보를 더합니다.

만약 attention이 유용한 정보를 찾지 못했다면, 모델은 출력을 0에 가깝게 만들어 원래 입력을 그대로 통과시킬 수 있습니다. Dropout의 1/(1-dropout_rate) 스케일링은 추론 시와 학습 시의 기댓값을 같게 만듭니다.

학습 시 일부 뉴런을 끄므로, 남은 뉴런의 값을 키워서 보상합니다. PyTorch의 F.dropout이 이를 자동으로 처리합니다.

실무에서는 이 레이어 뒤에 Feed-Forward Network가 오고, 그 뒤에 또 다른 Layer Norm과 Residual Connection이 옵니다. 이 전체 구조가 하나의 Transformer Block을 구성하며, 이를 N번 반복하여 깊은 모델을 만듭니다.

실전 팁

💡 Pre-LN과 Post-LN의 차이를 이해하세요. Pre-LN은 학습이 더 안정적이고 warm-up이 덜 필요하지만, Post-LN은 때때로 더 나은 최종 성능을 보입니다.

💡 Residual connection이 작동하려면 입력과 출력의 차원이 같아야 합니다. 차원을 바꾸는 projection layer가 있다면 residual path에도 적용해야 합니다.

💡 Layer Normalization의 epsilon(1e-6)이 너무 작으면 수치 불안정이 생길 수 있습니다. 특히 FP16 학습 시 1e-5나 1e-4를 사용하세요.

💡 Gradient clipping을 함께 사용하면 학습이 더 안정적입니다. 특히 긴 시퀀스에서 gradient가 폭발하는 것을 방지합니다.

💡 추론 시에는 dropout을 끄는 것을 잊지 마세요(training=False). PyTorch는 model.eval()로, TensorFlow는 training=False 인자로 처리합니다.


7. Self-Attention vs Cross-Attention

시작하며

여러분이 번역을 할 때를 생각해보세요. "I love programming"을 한국어로 번역하려면, 한국어 문장의 각 단어가 영어 문장의 어느 부분과 대응되는지 알아야 합니다.

"프로그래밍을"은 "programming"에, "사랑한다"는 "love"에 대응되죠. Self-Attention은 같은 문장 내에서 단어들 간의 관계를 찾지만, Cross-Attention은 두 개의 서로 다른 시퀀스 간의 관계를 찾습니다.

이것이 기계 번역, 이미지 캡셔닝, 질의응답 같은 많은 작업에 핵심적입니다. Transformer의 인코더-디코더 구조에서, 인코더는 Self-Attention만 사용하지만, 디코더는 Self-Attention과 Cross-Attention을 모두 사용합니다.

이 차이를 이해하는 것이 Transformer 아키텍처 전체를 이해하는 열쇠입니다.

개요

간단히 말해서, Self-Attention은 Query, Key, Value가 모두 같은 입력에서 나오지만, Cross-Attention은 Query는 한 시퀀스에서, Key와 Value는 다른 시퀀스에서 가져옵니다. 실무에서 이 구분은 매우 중요합니다.

BERT 같은 인코더 전용 모델은 Self-Attention만 사용하고, GPT 같은 디코더 전용 모델도 Self-Attention만 사용합니다. 하지만 T5나 BART 같은 인코더-디코더 모델은 둘 다 사용합니다.

예를 들어 기계 번역에서, 디코더의 Self-Attention은 "나는 프로그래밍을" 같은 지금까지 생성된 한국어 단어들 간의 관계를 학습합니다. 반면 Cross-Attention은 "나는"이 소스 문장의 "I"와 관련 있고, "프로그래밍을"이 "programming"과 관련 있음을 학습합니다.

핵심 차이는 정보의 흐름입니다. Self-Attention은 정보가 같은 시퀀스 내에서 순환하지만, Cross-Attention은 한 시퀀스(인코더 출력)에서 다른 시퀀스(디코더)로 정보가 흐릅니다.

이를 "attending to the encoder"라고 부릅니다. 구현상의 차이는 매우 작습니다.

Self-Attention에서 Q = K = V = X였다면, Cross-Attention에서는 Q = decoder_hidden, K = V = encoder_output입니다. 나머지 계산은 완전히 동일합니다.

코드 예제

import numpy as np

def self_attention(X, Wq, Wk, Wv, mask=None):
    """
    Self-Attention: Q, K, V 모두 같은 입력 X에서 생성
    """
    Q = X @ Wq
    K = X @ Wk
    V = X @ Wv

    d_k = Q.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)

    if mask is not None:
        scores = np.where(mask, -1e9, scores)

    attention = np.exp(scores - np.max(scores, axis=-1, keepdims=True))
    attention = attention / attention.sum(axis=-1, keepdims=True)

    return attention @ V

def cross_attention(decoder_hidden, encoder_output, Wq, Wk, Wv, mask=None):
    """
    Cross-Attention: Q는 디코더에서, K와 V는 인코더에서 생성
    Args:
        decoder_hidden: 디코더의 현재 hidden state (dec_len, d_model)
        encoder_output: 인코더의 출력 (enc_len, d_model)
    """
    Q = decoder_hidden @ Wq  # 디코더에서 Query
    K = encoder_output @ Wk  # 인코더에서 Key
    V = encoder_output @ Wv  # 인코더에서 Value

    d_k = Q.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)  # (dec_len, enc_len)

    # 패딩 마스크 (인코더의 패딩 토큰 가리기)
    if mask is not None:
        scores = np.where(mask, -1e9, scores)

    attention = np.exp(scores - np.max(scores, axis=-1, keepdims=True))
    attention = attention / attention.sum(axis=-1, keepdims=True)

    return attention @ V, attention  # attention weights도 반환 (시각화용)

# 사용 예시
d_model = 512
enc_len, dec_len = 7, 5  # 인코더와 디코더 시퀀스 길이 다름

# 파라미터 초기화
Wq = np.random.randn(d_model, d_model) / np.sqrt(d_model)
Wk = np.random.randn(d_model, d_model) / np.sqrt(d_model)
Wv = np.random.randn(d_model, d_model) / np.sqrt(d_model)

# Self-Attention (인코더)
encoder_input = np.random.randn(enc_len, d_model)
encoder_self_attn = self_attention(encoder_input, Wq, Wk, Wv)
print(f"Encoder Self-Attention output: {encoder_self_attn.shape}")  # (7, 512)

# Cross-Attention (디코더 -> 인코더)
decoder_hidden = np.random.randn(dec_len, d_model)
cross_attn_output, cross_attn_weights = cross_attention(
    decoder_hidden, encoder_self_attn, Wq, Wk, Wv
)
print(f"Cross-Attention output: {cross_attn_output.shape}")  # (5, 512)
print(f"Cross-Attention weights: {cross_attn_weights.shape}")  # (5, 7)
print("각 디코더 위치가 모든 인코더 위치를 볼 수 있음")

설명

이것이 하는 일: Self-Attention이 같은 문장 내 단어 관계를 학습한다면, Cross-Attention은 두 다른 문장(예: 소스와 타겟 언어) 간의 대응 관계를 학습합니다. Self-Attention의 핵심은 "자기 자신을 본다"는 것입니다.

인코더에서 "The cat sat on the mat"을 처리할 때, "cat"은 같은 문장의 "sat", "the", "mat" 등과의 관계를 학습합니다. Query, Key, Value 모두 같은 문장에서 생성되므로, attention score 행렬은 정방 행렬(seq_len × seq_len)입니다.

Cross-Attention의 핵심은 "다른 것을 본다"는 것입니다. 디코더에서 "고양이가"를 생성할 때, Query는 디코더의 현재 상태에서 나오지만, Key와 Value는 인코더의 출력(영어 문장의 표현)에서 나옵니다.

이를 통해 "고양이가"가 "cat"에 대응됨을 학습합니다. Attention weights의 형태가 다릅니다.

Self-Attention에서는 (seq_len, seq_len)의 정방 행렬이지만, Cross-Attention에서는 (dec_len, enc_len)으로 직사각형 행렬입니다. 각 디코더 위치가 모든 인코더 위치를 볼 수 있으므로, 소스 문장 전체의 맥락을 활용할 수 있습니다.

마스킹도 다르게 적용됩니다. Self-Attention에서 디코더는 causal mask를 사용하여 미래를 가리지만, Cross-Attention에서는 인코더의 패딩 토큰만 가립니다.

인코더의 모든 실제 토큰은 볼 수 있습니다(미래가 아니므로). Transformer 디코더 블록의 구조는 다음과 같습니다: 1) Masked Self-Attention (지금까지 생성한 것들 간의 관계), 2) Cross-Attention (인코더 출력과의 관계), 3) Feed-Forward Network.

각 서브레이어마다 Residual Connection과 Layer Normalization이 있습니다. 실무에서 Cross-Attention weights를 시각화하면 alignment(정렬)를 볼 수 있습니다.

번역 작업에서 "나는"이 "I"에 높은 attention을, "프로그래밍을"이 "programming"에 높은 attention을 보이는 것을 확인할 수 있습니다. 이는 과거의 alignment 모델과 유사한 역할을 합니다.

실전 팁

💡 인코더-디코더 모델에서 인코더 출력은 모든 디코더 레이어에서 재사용됩니다. 한 번 계산하여 캐싱하면 효율적입니다.

💡 Cross-Attention에서 Key와 Value는 항상 같은 소스(인코더)에서 와야 합니다. Query만 다른 소스(디코더)에서 옵니다.

💡 Beam search 같은 디코딩 전략을 사용할 때, 각 beam마다 인코더 출력을 복제해야 합니다. 메모리 사용에 주의하세요.

💡 최근의 많은 모델(GPT, BERT)은 디코더 전용 또는 인코더 전용이므로 Cross-Attention이 없습니다. 하지만 multimodal 모델(CLIP, Flamingo)은 이미지와 텍스트 간 Cross-Attention을 사용합니다.

💡 Cross-Attention weights를 분석하면 모델이 어떤 입력 부분에 집중하는지 알 수 있어, 디버깅과 모델 해석에 매우 유용합니다.


8. Attention 시각화와 해석

시작하며

여러분이 블랙박스 모델을 사용할 때 가장 답답한 것은 "왜 이런 결과가 나왔지?"일 겁니다. 딥러닝 모델은 종종 해석하기 어렵다는 비판을 받지만, Attention은 예외입니다.

이것은 우리가 들여다볼 수 있는 창입니다. Attention weights는 모델이 각 시점에 어디에 "주목"하는지 보여줍니다.

"The cat sat on the mat"에서 "sat"을 처리할 때 "cat"에 높은 attention을 보인다면, 모델이 주어-동사 관계를 학습했다는 증거입니다. 바로 이것이 Attention 시각화의 힘입니다.

모델의 내부 작동을 이해하고, 버그를 찾고, 모델이 무엇을 학습했는지 검증할 수 있습니다. 실무에서 이는 단순한 호기심이 아니라 필수적인 디버깅 도구입니다.

개요

간단히 말해서, Attention 시각화는 attention weights 행렬을 히트맵이나 그래프로 표현하여, 모델이 어떤 토큰 간의 관계를 중요하게 여기는지 시각적으로 보여주는 기법입니다. 실무에서 이것은 여러 목적으로 사용됩니다.

첫째, 모델 디버깅입니다. 모델이 잘못된 예측을 할 때, attention을 보면 어디에 잘못 집중했는지 알 수 있습니다.

둘째, 모델 검증입니다. 문법 구조나 의미 관계를 올바르게 포착했는지 확인합니다.

셋째, 연구와 논문 작성입니다. Attention 패턴을 보여주면 모델의 작동 원리를 효과적으로 설명할 수 있습니다.

전통적인 신경망은 완전히 블랙박스였지만, Attention은 중간 계산 과정이 해석 가능합니다. 0과 1 사이의 확률 분포이므로 직관적으로 이해할 수 있고, 각 연결의 강도를 정량화할 수 있습니다.

여러 연구에서 Attention이 언어학적으로 의미 있는 패턴을 학습함을 보였습니다. 어떤 head는 의존 구문 구조를 따르고, 어떤 head는 공지시어를 해결하고, 어떤 head는 명명된 개체 간 관계를 포착합니다.

하지만 주의할 점도 있습니다. Attention weights가 높다고 해서 그 토큰이 반드시 예측에 중요한 것은 아닙니다.

최근 연구들은 attention이 완전한 설명이 아닐 수 있음을 지적합니다. 그럼에도 여전히 가장 유용한 해석 도구 중 하나입니다.

코드 예제

import numpy as np
import matplotlib.pyplot as plt

def visualize_attention(attention_weights, source_tokens, target_tokens=None, head_idx=0):
    """
    Attention weights를 히트맵으로 시각화
    Args:
        attention_weights: (num_heads, seq_len, seq_len) 또는 (seq_len, seq_len)
        source_tokens: 소스 토큰 리스트 (x축)
        target_tokens: 타겟 토큰 리스트 (y축, None이면 source와 동일)
        head_idx: 시각화할 head 인덱스 (multi-head인 경우)
    """
    # Multi-head인 경우 특정 head 선택
    if len(attention_weights.shape) == 3:
        attn = attention_weights[head_idx]
    else:
        attn = attention_weights

    if target_tokens is None:
        target_tokens = source_tokens

    # 히트맵 생성
    fig, ax = plt.subplots(figsize=(10, 8))
    im = ax.imshow(attn, cmap='viridis', aspect='auto')

    # 축 레이블 설정
    ax.set_xticks(np.arange(len(source_tokens)))
    ax.set_yticks(np.arange(len(target_tokens)))
    ax.set_xticklabels(source_tokens, rotation=45, ha='right')
    ax.set_yticklabels(target_tokens)

    # 각 셀에 값 표시 (선택적)
    for i in range(len(target_tokens)):
        for j in range(len(source_tokens)):
            text = ax.text(j, i, f'{attn[i, j]:.2f}',
                          ha="center", va="center", color="white", fontsize=8)

    ax.set_xlabel('Source Tokens (Keys)')
    ax.set_ylabel('Target Tokens (Queries)')
    ax.set_title(f'Attention Weights - Head {head_idx}')

    # 컬러바 추가
    plt.colorbar(im, ax=ax)
    plt.tight_layout()
    return fig

# 사용 예시
tokens = ["The", "cat", "sat", "on", "the", "mat"]
seq_len = len(tokens)

# 가상의 attention weights 생성 (실제로는 모델에서 가져옴)
# "sat"이 "cat"에 높은 attention을 보이도록 설정
attention = np.random.rand(seq_len, seq_len)
attention[2, 1] = 0.8  # "sat" -> "cat"
attention[2, 3] = 0.7  # "sat" -> "on"

# 각 행을 Softmax로 정규화
attention = np.exp(attention) / np.exp(attention).sum(axis=1, keepdims=True)

# 시각화
fig = visualize_attention(attention, tokens)
# plt.savefig('attention_heatmap.png', dpi=300, bbox_inches='tight')
print("Attention visualization created!")

# Multi-head attention 패턴 분석
def analyze_attention_patterns(attention_weights, tokens):
    """여러 head의 attention 패턴 통계 분석"""
    num_heads = attention_weights.shape[0]

    for head in range(num_heads):
        attn = attention_weights[head]

        # 각 토큰이 가장 많이 주목받는 위치
        max_attended = np.argmax(attn, axis=1)

        print(f"\nHead {head}:")
        for i, token in enumerate(tokens):
            most_attended_token = tokens[max_attended[i]]
            attention_score = attn[i, max_attended[i]]
            print(f"  '{token}' -> '{most_attended_token}' (score: {attention_score:.3f})")

설명

이것이 하는 일: 추상적인 숫자 행렬을 직관적인 시각적 표현으로 변환하여, 모델이 학습한 언어적 패턴을 인간이 이해할 수 있게 만듭니다. 첫 번째로 visualize_attention 함수는 attention weights 행렬을 히트맵으로 변환합니다.

imshow를 사용하여 각 (i, j) 위치의 값을 색상 강도로 나타냅니다. 밝은 색일수록 높은 attention을 의미하며, i번째 쿼리가 j번째 키에 얼마나 주목하는지 보여줍니다.

두 번째로 축에 실제 토큰을 레이블로 표시합니다. "The", "cat", "sat" 같은 단어가 축에 나타나, 숫자 인덱스 대신 의미 있는 텍스트로 패턴을 읽을 수 있습니다.

이렇게 하면 "sat이 cat에 집중한다" 같은 해석이 즉시 가능합니다. 세 번째로 선택적으로 각 셀에 정확한 수치를 표시합니다.

0.82 같은 값이 셀 안에 쓰여, 시각적 인상과 정량적 정보를 동시에 제공합니다. 다만 시퀀스가 길 때는 텍스트가 너무 작아지므로 생략하는 것이 좋습니다.

analyze_attention_patterns 함수는 여러 head를 자동으로 분석합니다. 각 head에서 각 토큰이 가장 많이 주목하는 대상을 찾아 출력합니다.

예를 들어 "Head 3: 'programming' -> 'love' (0.721)"처럼 표시되어, head마다 다른 패턴을 학습했음을 확인할 수 있습니다. 실무에서는 BertViz, exBERT 같은 전문 도구를 사용합니다.

이들은 인터랙티브한 시각화를 제공하여, 마우스를 올리면 해당 attention 패턴이 강조되고, 여러 레이어와 head를 동시에 비교할 수 있습니다. Cross-Attention을 시각화할 때는 특히 유용합니다.

기계 번역에서 소스와 타겟 문장의 alignment를 보여주어, "나는"이 "I"와, "프로그래밍을"이 "programming"과 대응됨을 시각적으로 확인할 수 있습니다. 과거의 통계 기반 번역 모델의 alignment와 유사한 정보를 제공합니다.

실전 팁

💡 모든 head를 한 번에 시각화하려면 subplot을 사용하세요: fig, axes = plt.subplots(4, 3, figsize=(15, 20))로 12개 head를 4×3 그리드로 배치합니다.

💡 특정 단어의 attention 패턴만 보려면 해당 행만 추출하여 bar chart로 그리세요. "sat"이 어디를 보는지 막대 그래프로 표현하면 더 명확합니다.

💡 레이어별 attention 변화를 보려면 여러 레이어의 같은 head를 나란히 배치하세요. 낮은 레이어는 구문 정보를, 높은 레이어는 의미 정보를 포착하는 경향이 있습니다.

💡 Attention entropy를 계산하면 집중도를 정량화할 수 있습니다: -sum(p * log(p)). 낮은 엔트로피는 특정 토큰에 집중, 높은 엔트로피는 고르게 분산됨을 의미합니다.

💡 HuggingFace Transformers 라이브러리는 output_attentions=True 옵션으로 모든 레이어의 attention weights를 반환합니다. 이를 활용하여 쉽게 시각화할 수 있습니다.


#Python#Self-Attention#Transformer#Query-Key-Value#Multi-Head-Attention#AI

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.