이미지 로딩 중...

LLM 구현 8편 GPT 모델 구현 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 8. · 4 Views

LLM 구현 8편 GPT 모델 구현 완벽 가이드

GPT(Generative Pre-trained Transformer) 모델을 밑바닥부터 직접 구현해보는 실전 가이드입니다. Transformer 아키텍처 기반의 언어 모델을 PyTorch로 구현하면서 Self-Attention, Multi-Head Attention, Position Encoding 등 핵심 메커니즘을 완벽하게 이해할 수 있습니다.


목차

  1. GPT 모델 아키텍처 설정 - 모델의 뼈대 구성하기
  2. Multi-Head Self-Attention - GPT의 핵심 메커니즘
  3. Position-wise Feed-Forward Network - 비선형 변환의 힘
  4. Transformer Block - Attention과 FFN의 결합
  5. Positional Encoding - 위치 정보 주입하기
  6. Causal Self-Attention Masking - 미래를 보지 못하게 하기
  7. 전체 GPT 모델 조립 - 모든 컴포넌트 통합하기
  8. 텍스트 생성 전략 - 다양한 디코딩 방법
  9. 학습 루프 구현 - 효율적인 모델 훈련
  10. GPT-2 Pre-trained 가중치 로딩 - 전이 학습 활용하기

1. GPT 모델 아키텍처 설정 - 모델의 뼈대 구성하기

시작하며

여러분이 대규모 언어 모델을 처음 구현하려고 할 때, 어디서부터 시작해야 할지 막막하신가요? 수많은 파라미터와 복잡한 구조 앞에서 어떻게 접근해야 할지 고민이 되실 겁니다.

GPT 모델 구현의 첫 단계는 바로 모델의 전체적인 설계도를 그리는 것입니다. 모델의 크기, 레이어 개수, 어텐션 헤드 수 등 핵심 하이퍼파라미터를 정의하지 않으면 일관성 없는 코드가 만들어지고, 나중에 수정하기 어려워집니다.

바로 이럴 때 필요한 것이 GPTConfig 클래스입니다. 모든 설정을 한 곳에서 관리하면 실험을 할 때도 편리하고, 다른 크기의 모델로 쉽게 전환할 수 있습니다.

개요

간단히 말해서, GPTConfig는 GPT 모델의 모든 하이퍼파라미터를 담는 설정 컨테이너입니다. 마치 건물의 설계도처럼, 모델이 어떤 구조를 가질지 미리 정의합니다.

왜 이런 설정 클래스가 필요할까요? 실무에서는 작은 모델로 프로토타입을 만들고, 점차 크기를 키워가며 실험합니다.

예를 들어, 로컬에서는 6개 레이어로 테스트하다가 GPU 서버에서는 12개 레이어로 확장하는 경우가 많습니다. 설정 클래스가 없다면 코드 곳곳의 하드코딩된 값들을 일일이 찾아 수정해야 하는 악몽을 경험하게 됩니다.

기존에는 파라미터를 함수 인자로 넘기거나 전역 변수로 관리했다면, 이제는 하나의 객체로 깔끔하게 관리할 수 있습니다. 설정을 바꾸고 싶다면 Config 객체만 수정하면 모든 곳에 자동으로 반영됩니다.

이 클래스의 핵심 특징은 vocab_size(단어장 크기), n_layer(레이어 개수), n_head(어텐션 헤드 수), n_embd(임베딩 차원) 등을 체계적으로 관리한다는 것입니다. 이러한 특징들은 모델의 표현력과 학습 능력을 직접적으로 결정하기 때문에 매우 중요합니다.

코드 예제

import torch
import torch.nn as nn
from dataclasses import dataclass

@dataclass
class GPTConfig:
    vocab_size: int = 50257  # GPT-2 토크나이저 크기
    n_layer: int = 12  # Transformer 레이어 개수
    n_head: int = 12  # Multi-head attention 헤드 수
    n_embd: int = 768  # 임베딩 차원
    block_size: int = 1024  # 최대 시퀀스 길이
    dropout: float = 0.1  # 드롭아웃 비율
    bias: bool = True  # Linear 레이어와 LayerNorm에 bias 사용 여부

# GPT-2 Small 설정 예시
config = GPTConfig()
print(f"모델 파라미터 수: {config.n_layer * config.n_embd * config.n_embd / 1e6:.1f}M")

설명

이것이 하는 일: GPTConfig는 GPT 모델 구현에 필요한 모든 구조적 파라미터를 정의하고 관리합니다. 이 설정은 모델의 모든 컴포넌트에서 참조되어 일관된 구조를 보장합니다.

첫 번째로, vocab_size는 모델이 이해할 수 있는 토큰의 총 개수를 정의합니다. GPT-2는 50,257개의 서로 다른 토큰을 사용하며, 이는 단어, 서브워드, 특수 문자를 모두 포함합니다.

이 값이 클수록 더 다양한 표현이 가능하지만 임베딩 레이어의 메모리 사용량도 증가합니다. 그 다음으로, n_layer와 n_head는 모델의 깊이와 넓이를 결정합니다.

12개의 레이어는 정보가 12번 변환되면서 점점 더 추상적인 표현을 학습한다는 의미입니다. 12개의 어텐션 헤드는 각각 다른 관점에서 문맥을 파악하여, 단어 간의 다양한 관계를 동시에 학습할 수 있게 합니다.

n_embd는 각 토큰을 얼마나 풍부한 벡터로 표현할지를 결정합니다. 768차원 벡터는 각 단어의 의미, 문법적 역할, 문맥상 뉘앙스 등을 담기에 충분한 표현력을 제공합니다.

block_size는 모델이 한 번에 처리할 수 있는 최대 토큰 수로, 긴 문맥을 이해하는 능력과 직결됩니다. 여러분이 이 설정 클래스를 사용하면 다양한 크기의 GPT 모델을 쉽게 실험할 수 있습니다.

GPT-2 Small(124M), Medium(355M), Large(774M) 모델을 만들고 싶다면 이 파라미터들만 조정하면 됩니다. 또한 데이터클래스를 사용하여 타입 힌트와 기본값을 명확히 하므로 코드의 가독성과 유지보수성이 크게 향상됩니다.

실전 팁

💡 n_embd는 항상 n_head로 나누어떨어져야 합니다. 각 헤드가 동일한 차원을 담당하기 때문입니다. 예를 들어 n_embd=768, n_head=12라면 각 헤드는 64차원을 처리합니다.

💡 실험 초기에는 작은 모델(n_layer=6, n_embd=384)로 시작하세요. 학습 속도가 빠르고 버그를 찾기 쉽습니다. 검증이 끝나면 점차 크기를 키워가세요.

💡 메모리가 부족하다면 block_size를 줄이는 것이 가장 효과적입니다. 1024에서 512로 줄이면 메모리 사용량이 크게 감소하면서도 모델의 기본 능력은 유지됩니다.

💡 dropout은 과적합 방지를 위한 핵심 하이퍼파라미터입니다. 작은 데이터셋에서는 0.10.2, 대규모 데이터셋에서는 0.00.1로 설정하는 것이 일반적입니다.

💡 설정을 JSON으로 저장하고 불러오는 메서드를 추가하면 실험 관리가 훨씬 편해집니다. 나중에 어떤 설정으로 학습했는지 정확히 재현할 수 있습니다.


2. Multi-Head Self-Attention - GPT의 핵심 메커니즘

시작하며

여러분이 문장을 읽을 때 각 단어가 다른 단어들과 어떻게 관련되는지 파악하는 것이 중요하죠? "그 은행에 갔다"라는 문장에서 '은행'이 금융기관인지 강가인지는 문맥을 봐야 알 수 있습니다.

기존의 RNN이나 LSTM은 순차적으로 처리하기 때문에 멀리 떨어진 단어 간의 관계를 포착하기 어려웠습니다. 문장이 길어질수록 초반 정보가 희석되는 문제가 발생했죠.

바로 이럴 때 필요한 것이 Multi-Head Self-Attention입니다. 모든 단어가 다른 모든 단어와 직접 상호작용하여 문맥을 파악하고, 여러 개의 헤드가 각각 다른 측면의 관계를 학습합니다.

개요

간단히 말해서, Multi-Head Self-Attention은 문장 내 모든 단어 쌍 사이의 관련성을 동시에 계산하는 메커니즘입니다. '누가 누구에게 얼마나 집중해야 하는지'를 학습합니다.

왜 이 메커니즘이 필요할까요? 자연어는 복잡한 의존 관계를 가집니다.

주어와 동사가 멀리 떨어져 있어도, 대명사가 무엇을 가리키는지 정확히 파악해야 합니다. 예를 들어, "The cat, which was sitting on the mat, meowed"에서 'meowed'는 'cat'과 연결되어야 하는데, 여러 단어가 사이에 있어도 정확히 찾아냅니다.

기존의 순차적 처리 방식은 정보가 여러 단계를 거치며 손실되었지만, Self-Attention은 모든 단어가 직접 연결되어 정보 손실이 없습니다. 게다가 병렬 처리가 가능해 학습 속도도 훨씬 빠릅니다.

이 메커니즘의 핵심은 Query, Key, Value 세 가지 행렬을 사용한다는 것입니다. Query는 "내가 찾는 정보", Key는 "내가 제공하는 정보", Value는 "실제 전달할 내용"으로 이해할 수 있습니다.

Multi-Head는 이 과정을 여러 번 병렬로 수행하여 다양한 관점의 관계를 동시에 학습합니다.

코드 예제

class MultiHeadAttention(nn.Module):
    def __init__(self, config):
        super().__init__()
        assert config.n_embd % config.n_head == 0
        # Query, Key, Value를 한 번에 계산 (효율성)
        self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
        # 출력 프로젝션
        self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
        self.n_head = config.n_head
        self.n_embd = config.n_embd
        self.dropout = nn.Dropout(config.dropout)

    def forward(self, x):
        B, T, C = x.size()  # Batch, Time(sequence), Channel(embedding)
        # Q, K, V 계산 및 헤드별로 분리
        q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
        k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)  # (B, nh, T, hs)
        q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
        v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
        # Scaled Dot-Product Attention
        att = (q @ k.transpose(-2, -1)) * (1.0 / torch.sqrt(torch.tensor(k.size(-1))))
        att = torch.softmax(att, dim=-1)
        att = self.dropout(att)
        y = att @ v  # (B, nh, T, hs)
        y = y.transpose(1, 2).contiguous().view(B, T, C)  # 헤드 결합
        return self.c_proj(y)

설명

이것이 하는 일: Multi-Head Attention은 입력 시퀀스의 각 위치가 다른 모든 위치와 얼마나 관련이 있는지를 계산하고, 그 관련성에 따라 정보를 가중 평균하여 새로운 표현을 만듭니다. 첫 번째로, c_attn 레이어가 입력을 받아 Query, Key, Value 세 개의 행렬로 변환합니다.

이 세 행렬은 각각 다른 역할을 하는데, Query는 "내가 어떤 정보를 원하는가", Key는 "나는 이런 정보를 제공할 수 있다", Value는 "실제로 전달할 정보"를 표현합니다. 효율성을 위해 한 번의 행렬 곱셈으로 세 개를 동시에 계산합니다.

그 다음으로, 각 행렬을 n_head개의 작은 조각으로 분할합니다. 12개 헤드라면 768차원을 64차원씩 12개로 나눕니다.

이렇게 하는 이유는 각 헤드가 서로 다른 패턴을 학습하도록 하기 위함입니다. 어떤 헤드는 문법적 관계를, 어떤 헤드는 의미적 유사성을, 또 다른 헤드는 위치적 관계를 학습할 수 있습니다.

Scaled Dot-Product Attention 단계에서는 Query와 Key의 내적으로 관련성 점수를 계산합니다. 내적 값이 크면 두 단어가 관련이 높다는 뜻입니다.

sqrt(d_k)로 나누는 스케일링은 값이 너무 커져서 gradient가 소실되는 것을 방지합니다. Softmax를 적용하면 각 단어가 다른 단어들에 대한 확률 분포를 갖게 되고, 이 분포로 Value를 가중 평균하여 최종 출력을 만듭니다.

마지막으로, 모든 헤드의 결과를 다시 연결(concatenate)하고 c_proj 레이어를 통과시킵니다. 이 프로젝션은 여러 헤드의 다양한 관점을 하나의 통합된 표현으로 합치는 역할을 합니다.

최종적으로 각 단어는 문맥을 충분히 반영한 새로운 표현 벡터를 갖게 됩니다. 여러분이 이 어텐션 메커니즘을 사용하면 긴 문맥에서도 정확한 의미 파악이 가능하고, 병렬 처리로 학습 속도가 빠르며, 어떤 단어가 어떤 단어에 집중했는지 시각화하여 모델의 추론 과정을 이해할 수 있습니다.

이것이 GPT가 자연스러운 문장을 생성할 수 있는 핵심 비결입니다.

실전 팁

💡 Flash Attention을 사용하면 메모리 사용량을 크게 줄이고 속도를 높일 수 있습니다. torch.nn.functional.scaled_dot_product_attention을 사용하면 자동으로 최적화된 구현이 적용됩니다.

💡 Attention 가중치를 시각화하면 모델이 무엇을 학습했는지 직관적으로 이해할 수 있습니다. 어떤 단어가 어떤 단어에 집중하는지 히트맵으로 확인해보세요.

💡 Causal Masking(인과적 마스킹)을 적용하여 미래 토큰을 보지 못하게 해야 합니다. GPT는 자기회귀 모델이므로 현재 위치보다 뒤의 토큰은 마스킹해야 합니다.

💡 Key와 Value에 대한 캐싱을 구현하면 생성 속도가 크게 향상됩니다. 이미 계산한 토큰의 K, V는 재사용할 수 있습니다.


3. Position-wise Feed-Forward Network - 비선형 변환의 힘

시작하며

여러분이 Self-Attention으로 문맥 정보를 모았다면, 이제 그 정보를 어떻게 처리해야 할까요? 단순히 선형 변환만으로는 복잡한 패턴을 학습하기 어렵습니다.

신경망의 표현력은 비선형성에서 나옵니다. Attention이 '무엇을 볼지'를 결정했다면, 이제 '본 것을 어떻게 해석할지'가 필요합니다.

선형 레이어만으로는 단순한 조합만 가능하지만, 비선형 활성화 함수를 거치면 훨씬 복잡한 특징을 추출할 수 있습니다. 바로 이럴 때 필요한 것이 Position-wise Feed-Forward Network입니다.

각 위치에서 독립적으로 동일한 변환을 적용하여, Attention으로 모은 정보를 깊이 있게 처리합니다.

개요

간단히 말해서, Feed-Forward Network는 각 토큰 위치에서 독립적으로 적용되는 2개 레이어의 완전연결 신경망입니다. 중간에 비선형 활성화 함수를 사용하여 복잡한 패턴을 학습합니다.

왜 이 컴포넌트가 필요할까요? Self-Attention은 토큰 간의 관계를 파악하는 데 탁월하지만, 실제로는 선형 변환입니다.

복잡한 언어 패턴을 학습하려면 비선형성이 필수적입니다. 예를 들어, "not good"과 "good"의 의미 차이를 학습하려면 단순한 가중치 합 이상의 변환이 필요합니다.

기존의 RNN은 순차적으로 처리하면서 비선형성을 적용했다면, Transformer는 Attention과 FFN을 분리하여 각각의 역할을 명확히 합니다. Attention은 정보 수집, FFN은 정보 처리를 담당합니다.

이 네트워크의 핵심은 확장-축소 구조입니다. 먼저 차원을 4배로 확장(768 → 3072)하여 풍부한 표현 공간을 만들고, GELU 활성화 함수로 비선형성을 추가한 뒤, 다시 원래 차원으로 축소합니다.

이러한 구조가 복잡한 특징을 효과적으로 학습할 수 있게 합니다.

코드 예제

class FeedForward(nn.Module):
    def __init__(self, config):
        super().__init__()
        # 첫 번째 레이어: 차원 확장 (4배)
        self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
        # GELU 활성화 함수 (GPT-2에서 사용)
        self.gelu = nn.GELU()
        # 두 번째 레이어: 원래 차원으로 축소
        self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias)
        self.dropout = nn.Dropout(config.dropout)

    def forward(self, x):
        # x: (batch, seq_len, n_embd)
        x = self.c_fc(x)  # (batch, seq_len, 4*n_embd) - 확장
        x = self.gelu(x)  # 비선형 변환
        x = self.c_proj(x)  # (batch, seq_len, n_embd) - 축소
        x = self.dropout(x)
        return x

설명

이것이 하는 일: Feed-Forward Network는 Attention으로 수집한 문맥 정보를 각 위치에서 독립적으로 변환하여 더 추상적이고 유용한 표현으로 만듭니다. 첫 번째로, c_fc 레이어가 입력 차원을 4배로 확장합니다.

768차원이 3072차원이 되는데, 이는 표현 공간을 크게 넓혀서 더 많은 특징을 학습할 수 있게 합니다. 이 확장된 공간에서 모델은 훨씬 복잡한 패턴을 포착할 수 있습니다.

마치 작은 방에서 큰 방으로 이동하여 더 많은 정보를 펼쳐놓을 수 있는 것과 같습니다. 그 다음으로, GELU(Gaussian Error Linear Unit) 활성화 함수가 적용됩니다.

GELU는 ReLU보다 부드러운 곡선을 가지며, 음수 값도 완전히 0으로 만들지 않고 작은 값을 남깁니다. 이것이 GPT의 성능에 중요한 역할을 하는데, 학습 초기에도 gradient가 잘 흐르고, 미묘한 패턴도 학습할 수 있게 합니다.

세 번째 단계에서 c_proj 레이어가 확장된 차원을 다시 원래대로 축소합니다. 3072차원의 풍부한 정보를 768차원으로 압축하면서, 가장 중요한 특징만 남기고 나머지는 버립니다.

이 과정에서 모델은 어떤 정보가 중요한지를 학습하게 됩니다. 마지막으로 드롭아웃을 적용하여 과적합을 방지합니다.

무작위로 일부 뉴런을 비활성화함으로써 모델이 특정 패턴에 과도하게 의존하지 않도록 합니다. 이렇게 처리된 출력은 잔차 연결(residual connection)을 통해 원래 입력과 더해집니다.

여러분이 이 FFN을 사용하면 Attention만으로는 불가능한 복잡한 비선형 변환을 학습할 수 있습니다. 실제로 GPT의 파라미터 대부분이 이 FFN 레이어에 집중되어 있으며, 모델의 지식과 추론 능력의 상당 부분이 여기에 저장됩니다.

각 레이어의 FFN은 점점 더 추상적인 언어 패턴을 학습합니다.

실전 팁

💡 중간 차원을 4배 대신 다른 배수로 실험해볼 수 있습니다. 일부 연구에서는 2.67배(SwiGLU 사용 시)가 더 효율적이라는 결과도 있습니다.

💡 GELU 대신 SwiGLU나 GeGLU 같은 gated 활성화 함수를 사용하면 성능이 향상될 수 있습니다. 최신 LLM들은 이런 변형을 많이 사용합니다.

💡 FFN의 파라미터 수가 전체 모델의 2/3를 차지합니다. 메모리를 절약하려면 이 부분을 압축하거나 sparse하게 만드는 것이 효과적입니다.

💡 각 위치에서 독립적으로 적용되므로 배치와 시퀀스 차원을 합쳐서 한 번에 처리하면 계산이 더 효율적입니다.


4. Transformer Block - Attention과 FFN의 결합

시작하며

여러분이 Attention과 Feed-Forward Network를 각각 구현했다면, 이제 이들을 어떻게 조합해야 할까요? 단순히 이어 붙이는 것만으로는 깊은 신경망에서 발생하는 문제들을 해결할 수 없습니다.

깊은 신경망은 gradient vanishing/exploding 문제에 시달립니다. 레이어가 쌓일수록 초기 레이어로 gradient가 전달되기 어려워지고, 학습이 불안정해집니다.

이 문제를 해결하지 않으면 12개 레이어를 쌓아도 효과를 보기 어렵습니다. 바로 이럴 때 필요한 것이 Residual Connection과 Layer Normalization을 적용한 Transformer Block입니다.

각 서브레이어를 건너뛸 수 있는 지름길을 만들고, 안정적인 학습을 보장합니다.

개요

간단히 말해서, Transformer Block은 Multi-Head Attention과 Feed-Forward Network를 Residual Connection과 Layer Normalization으로 감싼 구조입니다. 이것이 GPT의 기본 반복 단위입니다.

왜 이런 구조가 필요할까요? ResNet 연구에서 밝혀진 것처럼, 잔차 연결은 매우 깊은 네트워크를 학습 가능하게 만듭니다.

각 레이어가 입력을 완전히 변환하는 것이 아니라, 입력에 작은 변화를 더하는 방식으로 동작합니다. 예를 들어, 100번째 레이어에서도 1번째 레이어의 정보가 직접 전달될 수 있어 gradient 흐름이 원활합니다.

기존의 단순 스택 구조는 레이어가 깊어질수록 학습이 어려웠지만, 잔차 연결을 사용하면 각 레이어가 정체성 함수(identity function)를 기본으로 학습할 수 있습니다. 즉, 최악의 경우에도 입력을 그대로 통과시킬 수 있어 성능이 나빠지지 않습니다.

Layer Normalization은 각 샘플의 특징들을 정규화하여 학습을 안정화합니다. Pre-LN 방식(LN을 서브레이어 앞에 배치)은 특히 깊은 모델에서 효과적입니다.

Dropout은 과적합을 방지하고 모델의 일반화 능력을 높입니다.

코드 예제

class TransformerBlock(nn.Module):
    def __init__(self, config):
        super().__init__()
        # Layer Normalization (Pre-LN 방식)
        self.ln_1 = nn.LayerNorm(config.n_embd)
        self.attn = MultiHeadAttention(config)
        self.ln_2 = nn.LayerNorm(config.n_embd)
        self.mlp = FeedForward(config)

    def forward(self, x):
        # Multi-Head Attention with residual connection
        # Pre-LN: LN -> Attention -> Residual
        x = x + self.attn(self.ln_1(x))

        # Feed-Forward Network with residual connection
        # Pre-LN: LN -> FFN -> Residual
        x = x + self.mlp(self.ln_2(x))

        return x

설명

이것이 하는 일: Transformer Block은 문맥 파악(Attention)과 정보 처리(FFN)를 순차적으로 수행하면서, 각 단계의 입력을 보존하여 깊은 네트워크에서도 안정적인 학습을 가능하게 합니다. 첫 번째로, 입력이 Layer Normalization을 거칩니다.

Pre-LN 방식에서는 서브레이어에 들어가기 전에 정규화를 수행합니다. Layer Norm은 각 샘플의 모든 특징에 대해 평균을 0, 분산을 1로 만들어 입력 분포를 안정화합니다.

이것이 중요한 이유는 학습 과정에서 각 레이어의 입력 분포가 계속 변하는 "internal covariate shift" 문제를 완화하기 때문입니다. 그 다음으로, 정규화된 입력이 Multi-Head Attention을 통과합니다.

여기서 각 토큰이 다른 토큰들과 상호작용하여 문맥 정보를 얻습니다. 중요한 점은 Attention의 출력을 원래 입력 x와 더한다는 것입니다.

이 잔차 연결 덕분에 Attention이 아무것도 학습하지 못하더라도(최악의 경우) 입력이 그대로 통과할 수 있습니다. 실제로는 Attention이 "입력에 대한 수정사항"을 학습합니다.

세 번째 단계에서 다시 Layer Normalization을 적용하고 Feed-Forward Network를 통과시킵니다. FFN은 각 위치에서 독립적으로 비선형 변환을 수행하여 Attention으로 모은 정보를 깊이 있게 처리합니다.

마찬가지로 FFN의 출력도 잔차 연결을 통해 입력과 더해집니다. 최종 출력은 원래 입력에 Attention과 FFN의 변환이 순차적으로 더해진 결과입니다.

수식으로 표현하면: x_out = x + FFN(LN(x + Attention(LN(x)))) 입니다. 12개의 이런 블록을 쌓으면 각 블록이 점진적으로 더 추상적인 표현을 학습합니다.

여러분이 이 블록 구조를 사용하면 수십 개의 레이어를 안정적으로 학습할 수 있고, 각 레이어가 명확한 역할 분담을 통해 효율적으로 작동하며, Pre-LN 방식 덕분에 학습률 튜닝도 쉬워집니다. GPT-3는 96개의 이런 블록을 사용하여 1750억 개의 파라미터를 학습했습니다.

실전 팁

💡 Post-LN(잔차 연결 후 정규화) 대신 Pre-LN을 사용하면 학습이 훨씬 안정적입니다. 특히 깊은 모델에서 필수적입니다.

💡 Gradient checkpointing을 활성화하면 메모리를 크게 절약할 수 있습니다. Forward pass 중간 결과를 저장하지 않고 backward 시 재계산합니다.

💡 각 블록의 출력 norm을 모니터링하세요. 값이 너무 크거나 작으면 학습이 불안정하다는 신호입니다. Gradient clipping과 함께 사용하면 효과적입니다.

💡 블록의 개수와 폭(n_embd)를 조절할 때, 일반적으로 더 넓고 얕은 모델보다 더 좁고 깊은 모델이 효율적입니다.


5. Positional Encoding - 위치 정보 주입하기

시작하며

여러분이 Self-Attention을 구현했을 때 한 가지 문제가 있습니다. Attention은 모든 토큰을 동시에 처리하기 때문에 순서 정보가 없다는 것입니다.

"나는 너를 사랑해"와 "너를 나는 사랑해"를 구분할 수 없습니다. 언어에서 순서는 매우 중요합니다.

같은 단어들이라도 순서가 바뀌면 의미가 완전히 달라집니다. RNN은 순차적으로 처리하기 때문에 자연스럽게 위치 정보를 가졌지만, Transformer는 병렬 처리의 이점을 위해 순서를 포기했습니다.

바로 이럴 때 필요한 것이 Positional Encoding입니다. 각 토큰의 임베딩에 위치 정보를 더해서 모델이 순서를 인식할 수 있게 만듭니다.

개요

간단히 말해서, Positional Encoding은 각 토큰의 위치를 나타내는 벡터를 임베딩에 더하는 기법입니다. GPT는 학습 가능한 위치 임베딩을 사용합니다.

왜 위치 정보를 추가해야 할까요? Attention 메커니즘은 set operation과 유사합니다.

즉, 입력의 순서를 바꿔도 출력이 같습니다. 하지만 자연어는 순서에 민감합니다.

"개가 고양이를 쫓는다"와 "고양이가 개를 쫓는다"는 완전히 다른 의미입니다. 예를 들어, 번역 작업에서 단어 순서가 틀리면 전혀 다른 문장이 만들어집니다.

원래 Transformer 논문에서는 사인/코사인 함수를 사용한 고정된 위치 인코딩을 제안했지만, GPT는 학습 가능한 위치 임베딩을 사용합니다. 이 방식은 데이터로부터 최적의 위치 표현을 학습할 수 있어 더 유연합니다.

핵심은 각 위치(0, 1, 2, ...)마다 고유한 벡터를 할당하고, 이를 토큰 임베딩과 더한다는 것입니다. 위치 0은 특정 패턴을, 위치 1은 다른 패턴을 나타내며, 모델은 이 패턴을 통해 순서를 인식합니다.

학습 과정에서 인접한 위치는 유사한 벡터를, 먼 위치는 다른 벡터를 갖도록 자동으로 조정됩니다.

코드 예제

class GPTEmbedding(nn.Module):
    def __init__(self, config):
        super().__init__()
        # 토큰 임베딩: 각 단어를 벡터로 변환
        self.token_embedding = nn.Embedding(config.vocab_size, config.n_embd)
        # 위치 임베딩: 각 위치를 벡터로 변환 (학습 가능)
        self.position_embedding = nn.Embedding(config.block_size, config.n_embd)
        self.dropout = nn.Dropout(config.dropout)

    def forward(self, idx):
        B, T = idx.size()
        # 토큰 임베딩
        tok_emb = self.token_embedding(idx)  # (B, T, n_embd)
        # 위치 인덱스 생성: [0, 1, 2, ..., T-1]
        pos = torch.arange(0, T, dtype=torch.long, device=idx.device)
        pos_emb = self.position_embedding(pos)  # (T, n_embd)
        # 토큰 임베딩 + 위치 임베딩
        x = tok_emb + pos_emb  # broadcasting: (B, T, n_embd)
        return self.dropout(x)

설명

이것이 하는 일: Positional Encoding은 Attention의 순서 불변성 문제를 해결하기 위해 각 위치에 고유한 신호를 주입하여 모델이 토큰의 순서를 인식하고 활용할 수 있게 합니다. 첫 번째로, 토큰 임베딩 레이어가 각 토큰 ID를 dense vector로 변환합니다.

예를 들어 토큰 5000번은 768차원의 특정 벡터로 매핑됩니다. 이 벡터는 해당 단어의 의미적 정보를 담고 있습니다.

vocab_size x n_embd 크기의 룩업 테이블이라고 생각하면 됩니다. 그 다음으로, 위치 임베딩 레이어가 각 위치를 벡터로 변환합니다.

입력 시퀀스의 길이가 T라면, [0, 1, 2, ..., T-1]의 위치 인덱스를 생성하고 각각을 벡터로 변환합니다. 위치 0은 "문장의 시작"을 나타내는 패턴을, 위치 50은 "문장 중반"을 나타내는 패턴을 학습하게 됩니다.

block_size x n_embd 크기의 또 다른 룩업 테이블입니다. 세 번째 단계에서 두 임베딩을 더합니다.

broadcasting 덕분에 배치의 모든 샘플에 동일한 위치 임베딩이 적용됩니다. 예를 들어, 첫 번째 토큰은 항상 위치 0의 임베딩을 더하고, 두 번째 토큰은 위치 1의 임베딩을 더합니다.

이 덧셈으로 각 토큰은 "무엇(토큰)"과 "어디(위치)" 정보를 동시에 갖게 됩니다. 최종적으로 드롭아웃을 적용하여 과적합을 방지합니다.

결과 벡터는 Transformer Block에 입력되어 Attention과 FFN을 거치면서 점차 정제됩니다. 중요한 점은 위치 임베딩이 학습 가능하다는 것입니다.

역전파를 통해 최적의 위치 표현이 자동으로 학습됩니다. 여러분이 이 위치 임베딩을 사용하면 모델이 "주어 다음에는 동사가 온다" 같은 순서 패턴을 학습할 수 있고, 상대적 위치 관계도 파악할 수 있으며, 문장의 앞부분과 뒷부분을 구분할 수 있습니다.

실험 결과 학습 가능한 위치 임베딩이 고정된 사인/코사인 인코딩보다 약간 더 나은 성능을 보입니다.

실전 팁

💡 block_size보다 긴 시퀀스는 처리할 수 없습니다. 학습 시 사용한 최대 길이가 추론 시의 한계입니다. 더 긴 문맥이 필요하면 처음부터 큰 block_size로 학습해야 합니다.

💡 RoPE(Rotary Position Embedding)나 ALiBi 같은 고급 위치 인코딩을 사용하면 더 긴 시퀀스로 일반화가 가능합니다. 최신 모델들은 이런 방식을 선호합니다.

💡 위치 임베딩의 가중치를 시각화하면 인접한 위치가 유사한 패턴을 학습했는지 확인할 수 있습니다. PCA나 t-SNE로 분석해보세요.

💡 절대 위치 대신 상대 위치 인코딩을 사용하면 모델이 위치 간의 거리를 더 잘 학습합니다. T5나 DeBERTa에서 사용하는 방식입니다.


6. Causal Self-Attention Masking - 미래를 보지 못하게 하기

시작하며

여러분이 언어 모델을 학습시킬 때 중요한 원칙이 있습니다. 현재 단어를 예측할 때 미래의 단어를 봐서는 안 된다는 것입니다.

마치 시험 문제를 풀 때 정답지를 보면 안 되는 것과 같습니다. Self-Attention은 기본적으로 모든 위치를 동시에 볼 수 있습니다.

하지만 GPT는 자기회귀(autoregressive) 모델로, 왼쪽에서 오른쪽으로 순차적으로 생성합니다. "나는 학교에 ___"를 예측할 때 "갔다"라는 답을 미리 보면 학습이 의미 없어집니다.

바로 이럴 때 필요한 것이 Causal Masking입니다. Attention 계산 시 현재 위치보다 뒤에 있는 토큰들을 마스킹하여 모델이 미래를 볼 수 없게 만듭니다.

개요

간단히 말해서, Causal Masking은 Attention 행렬의 상삼각 부분을 -∞로 설정하여 미래 토큰에 대한 attention을 차단하는 기법입니다. 각 토큰은 자신과 이전 토큰들만 볼 수 있습니다.

왜 이 마스킹이 필수일까요? 언어 모델의 목표는 이전 문맥만으로 다음 단어를 예측하는 것입니다.

학습 시 정답을 보면서 학습하면 모델이 실제로는 예측 능력을 배우지 못하고 단순히 복사만 하게 됩니다. 예를 들어, "The cat sat on the ___"를 예측할 때 "mat"을 미리 보면 안 됩니다.

실제 생성 상황에서는 그 정보가 없기 때문입니다. BERT 같은 양방향 모델은 모든 토큰을 보지만, GPT는 단방향(왼쪽→오른쪽)이므로 인과적(causal) 제약이 필요합니다.

Masking을 통해 학습 시와 추론 시의 조건을 동일하게 맞춥니다. 구현 방법은 간단합니다.

Attention score를 계산한 후 softmax를 적용하기 전에, 상삼각 부분(미래 위치)을 -∞로 설정합니다. Softmax(-∞)는 0이 되므로, 해당 위치에 대한 attention weight가 완전히 차단됩니다.

하삼각 행렬만 유효하므로 각 위치는 자신과 이전만 볼 수 있습니다.

코드 예제

class CausalSelfAttention(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
        self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
        self.n_head = config.n_head
        self.n_embd = config.n_embd
        self.dropout = nn.Dropout(config.dropout)
        # Causal mask 등록 (상삼각 행렬)
        self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size))
                             .view(1, 1, config.block_size, config.block_size))

    def forward(self, x):
        B, T, C = x.size()
        q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
        k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
        q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
        v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
        # Attention scores
        att = (q @ k.transpose(-2, -1)) * (1.0 / torch.sqrt(torch.tensor(k.size(-1))))
        # Causal masking: 미래 위치를 -inf로 설정
        att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))
        att = torch.softmax(att, dim=-1)
        att = self.dropout(att)
        y = att @ v
        y = y.transpose(1, 2).contiguous().view(B, T, C)
        return self.c_proj(y)

설명

이것이 하는 일: Causal Masking은 학습과 추론 시의 조건을 일치시키기 위해 각 위치가 미래 정보에 접근하지 못하도록 강제합니다. 첫 번째로, register_buffer를 사용하여 하삼각 행렬(lower triangular matrix)을 모델의 버퍼로 등록합니다.

torch.tril은 대각선과 아래쪽만 1, 위쪽은 0인 행렬을 만듭니다. 이것이 우리의 마스크입니다.

Buffer로 등록하면 학습 파라미터는 아니지만 모델과 함께 GPU로 이동하고 저장됩니다. 그 다음으로, Attention score를 계산합니다.

Query와 Key의 내적으로 각 토큰 쌍의 관련성을 수치화합니다. 이 시점의 att 행렬은 모든 위치 간의 점수를 담고 있는데, 아직 미래 정보가 포함되어 있습니다.

예를 들어 att[0, 5]는 위치 0이 위치 5(미래)를 얼마나 볼지를 나타냅니다. 세 번째 단계에서 masked_fill을 사용하여 마스크가 0인 위치(상삼각)를 -inf로 채웁니다.

self.bias[:,:,:T,:T]로 현재 시퀀스 길이 T에 맞게 마스크를 슬라이싱합니다. -inf로 설정하는 이유는 다음 단계의 softmax에서 이 값들이 0으로 변환되기 때문입니다.

exp(-inf) = 0이므로 해당 위치에 대한 attention weight가 완전히 사라집니다. 마지막으로 softmax를 적용하면 각 행의 합이 1인 확률 분포가 됩니다.

미래 위치는 weight가 0이므로, 각 토큰은 자신과 이전 토큰들에만 집중합니다. 예를 들어 위치 3의 토큰은 위치 0, 1, 2, 3에만 attention을 분배하고 4, 5, ...는 완전히 무시합니다.

여러분이 이 마스킹을 사용하면 학습 시 정답을 보는 치팅을 방지할 수 있고, 병렬 학습의 이점을 유지하면서도 자기회귀적 특성을 보장하며, 추론 시와 동일한 조건에서 학습하여 더 나은 일반화 성능을 얻습니다. 이것이 GPT가 일관성 있는 긴 텍스트를 생성할 수 있는 핵심 메커니즘입니다.

실전 팁

💡 Flash Attention을 사용할 때는 is_causal=True 플래그를 설정하면 자동으로 causal masking이 적용됩니다. 별도로 마스크를 만들 필요가 없어 더 효율적입니다.

💡 KV 캐싱을 구현할 때는 이미 생성된 토큰의 Key와 Value를 재사용하여 속도를 크게 높일 수 있습니다. 새 토큰은 모든 이전 토큰을 봐야 하지만, 이전 토큰의 KV는 변하지 않습니다.

💡 Attention 가중치를 시각화하면 하삼각 패턴이 명확히 보입니다. 각 행이 왼쪽으로만 attention을 주는 것을 확인할 수 있습니다.

💡 Prefix LM처럼 일부는 양방향, 일부는 단방향으로 보려면 커스텀 마스크를 만들어야 합니다. Prefix 부분은 모두 볼 수 있고, 생성 부분만 causal하게 만듭니다.


7. 전체 GPT 모델 조립 - 모든 컴포넌트 통합하기

시작하며

여러분이 지금까지 Embedding, Attention, FFN, Transformer Block을 모두 구현했다면, 이제 이들을 하나의 완전한 모델로 조립할 차례입니다. 각 부품이 아무리 훌륭해도 제대로 연결하지 않으면 작동하지 않습니다.

GPT 모델의 전체 구조는 임베딩 레이어, 여러 개의 Transformer Block, 그리고 출력 레이어로 구성됩니다. 이들을 올바른 순서로 연결하고, 가중치를 효율적으로 공유하며, 출력을 올바르게 생성해야 합니다.

바로 이제 필요한 것이 전체 GPT 클래스입니다. 모든 컴포넌트를 통합하고, forward pass를 정의하며, 다음 토큰 예측을 위한 로짓(logits)을 출력합니다.

개요

간단히 말해서, GPT 모델은 토큰 임베딩 → N개의 Transformer Block → LayerNorm → 출력 프로젝션으로 이어지는 파이프라인입니다. 입력 토큰 시퀀스를 받아 각 위치에서 다음 토큰의 확률 분포를 출력합니다.

왜 이런 구조가 필요할까요? 언어 모델의 목표는 P(next_token | previous_tokens)를 학습하는 것입니다.

임베딩은 토큰을 벡터로 변환하고, Transformer Block들은 점점 더 추상적인 표현을 학습하며, 최종 레이어는 이 표현을 다시 vocab_size 크기의 로짓으로 변환합니다. 예를 들어, "The cat sat on the"가 입력이면 "mat", "floor", "chair" 등에 높은 확률을 부여해야 합니다.

중요한 최적화는 입력 임베딩과 출력 프로젝션의 가중치를 공유하는 것입니다. 이를 weight tying이라 하며, 파라미터 수를 줄이면서도 성능을 유지합니다.

같은 vocab을 인코딩하고 디코딩하므로 동일한 표현을 공유하는 것이 합리적입니다. 모델의 핵심 흐름은 다음과 같습니다: 입력 ID → 임베딩(토큰+위치) → 12개 Block 통과 → 최종 LayerNorm → 출력 프로젝션 → vocab_size 차원의 로짓.

학습 시에는 이 로짓과 실제 다음 토큰 간의 cross-entropy loss를 최소화합니다.

코드 예제

class GPT(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config
        # 임베딩 레이어
        self.transformer = nn.ModuleDict(dict(
            wte = nn.Embedding(config.vocab_size, config.n_embd),  # token embedding
            wpe = nn.Embedding(config.block_size, config.n_embd),  # position embedding
            drop = nn.Dropout(config.dropout),
            h = nn.ModuleList([TransformerBlock(config) for _ in range(config.n_layer)]),
            ln_f = nn.LayerNorm(config.n_embd),  # 최종 LayerNorm
        ))
        # 출력 레이어 (vocab으로 프로젝션)
        self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
        # Weight tying: 입력 임베딩과 출력 프로젝션 가중치 공유
        self.transformer.wte.weight = self.lm_head.weight
        self.apply(self._init_weights)

    def _init_weights(self, module):
        if isinstance(module, nn.Linear):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                torch.nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)

    def forward(self, idx, targets=None):
        B, T = idx.size()
        pos = torch.arange(0, T, dtype=torch.long, device=idx.device)
        # 임베딩
        tok_emb = self.transformer.wte(idx)  # (B, T, n_embd)
        pos_emb = self.transformer.wpe(pos)  # (T, n_embd)
        x = self.transformer.drop(tok_emb + pos_emb)
        # Transformer blocks
        for block in self.transformer.h:
            x = block(x)
        # 최종 정규화 및 출력
        x = self.transformer.ln_f(x)
        logits = self.lm_head(x)  # (B, T, vocab_size)

        loss = None
        if targets is not None:
            loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
        return logits, loss

설명

이것이 하는 일: GPT 모델은 입력 토큰 시퀀스를 받아 여러 변환 단계를 거쳐 각 위치에서 다음 토큰에 대한 확률 분포를 생성합니다. 첫 번째로, 입력 토큰 ID가 임베딩 레이어를 통과합니다.

wte(word token embedding)가 각 토큰을 벡터로 변환하고, wpe(word position embedding)가 위치 정보를 추가합니다. 두 임베딩을 더한 후 드롭아웃을 적용하여 과적합을 방지합니다.

이 초기 표현은 단어의 의미와 위치 정보를 모두 담고 있습니다. 그 다음으로, 12개(또는 config.n_layer개)의 Transformer Block을 순차적으로 통과합니다.

각 블록은 Self-Attention으로 문맥을 파악하고 FFN으로 정보를 처리합니다. 첫 번째 블록은 단어 수준의 패턴을, 중간 블록은 구문 수준의 패턴을, 마지막 블록은 문단 수준의 추상적 패턴을 학습합니다.

각 블록을 거칠 때마다 표현은 점점 더 정제됩니다. 세 번째 단계에서 최종 LayerNorm(ln_f)을 적용합니다.

이는 모든 블록을 거친 후 출력을 안정화하는 역할을 합니다. GPT-2의 경우 이 최종 정규화가 성능에 중요한 영향을 미칩니다.

마지막으로 lm_head 레이어가 n_embd 차원의 벡터를 vocab_size 차원으로 변환합니다. 각 차원은 해당 토큰이 다음에 올 가능성을 나타내는 로짓(logit)입니다.

예를 들어 vocab_size가 50257이면, 각 위치마다 50257개의 점수가 나옵니다. Softmax를 적용하면 확률 분포가 되고, argmax로 가장 가능성 높은 토큰을 선택합니다.

여러분이 이 모델을 사용하면 대규모 텍스트 데이터로 학습하여 언어의 패턴을 학습할 수 있고, 문맥에 맞는 자연스러운 텍스트를 생성할 수 있으며, fine-tuning을 통해 특정 작업에 맞게 조정할 수 있습니다. GPT-2 Small은 1.5GB 텍스트로 학습하여 놀라운 생성 능력을 보여줬습니다.

실전 팁

💡 가중치 초기화가 매우 중요합니다. 평균 0, 표준편차 0.02로 정규분포 초기화하면 학습 초기에 안정적입니다. 특히 깊은 모델일수록 초기화가 critical합니다.

💡 Gradient accumulation을 사용하면 작은 GPU에서도 큰 배치 사이즈 효과를 낼 수 있습니다. 여러 step의 gradient를 누적한 후 한 번에 업데이트합니다.

💡 Mixed precision training(FP16 또는 BF16)을 사용하면 메모리를 절반으로 줄이고 속도를 2배 높일 수 있습니다. torch.cuda.amp를 사용하세요.

💡 모델 저장 시 config도 함께 저장하세요. 나중에 모델을 불러올 때 정확히 같은 구조를 재현할 수 있어야 합니다.

💡 학습 중간에 생성 품질을 확인하세요. Perplexity만으로는 부족하고, 실제 텍스트를 생성해보면 문제를 빨리 발견할 수 있습니다.


8. 텍스트 생성 전략 - 다양한 디코딩 방법

시작하며

여러분이 모델을 학습시켰다면, 이제 실제로 텍스트를 생성할 차례입니다. 하지만 단순히 가장 높은 확률의 토큰만 선택하면 반복적이고 지루한 텍스트가 나옵니다.

"I think I think I think..."처럼 같은 패턴이 무한 반복될 수 있습니다. 텍스트 생성의 품질은 디코딩 전략에 크게 좌우됩니다.

너무 결정적이면 단조롭고, 너무 무작위적이면 일관성이 없습니다. 창의성과 일관성의 균형을 맞춰야 합니다.

바로 이럴 때 필요한 것이 다양한 샘플링 전략입니다. Temperature Scaling, Top-k Sampling, Top-p(Nucleus) Sampling 등을 통해 생성 품질을 제어할 수 있습니다.

개요

간단히 말해서, 텍스트 생성 전략은 모델의 확률 분포에서 다음 토큰을 선택하는 방법입니다. 가장 높은 확률(greedy), 무작위(sampling), 또는 둘의 조합을 사용할 수 있습니다.

왜 다양한 전략이 필요할까요? Greedy decoding(항상 최고 확률 선택)은 안전하지만 창의성이 없습니다.

반면 완전 무작위는 너무 불안정합니다. 실무에서는 대화형 AI는 다양성을, 번역은 정확성을 우선시합니다.

예를 들어, 창의적 글쓰기에서는 예상치 못한 단어 선택이 흥미롭지만, 기술 문서 요약에서는 정확한 용어가 중요합니다. Temperature는 확률 분포의 날카로움을 조절합니다.

낮은 온도(0.1)는 고확률 토큰에 집중하고, 높은 온도(1.5)는 저확률 토큰에도 기회를 줍니다. Top-k는 상위 k개만 고려하고, Top-p는 누적 확률이 p를 넘을 때까지만 고려합니다.

실제로는 여러 전략을 조합합니다. Temperature로 분포를 조정하고, Top-p로 너무 낮은 확률은 제외하며, 최종적으로 샘플링합니다.

이렇게 하면 창의적이면서도 합리적인 텍스트를 생성할 수 있습니다.

코드 예제

def generate(model, idx, max_new_tokens, temperature=1.0, top_k=None, top_p=0.9):
    """
    idx: (B, T) 초기 컨텍스트 토큰들
    max_new_tokens: 생성할 토큰 개수
    temperature: 샘플링 온도 (낮을수록 결정적)
    top_k: 상위 k개 토큰만 고려
    top_p: 누적 확률 p까지의 토큰만 고려 (nucleus sampling)
    """
    for _ in range(max_new_tokens):
        # 컨텍스트가 block_size를 넘으면 자르기
        idx_cond = idx if idx.size(1) <= model.config.block_size else idx[:, -model.config.block_size:]
        # Forward pass
        logits, _ = model(idx_cond)
        logits = logits[:, -1, :] / temperature  # 마지막 위치만, temperature 적용

        # Top-k filtering
        if top_k is not None:
            v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
            logits[logits < v[:, [-1]]] = -float('Inf')

        # Top-p (nucleus) filtering
        if top_p is not None:
            sorted_logits, sorted_indices = torch.sort(logits, descending=True)
            cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
            sorted_indices_to_remove = cumulative_probs > top_p
            sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
            sorted_indices_to_remove[..., 0] = 0
            indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove)
            logits[indices_to_remove] = -float('Inf')

        # 샘플링
        probs = F.softmax(logits, dim=-1)
        idx_next = torch.multinomial(probs, num_samples=1)
        idx = torch.cat((idx, idx_next), dim=1)

    return idx

설명

이것이 하는 일: 생성 함수는 초기 컨텍스트에서 시작하여 반복적으로 다음 토큰을 예측하고 추가하면서 원하는 길이의 텍스트를 만듭니다. 첫 번째로, 현재 컨텍스트로 모델에 forward pass를 수행합니다.

컨텍스트가 block_size보다 길면 최근 부분만 잘라서 사용합니다. 마지막 위치의 로짓만 가져오는데, 이것이 다음 토큰에 대한 예측이기 때문입니다.

Temperature로 나누면 분포가 변합니다. temperature=0.5는 차이를 증폭시켜 고확률 토큰이 더욱 지배적이 되고, temperature=2.0은 차이를 완화하여 저확률 토큰에도 기회를 줍니다.

그 다음으로, Top-k 필터링을 적용합니다. 상위 k개 토큰의 최소값을 찾고, 그보다 낮은 값들을 모두 -Inf로 설정합니다.

예를 들어 top_k=50이면 가장 가능성 높은 50개 토큰만 남기고 나머지는 제외합니다. 이렇게 하면 말도 안 되는 저확률 토큰이 선택될 위험을 줄입니다.

세 번째로, Top-p(nucleus) 필터링을 적용합니다. 확률이 높은 순으로 정렬하고, 누적 확률을 계산합니다.

누적 확률이 p(예: 0.9)를 넘으면 나머지는 제외합니다. Top-k와의 차이는 동적이라는 것입니다.

확률이 집중되어 있으면 적은 토큰만, 분산되어 있으면 많은 토큰을 고려합니다. 이것이 더 자연스러운 다양성을 제공합니다.

최종적으로 softmax로 확률 분포를 만들고 torch.multinomial로 샘플링합니다. 이는 확률에 비례하여 무작위로 선택하는 것입니다.

선택된 토큰을 기존 시퀀스에 추가하고, 이것이 다음 iteration의 컨텍스트가 됩니다. 이 과정을 max_new_tokens번 반복합니다.

여러분이 이 생성 전략을 사용하면 대화, 스토리 생성, 코드 완성 등 다양한 작업에서 자연스러운 결과를 얻을 수 있습니다. Temperature를 0.7, top_p를 0.9로 설정하는 것이 많은 경우에 좋은 출발점입니다.

작업에 따라 조정하면서 최적의 설정을 찾으세요.

실전 팁

💡 창의적 글쓰기에는 temperature=0.81.0, 사실 기반 작업에는 0.30.5가 적합합니다. 너무 높으면 일관성이 떨어지고, 너무 낮으면 반복이 많아집니다.

💡 Top-p는 Top-k보다 일반적으로 더 나은 결과를 냅니다. Top-k=50과 top_p=0.9를 함께 사용하면 둘의 장점을 모두 얻을 수 있습니다.

💡 Repetition penalty를 추가하면 같은 단어/구문의 반복을 줄일 수 있습니다. 이미 생성한 토큰의 확률을 페널티 계수로 나눕니다.

💡 Beam search는 greedy보다 나은 시퀀스를 찾지만 다양성이 떨어집니다. 번역이나 요약에 적합하고, 창의적 생성에는 sampling이 낫습니다.

💡 KV 캐싱을 구현하면 생성 속도가 10배 이상 빨라집니다. 이미 계산한 Key, Value는 재사용하고 새 토큰만 계산합니다.


9. 학습 루프 구현 - 효율적인 모델 훈련

시작하며

여러분이 모델 구조를 완성했다면, 이제 실제로 학습시킬 차례입니다. 하지만 단순히 데이터를 넣고 backward를 호출하는 것만으로는 부족합니다.

학습률 스케줄링, gradient clipping, 체크포인트 저장 등 많은 세부사항이 중요합니다. 대규모 언어 모델 학습은 시간과 자원이 많이 듭니다.

GPT-2 Small도 수 시간에서 수 일이 걸립니다. 학습 중 문제가 생겨 처음부터 다시 시작하면 엄청난 낭비입니다.

또한 학습률이나 배치 사이즈 같은 하이퍼파라미터가 성능에 큰 영향을 미칩니다. 바로 이럴 때 필요한 것이 체계적인 학습 루프입니다.

AdamW 옵티마이저, Cosine 학습률 스케줄러, Gradient Accumulation, Mixed Precision 등을 통합한 안정적인 학습 시스템을 구축합니다.

개요

간단히 말해서, 학습 루프는 데이터 로딩 → Forward pass → Loss 계산 → Backward pass → 가중치 업데이트를 반복하는 과정입니다. 여기에 다양한 최적화 기법을 추가합니다.

왜 복잡한 학습 파이프라인이 필요할까요? 기본적인 SGD만으로는 대규모 모델을 효과적으로 학습시킬 수 없습니다.

AdamW는 각 파라미터마다 적응적 학습률을 사용하여 더 빠르게 수렴합니다. Cosine 스케줄러는 학습 초기에는 큰 보폭으로, 후반에는 작은 보폭으로 조정하여 최적점에 안착합니다.

예를 들어, 초기 학습률 6e-4에서 시작하여 점차 줄이면 빠르게 학습하면서도 안정적으로 수렴합니다. Gradient Accumulation은 작은 GPU에서도 큰 배치 효과를 낼 수 있게 합니다.

실제 배치 사이즈가 8이어도 4번 누적하면 effective batch size 32의 효과를 얻습니다. Mixed Precision(FP16)은 메모리를 절반으로 줄이고 속도를 2배 높입니다.

학습 안정성을 위해 gradient clipping은 필수입니다. Gradient norm이 너무 크면 1.0으로 제한하여 폭발을 방지합니다.

또한 정기적으로 체크포인트를 저장하여 중단되어도 재개할 수 있게 합니다.

코드 예제

import torch
from torch.optim import AdamW
from torch.cuda.amp import autocast, GradScaler

def train(model, train_loader, config):
    # AdamW optimizer with weight decay
    optimizer = AdamW(model.parameters(), lr=config.learning_rate,
                      betas=(0.9, 0.95), weight_decay=0.1)
    # Mixed precision scaler
    scaler = GradScaler()
    # Cosine learning rate scheduler with warmup
    def get_lr(step):
        warmup_steps = 2000
        max_steps = 100000
        if step < warmup_steps:
            return config.learning_rate * step / warmup_steps
        if step > max_steps:
            return config.learning_rate * 0.1
        ratio = (step - warmup_steps) / (max_steps - warmup_steps)
        coeff = 0.5 * (1.0 + math.cos(math.pi * ratio))
        return config.learning_rate * 0.1 + coeff * (config.learning_rate - config.learning_rate * 0.1)

    model.train()
    for step, (x, y) in enumerate(train_loader):
        x, y = x.cuda(), y.cuda()
        # Mixed precision forward
        with autocast():
            logits, loss = model(x, y)
        # Backward with gradient scaling
        scaler.scale(loss).backward()
        # Gradient clipping
        scaler.unscale_(optimizer)
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        # Optimizer step
        scaler.step(optimizer)
        scaler.update()
        optimizer.zero_grad(set_to_none=True)
        # Learning rate scheduling
        lr = get_lr(step)
        for param_group in optimizer.param_groups:
            param_group['lr'] = lr

        if step % 100 == 0:
            print(f"Step {step}, Loss: {loss.item():.4f}, LR: {lr:.2e}")

설명

이것이 하는 일: 학습 루프는 데이터를 반복적으로 처리하면서 모델의 가중치를 최적화하고, 다양한 기법으로 학습을 안정화하고 가속화합니다. 첫 번째로, AdamW 옵티마이저를 설정합니다.

Adam은 각 파라미터의 1차, 2차 모멘트를 추적하여 적응적 학습률을 사용합니다. W는 weight decay를 올바르게 적용한다는 의미입니다.

beta1=0.9는 gradient의 이동 평균, beta2=0.95는 gradient 제곱의 이동 평균에 사용됩니다. weight_decay=0.1은 L2 정규화로 과적합을 방지합니다.

그 다음으로, 학습률 스케줄러를 구현합니다. Warmup 단계(첫 2000 step)에서는 0부터 목표 학습률까지 선형 증가시킵니다.

급격한 학습률로 시작하면 학습 초기에 불안정하기 때문입니다. 이후 Cosine annealing으로 점차 감소시킵니다.

코사인 함수의 부드러운 감소 곡선이 학습 후반의 미세 조정에 이상적입니다. 세 번째로, Mixed Precision을 사용합니다.

autocast() 컨텍스트 안에서는 자동으로 FP16 연산을 수행하여 메모리와 시간을 절약합니다. 하지만 FP16은 범위가 좁아 gradient가 underflow될 수 있습니다.

GradScaler가 loss를 큰 값으로 스케일링하여 이 문제를 해결합니다. Backward 후 다시 원래 스케일로 되돌립니다.

Gradient clipping 단계에서는 gradient norm을 계산하고 1.0을 초과하면 스케일을 줄입니다. 이것이 gradient exploding을 방지합니다.

특히 긴 시퀀스나 깊은 네트워크에서 필수적입니다. 그 다음 옵티마이저가 실제로 가중치를 업데이트하고, zero_grad로 다음 iteration을 준비합니다.

여러분이 이 학습 루프를 사용하면 수백만 개의 파라미터를 효율적으로 학습시킬 수 있고, GPU 메모리를 최대한 활용하며, 학습 중단 시에도 체크포인트에서 재개할 수 있습니다. GPT-2 Small은 단일 GPU에서 하루 정도면 괜찮은 성능을 얻을 수 있습니다.

실전 팁

💡 학습률은 가장 중요한 하이퍼파라미터입니다. 3e-4 ~ 6e-4에서 시작하여 실험하세요. 너무 크면 발산하고, 너무 작으면 학습이 느립니다.

💡 배치 사이즈는 가능한 한 크게 설정하세요. 작은 배치는 gradient 노이즈가 커서 학습이 불안정합니다. Gradient accumulation으로 effective batch size를 키우세요.

💡 Wandb나 TensorBoard로 loss, learning rate, gradient norm을 실시간 모니터링하세요. 문제를 조기에 발견할 수 있습니다.

💡 체크포인트는 자주 저장하되 공간을 고려하세요. 1000 step마다 저장하고, 최근 3개만 유지하는 것이 일반적입니다.

💡 Validation loss를 정기적으로 확인하여 과적합을 모니터링하세요. Train loss는 계속 줄어도 val loss가 증가하면 early stopping을 고려하세요.


10. GPT-2 Pre-trained 가중치 로딩 - 전이 학습 활용하기

시작하며

여러분이 처음부터 GPT를 학습시키려면 엄청난 데이터와 컴퓨팅 자원이 필요합니다. GPT-2는 40GB의 텍스트로 수 주간 학습되었습니다.

개인이나 작은 팀이 이를 재현하기는 현실적으로 어렵습니다. 다행히 OpenAI는 GPT-2의 사전 학습된 가중치를 공개했습니다.

이 가중치를 로딩하여 사용하면 즉시 강력한 언어 모델을 얻을 수 있습니다. 또는 특정 도메인 데이터로 fine-tuning하여 맞춤형 모델을 만들 수 있습니다.

바로 이럴 때 필요한 것이 사전 학습된 가중치 로딩입니다. HuggingFace의 transformers 라이브러리에서 가중치를 다운로드하고, 우리가 구현한 모델에 정확히 매핑합니다.

개요

간단히 말해서, 사전 학습 가중치 로딩은 OpenAI가 학습시킨 GPT-2의 파라미터를 우리 모델로 복사하는 과정입니다. 가중치 이름을 매칭하고 shape을 확인하여 정확히 이전합니다.

왜 가중치를 로딩해야 할까요? 언어의 기본 패턴(문법, 상식, 추론 능력)은 이미 GPT-2가 학습했습니다.

처음부터 다시 학습하는 것은 비효율적입니다. 사전 학습된 모델을 시작점으로 하면 적은 데이터로도 좋은 성능을 얻을 수 있습니다.

예를 들어, 의료 도메인 챗봇을 만든다면 일반 GPT-2에서 시작하여 의료 문서로 fine-tuning하는 것이 훨씬 효율적입니다. HuggingFace의 transformers 라이브러리는 GPT-2의 여러 버전(Small, Medium, Large, XL)을 제공합니다.

우리 코드는 구조가 같지만 변수 이름이 다를 수 있습니다. 따라서 체계적인 매핑이 필요합니다.

핵심은 state_dict를 올바르게 변환하는 것입니다. HuggingFace 모델의 각 파라미터를 우리 모델의 대응하는 위치에 복사합니다.

transpose가 필요한 레이어(Conv1D)도 있어 주의가 필요합니다. 모든 가중치가 정확히 매핑되었는지 확인하는 것이 중요합니다.

코드 예제

from transformers import GPT2LMHeadModel

def load_pretrained_gpt2(model, model_type='gpt2'):
    """
    HuggingFace에서 사전 학습된 GPT-2 가중치를 로딩합니다.
    model_type: 'gpt2', 'gpt2-medium', 'gpt2-large', 'gpt2-xl'
    """
    print(f"Loading pretrained {model_type} weights...")
    # HuggingFace 모델 로딩
    hf_model = GPT2LMHeadModel.from_pretrained(model_type)
    hf_state_dict = hf_model.state_dict()

    # State dict 변환 (HuggingFace -> 우리 모델)
    our_state_dict = model.state_dict()
    keys = [k for k in hf_state_dict.keys() if not k.endswith('.attn.masked_bias')]

    # 가중치 매핑 및 복사
    for k in keys:
        # HuggingFace의 이름 규칙을 우리 모델에 맞게 변환
        # 예: transformer.h.0.attn.c_attn.weight -> transformer.h.0.attn.c_attn.weight
        if 'c_attn' in k or 'c_proj' in k or 'c_fc' in k:
            # Conv1D는 transpose 필요
            our_state_dict[k].copy_(hf_state_dict[k].T)
        else:
            our_state_dict[k].copy_(hf_state_dict[k])

    model.load_state_dict(our_state_dict)
    print(f"Successfully loaded {len(keys)} parameters from {model_type}")
    return model

# 사용 예시
config = GPTConfig()
model = GPT(config)
model = load_pretrained_gpt2(model, 'gpt2')  # GPT-2 Small (124M)
model.eval()  # Inference 모드

설명

이것이 하는 일: 사전 학습 가중치 로딩 함수는 공개된 GPT-2 체크포인트를 다운로드하고, 가중치를 우리가 구현한 모델 구조에 정확히 매핑하여 복사합니다. 첫 번째로, HuggingFace의 from_pretrained 메서드로 GPT-2 모델과 가중치를 다운로드합니다.

'gpt2'는 124M 파라미터의 Small 버전이고, 'gpt2-medium'은 355M, 'gpt2-large'는 774M입니다. 이 모델들은 캐시 디렉토리에 다운로드되어 재사용됩니다.

state_dict()로 모든 파라미터의 딕셔너리를 얻습니다. 그 다음으로, 불필요한 키를 필터링합니다.

'attn.masked_bias'는 등록된 버퍼로 학습 파라미터가 아니므로 제외합니다. 우리 모델과 HuggingFace 모델의 구조가 거의 같지만, 일부 구현 디테일이 다를 수 있습니다.

세 번째로, 각 파라미터를 순회하며 복사합니다. 중요한 점은 HuggingFace가 Conv1D 레이어를 사용한다는 것입니다.

Conv1D는 내부적으로 가중치가 transpose되어 저장되므로, 우리의 Linear 레이어로 복사할 때 .T로 다시 transpose해야 합니다. c_attn, c_proj, c_fc가 이에 해당합니다.

나머지 파라미터(LayerNorm, Embedding 등)는 그대로 복사합니다. 최종적으로 load_state_dict로 변환된 가중치를 모델에 적용합니다.

모든 파라미터가 정확히 매칭되면 모델은 즉시 GPT-2의 능력을 갖게 됩니다. "The quick brown fox"를 입력하면 자연스러운 문장 완성을 생성할 수 있습니다.

여러분이 이 가중치 로딩을 사용하면 수 주간의 학습을 건너뛰고 즉시 강력한 모델을 얻을 수 있고, 특정 도메인으로 fine-tuning하여 맞춤형 모델을 만들 수 있으며, 모델 구현이 정확한지 검증할 수 있습니다. 같은 입력에 대해 HuggingFace 모델과 동일한 출력이 나오면 구현이 정확하다는 증거입니다.

실전 팁

💡 가중치를 로딩한 후 몇 가지 테스트 입력으로 생성을 확인하세요. HuggingFace 모델과 출력을 비교하면 구현 오류를 찾을 수 있습니다.

💡 Fine-tuning 시에는 학습률을 사전 학습보다 낮게(1e-5 ~ 5e-5) 설정하세요. 이미 좋은 가중치를 가지고 있으므로 조금만 조정합니다.

💡 특정 레이어만 fine-tuning하고 나머지는 freeze할 수도 있습니다. 상위 레이어만 학습시키면 빠르고 과적합을 방지합니다.

💡 LoRA나 Adapter 같은 파라미터 효율적 fine-tuning 기법을 사용하면 전체 모델을 학습시키지 않고도 좋은 성능을 얻을 수 있습니다.

💡 모델 크기가 클수록 성능이 좋지만 추론 속도는 느립니다. 작업의 요구사항에 맞는 크기를 선택하세요. 대부분의 경우 gpt2-medium이 좋은 균형점입니다.


#Python#GPT#Transformer#NeuralNetwork#DeepLearning#AI

댓글 (0)

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