ansir 님의 블로그

[ SK 네트웍스 Family AI 캠프 수업 내용 복습 ] 자연어 딥러닝 응용(언어 모델링) 2025-04-28 본문

SK 네트웍스 family AI 캠프/수업 내용 복습

[ SK 네트웍스 Family AI 캠프 수업 내용 복습 ] 자연어 딥러닝 응용(언어 모델링) 2025-04-28

ansir 2025. 4. 28. 17:06

Transformer

RNN/LSTM의 한계
  순차처리: 단어를 하나씩 읽어서 문맥을 이해, 초기 정보 잊음
  병렬화 불가: GPU를 활용한 빠른 학습이 불가
Transformer의 장점
  병렬 처리
  self-attention: 단어간의 관계를 직접 계산 긴 문맥도 잘 파악
Transformer의 전체 구조
  Encoder: 입력 문장을 벡터로 변환
    "영화가 재미있다" -> 의미 벡터
  Decoder: 출력 문장 생성, 번역.
    구성: 인코더와 유사 + masked self-attention + encoder-decoder attention
  self-attention: 단어간 중요도를 계산
    "영화가 재미있다" 강하게 연결됨
Positional Encoding: 단어의 위치를 나타내는 벡터를 추가
  방식: 사인 / 코사인 함수로 고정된 위치 벡터를 생성

RNN/LSTM의 한계

순차처리

  • RNN이나 LSTM은 입력 문장을 단어 하나씩 차례차례 읽어야 문맥을 이해할 수 있습니다.
  • 이 방식은 초기에 들어온 정보(예: 문장의 앞부분)를 기억하기 어렵고 뒤로 갈수록 잊어버리게 되는 문제가 있습니다.
    (→ 장기 의존성 문제)

병렬화 불가

  • RNN/LSTM은 순차적으로 단어를 처리하기 때문에,
    GPU를 이용한 병렬 처리를 하기가 어렵습니다. → 학습 속도가 느림.

Transformer의 장점

병렬 처리

Transformer는 문장의 모든 단어를 한 번에 처리할 수 있습니다. 이 덕분에 GPU를 활용한 빠른 학습이 가능합니다.

Self-Attention

문장 안의 단어들 사이의 관계(문맥)를 직접 계산합니다.

특히, 긴 문장도 앞뒤 문맥을 잘 파악할 수 있습니다.

  • "영화가 재미있다"에서는 "영화"와 "재미있다"가 강하게 연결되는 걸 계산할 수 있음

Transformer의 전체 구조

Encoder

입력 문장을 벡터(숫자)로 변환하는 역할을 합니다.

예:

"영화가 재미있다" → 하나의 의미 있는 벡터로 변환.

Decoder

변환된 벡터를 가지고 출력 문장(예: 번역 결과)을 생성합니다.

구성:

인코더와 비슷하지만,

추가로 2가지가 들어감:
Masked Self-Attention

아직 생성되지 않은 단어를 보지 않게 가림.

(예: 번역할 때 아직 출력하지 않은 단어를 미리 알 수 없도록)

Encoder-Decoder Attention

인코더가 만든 입력 문장의 정보를 참고해서, 디코더가 더 똑똑하게 문장을 생성할 수 있게 함.

핵심 개념: Self-Attention

문장 안에서 각 단어가 다른 단어들과 얼마나 중요한 관계가 있는지를 계산하는 과정.

  • 예를 들면,
    "영화가 재미있다"라는 문장에서 "영화"는 "재미있다"와 관계가 깊음
    이런 관계를 숫자(가중치)로 계산해서, 각 단어가 다른 단어를 얼마나 참고할지 정한다.

Positional Encoding

Transformer는 모든 단어를 동시에 처리하니까,
단어의 순서를 따로 알려줘야 합니다.

그래서 단어에 위치 정보를 가진 벡터를 추가로 더해줍니다.

방식

사인(sin)과 코사인(cos) 함수를 이용해서, 위치별로 고정된 숫자 패턴을 만듭니다.

이걸 단어 벡터에 더하면, Transformer는 단어의 순서를 알 수 있습니다.

추가 설명

왜 Masked Self-Attention이 필요할까?

→ 디코더는 미래의 단어를 미리 보면 안 됩니다.
번역할 때 아직 출력하지 않은 단어를 참고하면 정답을 미리 아는 것이기 때문에
'미래 단어'에 대한 정보를 가려서 모델이 정직하게 학습하게 합니다.

Self-Attention 계산은 어떻게 할까?

→ "Query(질문)", "Key(열쇠)", "Value(값)"라는 세 가지 벡터를 만들고,
Query와 Key를 비교해서 얼마나 중요한지 가중치를 계산하고,
그 가중치로 Value들을 조합하는 방식입니다.

Encoder와 Decoder는 층(layer)을 여러 개 쌓아 사용합니다.
단일층이 아니라, Self-Attention과 Feed Forward Layer를 여러 번 반복해서 깊고 복잡한 처리를 합니다.


self attention layer 구현

# 필요한 라이브러리 임포트
import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt
from typing import Tuple

# Self-Attention 레이어 구현
class SelfAttention(nn.Module):
    def __init__(self, embed_dim: int, num_heads: int = 1):
        super().__init__()
        self.embed_dim = embed_dim  # 임베딩 차원
        self.num_heads = num_heads  # 헤드 개수
        self.head_dim = embed_dim // num_heads  # 각 헤드가 담당할 feature 차원

        # Query, Key, Value를 만드는 선형 변환 레이어
        self.keys = nn.Linear(embed_dim, embed_dim)
        self.queries = nn.Linear(embed_dim, embed_dim)
        self.values = nn.Linear(embed_dim, embed_dim)

        # Softmax 전에 값이 너무 커지는 걸 방지하기 위한 스케일링 값
        self.scale = torch.sqrt(torch.FloatTensor([self.head_dim]))

    def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
        """
        x: 입력 텐서 (batch_size, seq_len, embed_dim)
        return: (출력 텐서, attention 가중치)
        """
        batch_size, seq_len, embed_dim = x.size()  # 입력 차원 추출

        # Query, Key, Value 생성
        Q = self.queries(x)  # (batch_size, seq_len, embed_dim)
        K = self.keys(x)
        V = self.values(x)

        # 멀티헤드 어텐션을 위해 형태 변경
        # (batch_size, num_heads, seq_len, head_dim)로 변환 후, 헤드 축을 앞으로
        Q = Q.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
        K = K.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
        V = V.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)

        # Attention Score 계산 (Q * K^T / sqrt(head_dim))
        scores = torch.matmul(Q, K.transpose(-2, -1)) / self.scale.to(x.device)  # 스케일을 디바이스 맞추기

        # 소프트맥스로 가중치 만들기 (가장 중요한 토큰에 높은 점수)
        attn_weight = torch.softmax(scores, dim=-1)

        # Attention 가중치를 Value에 적용
        out = torch.matmul(attn_weight, V)  # (batch_size, num_heads, seq_len, head_dim)

        # 다시 원래 차원으로 복원
        out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, embed_dim)

        return out, attn_weight

간단한 흐름 요약

  1. 입력 x를 받아서 → Query, Key, Value 벡터로 변환
  2. Query와 Key로 어텐션 스코어를 계산
  3. 소프트맥스해서 가중치를 구함
  4. 가중치를 Value에 곱해서 출력을 얻음
  5. 헤드들을 다시 합쳐서 최종 출력

SelfAttention 레이어 테스트

# 샘플 데이터 생성
batch_size = 1    # 배치 크기: 1개 문장
seq_len = 5       # 문장 길이: 5개의 토큰
embed_dim = 64    # 임베딩 차원: 64
num_head = 4      # 어텐션 헤드 수: 4

# 임의의 입력 데이터 (토큰화된 데이터처럼 가정)
x = torch.randn(batch_size, seq_len, embed_dim)  # (1, 5, 64) 형태 랜덤 텐서
print(f'입력 데이터 : {x.shape}')

# Self-Attention 모델 생성
device = 'cuda' if torch.cuda.is_available() else 'cpu'  # GPU 사용 가능 여부 확인
model = SelfAttention(embed_dim, num_head).to(device)    # 모델을 디바이스에 올림
x = x.to(device)  # 입력 데이터도 디바이스로 이동

# Self-Attention 적용
out, attn_weight = model(x)

# 결과 출력
print(f'출력 데이터 : {out.shape}')       # 기대 결과: (batch_size, seq_len, embed_dim)
print(f'어텐션 가중치 : {attn_weight.shape}')  # 기대 결과: (batch_size, num_heads, seq_len, seq_len)

중요 포인트

입력 x는 (batch_size, seq_len, embed_dim) 형태.
→ 즉, 한 문장에서 5개의 토큰이 있고, 각 토큰은 64차원 벡터로 표현됨.

모델의 출력 out은
(batch_size, seq_len, embed_dim)
→ 입력과 같은 크기! 즉, Self-Attention은 차원을 유지하는 구조

어텐션 가중치 attn_weight는
(batch_size, num_heads, seq_len, seq_len)

query 토큰 5개,

key 토큰 5개,

head는 4개.

→ 한 헤드 안에서 모든 토큰 쌍에 대해 "얼마나 주목할지"를 계산한다.

출력 예시 느낌

프린트한다면 이런 식으로 나오게 됩니다.

입력 데이터 : torch.Size([1, 5, 64])
출력 데이터 : torch.Size([1, 5, 64])
어텐션 가중치 : torch.Size([1, 4, 5, 5])

추가 Tip

attn_weight를 시각화할 때는,
matplotlib로 heatmap처럼 그려볼 수도 있습니다.

import seaborn as sns

# 첫 번째 헤드의 어텐션 가중치만 시각화
sns.heatmap(attn_weight[0, 0].detach().cpu().numpy(), annot=True)
plt.xlabel('Key')
plt.ylabel('Query')
plt.title('Attention Weight Heatmap (Head 1)')
plt.show()

요약

항목 의미
x 임의 입력 텐서 (batch_size, seq_len, embed_dim)
model(x) Self-Attention 연산 수행
out 어텐션 결과 (입력과 같은 모양)
attn_weight 어텐션 스코어 (헤드 수 만큼)

왜 attn_weight가 (5×5)인가?

위 코드에서 seq_len = 5로 되어 있었습니다.
즉, 문장에는 토큰이 5개가 있는 것입니다. (ex: 영화 / 가 / 정말 / 재미있 / 다)

Self-Attention에서는 각 토큰이 다른 모든 토큰을 바라봅니다( 주목합니다 ).

그림 느낌:

  토큰1 토큰2 토큰3 토큰4 토큰5
토큰1이 누구를 주목하는지
토큰2가 누구를 주목하는지
토큰3이 누구를 주목하는지
토큰4가 누구를 주목하는지
토큰5가 누구를 주목하는지
  • 가로는 key 토큰들
  • 세로는 query 토큰들

각 칸은 "query가 key를 얼마나 주목하는지"를 나타내는 숫자입니다.

따라서 5×5 매트릭스가 나온 것입니다. (query 5개 × key 5개)

Self-Attention 계산 과정 (수식)

Self-Attention은 한 문장(5개 토큰)이 들어왔을 때, 이렇게 계산합니다.

1.  **Query(Q), Key(K), Value(V) 만들기**
2.  입력 `X`를 세 개의 행렬로 변환:

$$ Q=XW^Q,K=XW^K,V=XW^V $$

- $ W^Q, W^K, W^V $ 는 학습 가능한 가중치 행렬.
  1. 어텐션 점수(Attention Score) 계산 각 Query와 모든 Key를 곱해서 "얼마나 주목할지"를 계산:
    • $d_k$ 는 key 벡터의 차원수 (head_dim).
    • ${\sqrt{d_k}}$로 나누는 이유: 점수 값이 너무 커지지 않게 해서 softmax 안정화.
  2. $$ {score}(Q, K) = \frac{QK^T}{\sqrt{d_k}} $$
  1. 어텐션 가중치(Attention Weight) 만들기
    점수에 softmax를 적용:→ 이렇게 하면 가장 중요한 토큰에 큰 확률을 할당.
  2. $$ attention_weight=softmax(score(Q,K)) $$
  3. 출력 계산
    어텐션 가중치와 Value를 곱해서 최종 출력을 만듦:
  4. $$
    {Attention}(Q, K, V) = \text{softmax}\left( \frac{QK^T}{\sqrt{d_k}} \right) V
    $$

요약

  • "각 토큰이 다른 토큰들과 얼마나 관련 있는지를 계산해서, 그걸 반영해서 새 벡터를 만든다"
  • 5개 토큰이면 → 5×5 관계 매트릭스(attention weight)가 나오고
    각각의 토큰은 자기 주변과 과거/미래 토큰을 다 볼 수 있음

부가 그림 (텍스트 버전)

입력 (X): [토큰1, 토큰2, 토큰3, 토큰4, 토큰5]
    ↓ (Wq, Wk, Wv)
Query(Q), Key(K), Value(V) 생성
    ↓
Q와 K로 Attention Score 계산 (5x5 매트릭스)
    ↓
Softmax → Attention Weight (5x5 매트릭스)
    ↓
Attention Weight × V → 최종 Output

어텐션 가중치 시각화

#어텐션 가중치 시각화

attn_weight[0, 0, 2, 4] # 0.2061  0번 헤드에서 2번째 토큰( query ) 4번째 토큰( key )를 0.206

import seaborn as sns

# 첫 번째 헤드의 어텐션 가중치만 가져오기
attn_weights = attn_weight[0, 0].detach().cpu().numpy()  # .detach()로 그래디언트 추적을 끄고, .cpu()로 CPU로 이동

# 히트맵으로 시각화
sns.heatmap(attn_weights, annot=True)  # annot=True로 각 칸에 값 표시
plt.xlabel('Key')  # x축: Key 토큰
plt.ylabel('Query')  # y축: Query 토큰
plt.show()

attn_weight[0, 0, 2, 4]이 무엇인가

이 코드는 어텐션 가중치에서 첫 번째 배치(0번째), 첫 번째 헤드(0번째)에서,
2번째 쿼리 토큰(Query 2번)이 4번째 키 토큰(Key 4번)과 주목하는 관계의 가중치가 0.2061임을 의미합니다.

간단히 말해서, "쿼리 토큰 2"가 "키 토큰 4"를 얼마나 중요하게 생각하는지를 보여주는 값입니다.

어텐션 가중치 시각화

코드의 목적은 첫 번째 헤드의 어텐션 가중치를 시각화하는 것입니다.
이를 통해 각 토큰 간의 관계를 직관적으로 볼 수 있습니다.

import seaborn as sns

# 첫 번째 헤드의 어텐션 가중치만 가져오기
attn_weights = attn_weight[0, 0].detach().cpu().numpy()  # .detach()로 그래디언트 추적을 끄고, .cpu()로 CPU로 이동

# 히트맵으로 시각화
sns.heatmap(attn_weights, annot=True)  # annot=True로 각 칸에 값 표시
plt.xlabel('Key')  # x축: Key 토큰
plt.ylabel('Query')  # y축: Query 토큰
plt.show()

주요 설명:

  • attn_weight[0, 0]은 첫 번째 배치와 첫 번째 헤드의 어텐션 가중치를 추출하는 부분입니다.
  • detach()는 그래디언트 계산을 막아서, 계산 그래프와는 분리된 텐서를 반환합니다.
  • cpu()는 GPU에서 CPU로 데이터를 옮기는 작업 ( 필요 없으면 생략할 수 있습니다 ).
  • sns.heatmap은 어텐션 가중치 값을 히트맵으로 시각화하는 부분입니다. 각 값이 어떤 관계인지 색상으로 볼 수 있습니다.

시각화 결과

  • 쿼리 토큰(세로)은 각 토큰(단어)이 다른 토큰을 얼마나 주목하는지 보여주고,
  • 키 토큰(가로)은 그에 대한 주목도를 나타내는 가중치를 시각적으로 표현.

예시:

쿼리 2번과 키 4번이 높은 값(예: 0.2061)을 가지면, 2번 토큰이 4번 토큰을 많이 주목한다고 볼 수 있습니다.

쿼리 토큰 → 키 토큰 관계를 색으로 나타내는데,
색이 짙을수록 (값이 높을수록) 주목도가 높다는 뜻입니다.
반응형