🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

A

AI Assistant

2026. 3. 31. · 0 Views

GPT 모델 아키텍처 완벽 분석 - CausalSelfAttention부터 GPT까지

AutoResearch의 train.py에 구현된 GPT 모델 아키텍처를 상세 분석합니다. GPTConfig 데이터클래스부터 CausalSelfAttention, MLP, Block, GPT 클래스까지 전체 구조와 가중치 초기화 전략을 다룹니다.


목차

  1. GPTConfig 데이터클래스 설계
  2. CausalSelfAttention 다중 헤드 어텐션 구현
  3. Grouped Query Attention GQA 지원
  4. MLP Squared ReLU 활성화 함수
  5. Block Pre-Norm과 Residual Connection
  6. GPT 클래스 전체 모델 조립
  7. 가중치 초기화 전략

1. GPTConfig 데이터클래스 설계

김개발 씨는 AutoResearch 프로젝트의 코드를 처음 열어보고 가장 먼저 눈에 들어온 것은 GPTConfig라는 데이터클래스였습니다. "이게 모델의 전체 설계도인가요?" 박시니어 씨가 옆에서 고개를 끄덕였습니다.

GPTConfig는 GPT 모델의 하이퍼파라미터를 한 곳에 모아둔 데이터클래스입니다. 마치 건축 설계도에 건물의 크기, 층수, 재질을 미리 정해두는 것과 같습니다. 이 하나의 클래스가 모델의 규모와 구조를 결정합니다.

@dataclass
class GPTConfig:
    vocab_size: int = 50304     # 어휘 사전 크기
    n_layer: int = 12           # Transformer 블록 수
    n_head: int = 12            # 어텐션 헤드 수
    n_embd: int = 768           # 임베딩 차원
    block_size: int = 1024      # 컨텍스트 윈도우 크기
    n_kv_head: int = None       # GQA용 KV 헤드 수 (None이면 n_head와 동일)

GPTConfig는 모델의 모든 하이퍼파라미터를 정의하는 데이터클래스입니다. vocab_size부터 block_size까지 모델의 규모와 구조를 한눈에 파악할 수 있습니다.

상세 설명

"AutoResearch 완전 분석 - AI 자율 연구 에이전트" 코스의 두 번째 시간입니다. 이전 카드뉴스에서는 AutoResearch 프로젝트의 전체 아키텍처와 실험 루프 구조를 살펴보았습니다. 이번에는 그 핵심에 자리 잡은 GPT 모델 자체를 해부해 보겠습니다. 김개발 씨는 프로젝트 레포지토리를 클론하고 가장 먼저 연 파일은 train.py였습니다. 수백 줄의 코드가 펼쳐졌지만, 맨 위에 있는 GPTConfig가 유독 눈길을 끌었습니다. "선배님, 이 GPTConfig가 뭔가요? 그냥 변수 모음인 것 같은데 굳이 클래스로 만든 이유가 있나요?" 박시니어 씨가 모니터를 가리키며 설명을 시작했습니다. "좋은 질문이에요. GPTConfig는 이 모델의 DNA라고 할 수 있어요. 모델이 몇 층으로 이루어져 있는지, 어텐션 헤드가 몇 개인지, 문맥을 얼마나 길게 기억하는지, 모든 결정이 여기서 내려집니다." 쉽게 비유하자면, GPTConfig는 자동차의 **명판(銘板)**과 같습니다. 엔진 배기량, 축 수, 좌석 수가 명판에 적혀 있듯이, GPT 모델의 모든 구조적 결정이 이 클래스에 담겨 있습니다. 명판을 보면 그 차가 세단인지 SUV인지 알 수 있듯, GPTConfig를 보면 모델의 규모가 어느 정도인지 즉시 파악할 수 있습니다. 이전에는 이런 파라미터들을 여기저기 흩어놓는 경우가 많았습니다. 코드가 길어질수록 어디서 어떤 값을 사용했는지 추적하기 어려워졌습니다. config 객체 하나로 묶어두면 모델 생성, 실험 로깅, 체크포인트 저장 모두에서 동일한 설정을 일관되게 참조할 수 있습니다. 코드를 자세히 보겠습니다. 가장 먼저 vocab_size는 모델이 인식하는 토큰의 총 개수입니다. 50304라는 값은 GPT-2가 사용한 BPE 토크나이저의 어휘 크기입니다. n_layer는 Transformer 블록의 층수로, 이 값이 클수록 모델이 더 복잡한 패턴을 학습할 수 있습니다. n_headn_embd의 관계도 중요합니다. n_embd는 전체 임베딩 차원이고, 이를 n_head로 나누면 각 헤드당 차원이 됩니다. 예를 들어 768을 12로 나누면 64, 즉 각 어텐션 헤드가 64차원에서 작업하게 됩니다. 이 비율이 모델의 표현력에 직접적인 영향을 미칩니다. block_size컨텍스트 윈도우의 크기입니다. 모델이 한 번에 처리할 수 있는 최대 토큰 수를 의미합니다. 1024면 약 750단어 정도의 영문 텍스트를 한 번에 볼 수 있습니다. 이 값이 클수록 더 긴 문맥을 이해할 수 있지만, 메모리 사용량은 제곱에 비례해서 증가합니다. 특히 흥미로운 필드는 n_kv_head입니다. 이것이 None이면 기본 Multi-Head Attention을 사용하고, n_head보다 작은 값이면 **Grouped Query Attention(GQA)**을 사용합니다. GQA는 메모리를 절약하면서도 성능을 유지하는 현대적인 최적화 기법입니다. 이에 대해서는 다음 카드에서 자세히 다루겠습니다. AutoResearch에서는 이 GPTConfig를 통해 다양한 크기의 모델을 유연하게 정의합니다. 작은 모델로 빠르게 실험하고, 좋은 결과가 나오면 크기를 키워서 재실험하는 식의 하이퍼파라미터 서치가 가능해집니다. "아하, 그러니까 모델의 '설계도'를 클래스로 깔끔하게 정리한 거군요!" 김개발 씨가 이해한 듯 미소를 지었습니다.

  • GPTConfig의 기본값은 GPT-2 small(124M)의 구성과 일치합니다. 참고용으로 기억해 두면 좋습니다.
  • n_embd가 n_head로 나누어 떨어지는지 항상 확인하세요. 그렇지 않으면 어텐션 연산에서 에러가 발생합니다.
  • AutoResearch에서는 block_size를 시스템 메모리에 맞춰 조절하며, 5분 타임 버짓 내에 학습이 가능한 크기로 자동 설정합니다.
  • 이 카드뉴스는 "AutoResearch 완전 분석 - AI 자율 연구 에이전트" 코스의 2/8편입니다.

2. CausalSelfAttention 다중 헤드 어텐션 구현

김개발 씨가 Transformer 블록의 첫 번째 레이어인 CausalSelfAttention을 열어보았습니다. "어텐션은 교과서에서 여러 번 봤는데, 실제 구현은 처음이에요." 박시니어 씨가 옆에서 코드를 같이 보며 설명을 시작했습니다.

CausalSelfAttention은 GPT 모델의 핵심 메커니즘으로, 과거 토큰에만 주목하도록 제약을 건 인과적 어텐션입니다. 마치 책을 앞에서부터 순서대로 읽으며 이해하는 것처럼, 미래 정보를 염탐하지 못하게 마스킹합니다.

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.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()
        qkv = self.c_attn(x).reshape(B, T, 3, self.n_head, C // self.n_head)
        q, k, v = qkv.permute(2, 0, 3, 1, 4)  # (3, B, nh, T, hs)
        att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
        att = att.masked_fill(self.bias[:, :, :T, :T] == 0, float('-inf'))
        att = F.softmax(att, dim=-1)
        y = att @ v
        y = y.transpose(1, 2).contiguous().view(B, T, C)
        return self.c_proj(y)

CausalSelfAttention은 Q, K, V를 하나의 선형 변환으로 생성하고, 하삼각 마스크로 미래 토큰을 차단하여 인과성을 보장합니다. 이것이 GPT가 순차적으로 텍스트를 생성할 수 있는 핵심 원리입니다.

상세 설명

GPTConfig를 통해 모델의 뼈대를 정의했다면, 이제 그 안에 들어갈 실제 연산 레이어들을 살펴볼 차례입니다. Transformer 아키텍처에서 가장 중요한 부분이 바로 Self-Attention입니다. "선배님, Causal이라는 접두사가 붙은 이유가 뭔가요? 그냥 Self-Attention이면 안 되나요?" 박시니어 씨가 화이트보드에 간단한 다이어그램을 그렸습니다. "이 차이가 GPT와 BERT를 가르는 결정적인 차이예요. BERT는 양방향으로 문맥을 파악하지만, GPT는 과거에서 현재로만 정보를 흘려보냅니다. 그래야 다음 단어를 '예측'할 수 있으니까요." 비유하자면, CausalSelfAttention은 연속 재생되는 오디오북과 같습니다. 독자가 뒤의 페이지를 미리 볼 수 없듯이, 모델도 아직 생성하지 않은 미래의 토큰에서 정보를 가져올 수 없습니다. 이것이 GPT가 **자기회귀적(Autoregressive)**으로 텍스트를 생성할 수 있는 이유입니다. 코드를 단계별로 분석해 보겠습니다. 먼저 c_attn은 하나의 선형 레이어에서 Q, K, V 세 개의 행렬을 한 번에 생성합니다. 3 * config.n_embd 출력 차원을 가진 이유가 바로 그것입니다. 세 개의 개별 선형 레이어를 사용하는 것보다 메모리 접근이 효율적이고, GPU 커널 최적화에도 유리합니다. register_buffer로 등록된 bias는 **하삼각 마스크(Triangular Mask)**입니다. torch.tril 함수로 생성된 이 마스크는 정사각 행렬의 아래쪽 삼각형만 1이고, 위쪽은 모두 0입니다. forward 연산에서 이 0 위치를 -inf로 채우면, softmax를 통과한 뒤 해당 위치의 가중치가 0이 되어 미래 정보가 전달되지 않습니다. forward 메서드의 핵심 흐름을 따라가 보겠습니다. qkv 텐서를 생성한 후, reshapepermute로 차원을 재배열합니다. 이 과정에서 (B, T, C) 형태의 입력이 (3, B, nh, T, hs) 형태로 변환됩니다. 여기서 nh는 헤드 수, hs는 헤드당 차원입니다. 어텐션 스코어를 계산할 때 q @ k.transpose(-2, -1)내적을 수행합니다. 이것이 각 토큰이 다른 모든 토큰과 얼마나 관련이 있는지를 측정하는 핵심 연산입니다. 그 뒤에 오는 1.0 / math.sqrt(k.size(-1))스케일링 팩터로, 차원이 커질수록 어텐션 분포가 날카로워지는 것을 방지합니다. 마스킹, softmax, 그리고 V와의 행렬 곱셈을 거치면 최종 어텐션 출력이 나옵니다. 마지막으로 c_proj 선형 변환을 통해 출력을 원래의 임베딩 차원으로 돌려놓습니다. 실제 현업에서는 Flash Attention 같은 최적화된 커널을 사용하여 이 연산의 메모리 사용량을 크게 줄입니다. AutoResearch에서도 Flash Attention 3을 지원하며, 이를 통해 더 긴 컨텍스트를 처리할 수 있습니다. "어텐션 수식은 교과서에서 봤지만, 이렇게 구현을 보니 훨씬 이해가 잘 되네요." 김개발 씨가 코드를 다시 한번 훑어보며 말했습니다.

  • c_attn에서 Q, K, V를 하나의 레이어로 합치는 것은 fused 연산으로, GPU에서 약 30% 정도 성능 향상을 얻을 수 있습니다.
  • 스케일링 팩터 1/sqrt(d_k)를 빼먹으면 모델 학습이 불안정해집니다. 이것은 Attention Is All You Need 논문에서 증명된 필수 요소입니다.
  • 마스크의 크기는 block_size에 맞춰져 있으므로, 실제 시퀀스 길이 T에 맞게 슬라이싱(:T, :T)하여 사용합니다.
  • 이 카드뉴스는 "AutoResearch 완전 분석 - AI 자율 연구 에이전트" 코스의 2/8편입니다.

3. Grouped Query Attention GQA 지원

박시니어 씨가 CausalSelfAttention 코드의 한 줄을 가리키며 말했습니다. "여기 봐요, n_kv_head라는 필드가 있죠? 이게 바로 Grouped Query Attention을 위한 장치예요." 김개발 씨는 고개를 갸웃했습니다. "GQA? 그건 또 뭔가요?"

**Grouped Query Attention(GQA)**은 여러 Query 헤드가 Key, Value 헤드를 공유하는 기법입니다. 마치 여러 학생이 한 명의 튜터에게 질문하는 것처럼, 메모리 사용량을 크게 줄이면서도 모델 성능을 유지합니다.

# GQA 지원을 위한 Q, K, V 분리 처리
n_head = config.n_head
n_kv_head = config.n_kv_head if config.n_kv_head is not None else config.n_head
n_rep = n_head // n_kv_head  # Q 헤드당 공유하는 KV 헤드 수

# Q는 전체 헤드 수, K/V는 kv_head 수만큼만 생성
self.q_proj = nn.Linear(config.n_embd, n_head * head_size, bias=config.bias)
self.kv_proj = nn.Linear(config.n_embd, 2 * n_kv_head * head_size, bias=config.bias)

# KV 헤드를 Q 헤드 수만큼 복제 (repeat_interleave)
def repeat_kv(x, n_rep):
    B, n_kv_head, T, head_size = x.shape
    if n_rep == 1:
        return x
    return (x[:, :, None, :, :].expand(B, n_kv_head, n_rep, T, head_size)
              .reshape(B, n_kv_head * n_rep, T, head_size))

GQA는 n_head개의 Query 헤드가 n_kv_head개의 Key/Value 헤드를 공유하여 KV 캐시 메모리를 n_head/n_kv_head 비율로 줄입니다. 추론 시 메모리 병목을 해결하는 핵심 기법입니다.

상세 설명

"AutoResearch 완전 분석 - AI 자율 연구 에이전트" 코스에서 다루는 GPT 모델은 단순한 구현이 아닙니다. 최신 연구 결과를 반영한 **Grouped Query Attention(GQA)**을 지원합니다. GQA의 필요성을 이해하려면, 먼저 Multi-Head Attention(MHA)의 문제를 알아야 합니다. MHA에서는 각 어텐션 헤드가 자신만의 K, V 쌍을 가집니다. 헤드가 32개면 K 캐시와 V 캐시도 각각 32개가 필요합니다. 추론(Inference) 시 이 캐시는 모델이 이전 토큰을 기억하기 위해 GPU 메모리에 계속 상주해야 합니다. 쉽게 비유하자면, MHA는 32명의 학생이 각자 전용 튜터를 두는 것과 같습니다. 학생마다 튜터가 따로 있으면 교육의 질은 높지만, 비용이 어마어마합니다. 반면 GQA는 32명의 학생이 4명의 튜터를 공유하는 방식입니다. 8:1 비율로 튜터를 공유해도 학습 효과는 크게 떨어지지 않는다는 것이 여러 연구에서 입증되었습니다. GQA가 빛을 발하는 곳은 바로 추론 시의 KV 캐시 메모리입니다. n_head가 32이고 n_kv_head가 4라면, KV 캐시의 크기는 MHA 대비 8분의 1로 줄어듭니다. 긴 컨텍스트를 처리할 때 이 메모리 절감 효과는 매우 큽니다. 배치 사이즈를 늘리거나 컨텍스트 윈도우를 확장할 수 있는 여유가 생깁니다. 코드에서 핵심은 n_rep = n_head // n_kv_head 계산과 repeat_kv 함수입니다. Q는 전체 헤드 수만큼 생성하지만, K와 V는 n_kv_head 수만큼만 생성합니다. 그리고 어텐션 스코어를 계산하기 전에 repeat_kv로 K, V를 Q 헤드 수에 맞게 복제합니다. 이 복제 과정이 일반적인 MHA와의 차이점입니다. MHA에서는 모든 헤드가 독립적인 K, V를 가지므로 복제가 필요 없습니다. 반면 GQA에서는 여러 Q 헤드가 동일한 K, V를 참조하므로, 행렬 연산의 차원을 맞추기 위해 명시적으로 복제해야 합니다. AutoResearch에서는 GQA 지원을 통해 단일 GPU 환경에서도 더 큰 모델을 실험할 수 있습니다. 5분 타임 버짓이라는 엄격한 시간 제약 안에서, 메모리 효율은 곧 실험 가능성과 직결됩니다. "그러니까 GQA는 성능은 비슷하게 유지하면서 메모리만 대폭 줄이는 기술이군요!" 김개발 씨가 눈을 반짝였습니다.

  • n_kv_head를 n_head와 같게 설정하면 GQA는 일반 MHA와 완전히 동일하게 동작합니다. 하위 호환성이 보장됩니다.
  • GQA의 KV 헤드 수는 일반적으로 n_head의 1/4이나 1/8로 설정합니다. LLaMA 2는 1/8, Mistral은 1/4를 사용합니다.
  • AutoResearch의 작은 모델(예: n_head=12)에서는 GQA의 효과가 미미하므로, 큰 모델에서 주로 활용됩니다.
  • 이 카드뉴스는 "AutoResearch 완전 분석 - AI 자율 연구 에이전트" 코스의 2/8편입니다.

4. MLP Squared ReLU 활성화 함수

어텐션 레이어를 지나 김개발 씨는 두 번째 서브레이어인 MLP 클래스를 만났습니다. "ReLU 대신 Squared ReLU를 사용하네요? 무슨 차이가 있는 건가요?" 박시니어 씨가 의미심장한 미소를 지었습니다.

**MLP(Multi-Layer Perceptron)**은 어텐션이 수집한 정보를 비선형 변환으로 처리하는 레이어입니다. 여기서 사용되는 Squared ReLU는 기존 ReLU의 제곱으로, 0 이상의 값은 더 크게, 0 미만은 여전히 0으로 처리하여 모델의 표현력을 높입니다.

class MLP(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
        self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias)
        self.gelu = nn.GELU()  # 또는 Squared ReLU 사용

    def forward(self, x):
        x = self.c_fc(x)
        x = self.gelu(x)       # 비선형 활성화
        x = self.c_proj(x)     # 원래 차원으로 축소
        return x

# Squared ReLU는 다음과 같이 구현됩니다:
# def squared_relu(x):
#     return torch.square(F.relu(x))

MLP는 어텐션 출력을 4배 확장했다가 다시 원래 크기로 압축하며, Squared ReLU 비선형 활성화로 복잡한 패턴을 학습합니다. 이 "확장-축소" 구조는 Transformer의 표준 설계 패턴입니다.

상세 설명

Transformer 블록은 어텐션과 MLP 두 개의 서브레이어로 구성됩니다. 어텐션이 "어디에 주목할지"를 결정한다면, MLP는 "주목한 정보를 어떻게 변환할지"를 담당합니다. 이 둘은 서로 보완적으로 작동합니다. "어텐션만으로는 부족한 건가요? 왜 MLP가 또 필요한 거죠?" 박시니어 씨가 설명했습니다. "어텐션은 선형 연산이에요. 토큰 간의 관계를 '가중치 합'으로 계산하죠. 하지만 현실의 언어 패턴은 선형적이지 않아요. '좋다'와 '좋지 않다'는 단 한 단어 차이지만, 의미는 정반대입니다. 이런 비선형 패턴을 잡아내는 것이 MLP의 역할입니다." 비유하자면, 어텐션은 수집가이고 MLP는 해석가입니다. 수집가가 세상의 정보를 수집해 오면, 해석가가 그것을 분석하고 조합하여 새로운 의미를 만들어냅니다. 수집가 혼자서는 정보를 모을 수 있지만, 깊은 의미를 파악하려면 해석가의 비선형적 사고가 필요합니다. MLP의 구조를 보면 특이한 점이 있습니다. c_fc에서 4 * config.n_embd4배 확장했다가, c_proj에서 다시 원래 크기로 압축합니다. 이 확장-축소 패턴은 Transformer의 표준 설계로, 고차원 공간에서 더 풍부한 표현을 학습하기 위한 것입니다. AutoResearch에서 특히 주목할 점은 활성화 함수의 선택입니다. 전통적인 GPT는 **GELU(Gaussian Error Linear Unit)**를 사용하지만, 최근 연구에서는 Squared ReLU가 더 나은 성능을 보인다는 결과가 발표되었습니다. Squared ReLU는 max(0, x)^2로 정의되며, 양수 구간에서 값이 제곱으로 커지므로 미분값도 더 커집니다. 이것은 **기울기 흐름(Gradient Flow)**을 개선하는 효과가 있습니다. 왜 기울기 흐름이 중요할까요? 딥러닝 모델은 역전파(Backpropagation)로 학습합니다. 출력에서 계산된 기울기가 입력 쪽으로 전달되어야 가중치가 업데이트됩니다. 층이 깊어질수록 기울기가 점점 작아지는 기울기 소실(Vanishing Gradient) 문제가 발생할 수 있는데, Squared ReLU는 이를 완화하는 데 도움이 됩니다. 또한 Squared ReLU는 GELU에 비해 계산 비용이 낮습니다. GELU는 가우시안 CDF를 근사해야 하므로 복잡한 수학 연산이 필요하지만, Squared ReLU는 단순히 "음수를 0으로, 양수를 제곱"하는 연산입니다. 이 작은 차이가 큰 모델에서는 누적되어 의미 있는 속도 향상을 가져옵니다. AutoResearch에서는 5분이라는 짧은 시간 안에 모델을 학습해야 하므로, 이런 미세한 최적화가 실험의 성패에 영향을 미칠 수 있습니다. "활성화 함수 하나 바꾸는 게 이렇게 큰 차이를 만들 수 있군요." 김개발 씨가 놀란 표정이었습니다.

  • MLP의 은닉층 차원을 4배로 확장하는 것은 원래 Transformer 논문의 설계로, 실험적으로 최적의 비율로 검증되었습니다.
  • Squared ReLU는 값이 크게 커질 수 있으므로, 필요에 따라 출력 스케일링(예: 1/sqrt 확장 비율)을 적용할 수 있습니다.
  • AutoResearch에서는 실험별로 GELU와 Squared ReLU를 비교하여 최적의 활성화 함수를 탐색합니다.
  • 이 카드뉴스는 "AutoResearch 완전 분석 - AI 자율 연구 에이전트" 코스의 2/8편입니다.

5. Block Pre-Norm과 Residual Connection

CausalSelfAttention과 MLP를 각각 보았으니, 이제 이 둘을 합치는 Block 클래스를 살펴볼 차례입니다. 김개발 씨가 Block의 forward 메서드를 보며 감탄했습니다. "이렇게 깔끔하게 연결할 수 있군요."

Block은 하나의 Transformer 블록으로, LayerNorm(Pre-Norm) -> CausalSelfAttention -> Residual -> LayerNorm(Pre-Norm) -> MLP -> Residual의 구조를 가집니다. 마치 레고 블록을 쌓듯, 이 Block을 n_layer번 반복하여 깊은 네트워크를 구성합니다.

class Block(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.ln_1 = nn.LayerNorm(config.n_embd)
        self.attn = CausalSelfAttention(config)
        self.ln_2 = nn.LayerNorm(config.n_embd)
        self.mlp = MLP(config)

    def forward(self, x):
        x = x + self.attn(self.ln_1(x))  # Pre-Norm + Residual
        x = x + self.mlp(self.ln_2(x))   # Pre-Norm + Residual
        return x

Block은 Pre-Norm 방식으로 LayerNorm을 어텐션과 MLP 앞에 두고, Residual Connection으로 입력을 직접 더해줍니다. 이 구조는 깊은 층에서도 안정적인 학습을 가능하게 합니다.

상세 설명

Transformer 블록을 이해하려면 두 가지 핵심 개념을 알아야 합니다. Pre-NormResidual Connection입니다. 이 둘이 결합되어야 깊은 네트워크가 안정적으로 학습할 수 있습니다. "선배님, 왜 LayerNorm을 어텐션 뒤가 아니라 앞에 두는 건가요?" 좋은 질문입니다. 초기 Transformer 논문에서는 Post-Norm 방식을 사용했습니다. 어텐션 -> LayerNorm -> Residual 순서입니다. 하지만 이 방식은 층이 깊어질수록 학습이 불안정해지는 문제가 있었습니다. GPT-2부터 도입된 Pre-Norm은 LayerNorm을 어텐션 앞에 배치합니다. LayerNorm -> 어텐션 -> Residual 순서죠. 비유하자면, Post-Norm은 경기 후에 몸 풀기를 하는 것이고, Pre-Norm은 경기 전에 워밍업을 하는 것입니다. 당연히 경기 전에 준비 운동을 하는 것이 부상 위험을 줄이고 더 나은 성능을 발휘할 수 있죠. Pre-Norm도 마찬가지로, 어텐션 연산이 들어가기 전에 입력을 정규화하여 안정적인 연산을 보장합니다. Residual Connection은 코드의 x = x + self.attn(...) 부분입니다. 어텐션의 출력을 별도의 경로로 처리한 뒤, 원래 입력 x더합니다. 이것은 정보가 층을 건너뛰어 직접 흐를 수 있는 **지름길(Shortcut)**을 만듭니다. 왜 지름길이 필요할까요? 12층짜리 네트워크를 생각해 보세요. 입력이 12번의 변환을 거쳐 출력에 도달합니다. 만약 중간 층의 변환이 정보를 손상시킨다면, 출력 쪽에서는 원래 정보를 복원하기 어렵습니다. Residual Connection이 있으면 원래 입력이 지름길을 통해 직접 전달되므로, 정보 손실의 위험이 크게 줄어듭니다. 수학적으로 Residual Connection은 **항등 함수(Identity Function)**를 학습하기 쉽게 만듭니다. 만약 어텐션 레이어가 유용하지 않다면, 가중치가 0에 가까워져서 x + 0 = x로 수렴할 수 있습니다. 즉, 레이어를 "스킵"하는 것이 학습 가능합니다. 이것이 깊은 네트워크에서도 학습이 잘 되는 비결입니다. AutoResearch에서는 이 Block을 n_layer번 반복하여 전체 Transformer를 구성합니다. GPTConfig에서 n_layer=12로 설정하면, 이 Block이 12번 쌓여서 12층짜리 GPT 모델이 됩니다. 더 큰 모델에서는 24, 36, 48층까지도 사용합니다. "Block 하나하나는 간단한데, 이걸 12번 쌓으면 엄청나게 복잡한 패턴을 학습할 수 있게 되는 거군요." 김개발 씨가 감탄했습니다.

  • Pre-Norm은 Post-Norm에 비해 학습 초기의 안정성이 훨씬 높습니다. 실무에서는 거의 항상 Pre-Norm을 사용합니다.
  • Residual Connection이 없으면 12층 이상의 네트워크에서 기울기 소실 문제가 심각해집니다.
  • Block의 두 번째 ln_2 다음에 dropout을 추가할 수 있지만, AutoResearch에서는 단일 GPU 환경에서의 효율을 위해 생략된 경우가 많습니다.
  • 이 카드뉴스는 "AutoResearch 완전 분석 - AI 자율 연구 에이전트" 코스의 2/8편입니다.

6. GPT 클래스 전체 모델 조립

모든 부품을 살펴보았으니, 이제 마지막 퍼즐 조각인 GPT 클래스를 보겠습니다. 김개발 씨가 GPT 클래스의 __init__을 보며 감탄했습니다. "이게 바로 모델의 완성체군요."

GPT 클래스는 Token Embedding, Position Embedding, n_layer개의 Block, 최종 LayerNorm, 그리고 출력 Linear 레이어를 순차적으로 조립하여 완전한 GPT 모델을 구성합니다. 마치 자동차 공장에서 엔진, 차체, 바퀴를 하나씩 조립하는 것과 같습니다.

class GPT(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.transformer = nn.ModuleDict(dict(
            wte = nn.Embedding(config.vocab_size, config.n_embd),       # 토큰 임베딩
            wpe = nn.Embedding(config.block_size, config.n_embd),      # 위치 임베딩
            h = nn.ModuleList([Block(config) for _ in range(config.n_layer)]),  # 블록 스택
            ln_f = nn.LayerNorm(config.n_embd),                        # 최종 레이어 노름
        ))
        self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)

    def forward(self, idx, targets=None):
        B, T = idx.size()
        tok_emb = self.transformer.wte(idx)        # (B, T, n_embd)
        pos_emb = self.transformer.wpe(torch.arange(T))  # (T, n_embd)
        x = tok_emb + pos_emb                       # 토큰 + 위치 임베딩
        for block in self.transformer.h:
            x = block(x)                            # 블록 통과
        x = self.transformer.ln_f(x)                # 최종 정규화
        logits = self.lm_head(x)                    # 어휘 사전 크기의 로짓
        return logits

GPT 클래스는 토큰 임베딩과 위치 임베딩을 더한 후 n_layer개의 Block을 통과시키고, 최종 LayerNorm과 Linear로 다음 토큰 확률을 출력합니다. 이것이 GPT 모델의 전체 데이터 흐름입니다.

상세 설명

지금까지 살펴본 GPTConfig, CausalSelfAttention, MLP, Block이 모두 이 GPT 클래스 안에서 하나의 완성된 모델로 조립됩니다. 마치 레고 블록의 개별 조각들이 하나의 완성품으로 변하는 순간입니다. "드디어 전체 그림이 보이네요! 각 부품이 어떻게 연결되는지 이해가 됩니다." GPT 클래스의 구조를 크게 세 부분으로 나눌 수 있습니다. 입력 처리, 블록 스택, 출력 처리입니다. 입력 처리에서는 두 가지 임베딩이 사용됩니다. wteWord Token Embedding으로, 각 토큰을 고차원 벡터로 변환합니다. 예를 들어 "안녕"이라는 토큰이 768차원의 벡터로 매핑됩니다. wpeWord Position Embedding으로, 토큰의 순서 정보를 더해줍니다. "나는 사과를 좋아한다"와 "사과는 나를 좋아한다"는 같은 단어로 구성되어 있지만 순서가 다릅니다. Position Embedding이 이 순서 차이를 모델에 전달합니다. 비유하자면, Token Embedding은 단어의 의미를 담당하고, Position Embedding은 단어의 자리를 담당합니다. 의미만 알고 자리를 모르면 문장을 이해할 수 없고, 자리만 알고 의미를 모르면 마찬가지입니다. 두 임베딩을 더함으로써 비로소 "어떤 단어가 어떤 위치에 있는지"를 완전히 표현할 수 있습니다. 블록 스택은 nn.ModuleList로 구현됩니다. config.n_layer번 Block이 순차적으로 쌓입니다. 각 Block은 이전 Block의 출력을 받아 어텐션과 MLP를 거친 뒤 다음 Block으로 전달합니다. 층이 거듭될수록 모델은 더 추상적이고 복잡한 패턴을 학습합니다. 초기 층에서는 단어의 문법적 특성을, 중간 층에서는 문장 구조를, 상위 층에서는 의미론적 관계를 파악하는 것으로 알려져 있습니다. 최종 LayerNorm ln_f는 모든 블록을 거친 출력을 정규화합니다. 여러 층의 변환을 거치면서 값의 스케일이 불안정해질 수 있으므로, 마지막에 한 번 더 정규화하여 안정적인 로짓을 보장합니다. lm_headLanguage Model Head로, 최종 출력을 어휘 사전의 크기에 맞춥니다. 출력값인 **로짓(Logit)**은 각 토큰이 다음에 올 확률을 나타냅니다. 이 로짓에 softmax를 적용하면 확률 분포가 되고, 가장 높은 확률의 토큰을 선택하면 모델의 "예측"이 됩니다. 한 가지 흥미로운 최적화는 lm_head의 가중치를 wte와 **공유(Weight Tying)**할 수 있다는 점입니다. 출력 레이어의 가중치를 입력 임베딩과 공유하면 파라미터 수가 줄어들고, 학습 안정성도 향상됩니다. AutoResearch에서는 이 최적화도 지원합니다. "이렇게 보니 GPT가 그렇게 복잡한 것만은 아니네요. 각 부품의 역할이 명확하니까 이해하기 쉽습니다." 김개발 씨가 만족스러운 표정을 지었습니다.

  • Weight Tying(lm_head와 wte 공유)은 파라미터를 약 15-20% 줄여주며, 작은 모델에서 특히 효과적입니다.
  • AutoResearch에서는 forward에 targets를 전달하면 학습용 손실도 함께 계산하여 실험 루프에서 바로 사용할 수 있습니다.
  • Position Embedding은 고정 크기(block_size)이므로, 학습 시보다 긴 시퀀스를 처리하려면 추가 처리가 필요합니다.
  • 이 카드뉴스는 "AutoResearch 완전 분석 - AI 자율 연구 에이전트" 코스의 2/8편입니다.

7. 가중치 초기화 전략

마지막으로 김개발 씨가 GPT 클래스의 끝부분에 있는 init_weights 메서드를 발견했습니다. "가중치 초기화라고요? 랜덤하게 설정하면 안 되나요?" 박시니어 씨가 진지한 얼굴로 고개를 저었습니다. "아니요, 초기화가 학습 성공의 반입니다."

**가중치 초기화(Weight Initialization)**는 학습 시작 전 가중치를 적절한 값으로 설정하는 전략입니다. Xavier, He, 그리고 AutoResearch에서 사용하는 N(0, 0.02) 정규 분포와 **잔차 스케일링(Residual Scaling)**은 깊은 네트워크에서 기울기 흐름을 안정화하는 핵심 기법입니다.

def init_weights(self):
    # 기본 선형 레이어: 정규 분포 초기화
    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)

# 잔차 스케일링: 깊은 층의 기울기 흐름 안정화
for block in self.transformer.h:
    nn.init.normal_(block.ln_1.weight, std=1.0)
    nn.init.zeros_(block.ln_1.bias)
    nn.init.normal_(block.ln_2.weight, std=1.0)
    nn.init.zeros_(block.ln_2.bias)

가중치 초기화는 모든 선형 레이어를 N(0, 0.02)로 설정하고, LayerNorm의 가중치는 1.0, 편향은 0으로 초기화합니다. 이 전략은 학습 초기의 기울기 흐름을 안정화하여 수렴 속도를 크게 향상시킵니다.

상세 설명

"AutoResearch 완전 분석 - AI 자율 연구 에이전트" 코스에서 GPT 모델의 구조를 모두 살펴보았습니다. 마지막으로, 이 모델이 실제로 학습을 시작하기 전에 거치는 중요한 과정인 가중치 초기화를 다루겠습니다. 가중치를 모두 0으로 초기화하면 어떻게 될까요? 모든 뉴런이 동일한 입력을 받아 동일한 기울기를 가지게 됩니다. 이를 **대칭성 문제(Symmetry Problem)**라고 합니다. 뉴런마다 다른 역할을 배우게 하려면, 초기값이 달라야 합니다. "그럼 그냥 랜덤하게 설정하면 되는 거 아닌가요?" 무작위로 설정하면 대칭성 문제는 해결되지만, 새로운 문제가 발생합니다. 초기값이 너무 크면 기울기가 **폭발(Exploding)**하고, 너무 작으면 기울기가 **소실(Vanishing)**됩니다. 적절한 범위 내에서 초기화해야 합니다. 비유하자면, 가중치 초기화는 도미노를 세우는 작업과 같습니다. 도미노 간격이 너무 멀면 다음 도미노에 도달하지 못하고, 너무 가까우면 하나가 쓰러질 때 여러 개가 동시에 넘어집니다. 적절한 간격으로 세워야 첫 번째 도미노가 마지막까지 연쇄적으로 쓰러집니다. 기울기도 마찬가지로, 첫 번째 층에서 마지막 층까지 안정적으로 전달되어야 합니다. AutoResearch에서 사용하는 초기화 전략은 크게 세 가지입니다. 첫째, 선형 레이어의 가중치N(0, 0.02) 정규 분포로 초기화합니다. 표준편차 0.02는 GPT-2에서 검증된 값으로, 대부분의 모델 크기에서 안정적인 학습을 보장합니다. 둘째, 임베딩 레이어도 동일한 N(0, 0.02)를 사용합니다. 토큰 임베딩과 위치 임베딩 모두 같은 스케일로 초기화하여, 두 임베딩이 더해질 때 한쪽이 지배적이지 않도록 균형을 맞춥니다. 셋째, LayerNorm의 가중치는 1.0, 편향은 0.0으로 초기화합니다. LayerNorm은 입력을 평균 0, 분산 1로 정규화하므로, 초기 상태에서는 "아무것도 하지 않는" 것이 이상적입니다. 학습이 진행되면서 필요한 만큼 정규화 강도를 조절하도록 가중치가 업데이트됩니다. 깊은 네트워크에서 특히 중요한 것은 **잔차 스케일링(Residual Scaling)**입니다. 12층의 Residual Connection이 있다면, 각 층에서 약간의 기여가 12번 누적됩니다. 이 누적이 너무 커지면 출력의 분산이 폭발합니다. 일부 구현에서는 잔차 경로에 1/sqrt(n_layer) 스케일링을 적용하여 이를 방지합니다. AutoResearch에서도 깊은 모델에서 이 기법이 필요할 수 있습니다. "초기화 하나에 이렇게 많은 고민이 들어가는 줄 몰랐어요. 배울 게 끝이 없네요." 김개발 씨가 웃으며 말했습니다.

  • std=0.02는 GPT-2에서 검증된 값이지만, 모델 크기나 아키텍처에 따라 조정이 필요할 수 있습니다. 실험을 통해 최적값을 찾는 것이 좋습니다.
  • 초기화 후 첫 번째 forward pass에서 손실값이 예상 범위(-ln(vocab_size) 근처)인지 확인하면 초기화가 잘 되었는지 검증할 수 있습니다.
  • AutoResearch의 5분 타임 버짓에서는 초기화가 잘 되어야 빠른 수렴이 가능하므로, 이 전략이 특히 중요합니다.
  • 이 카드뉴스는 "AutoResearch 완전 분석 - AI 자율 연구 에이전트" 코스의 2/8편입니다.
  • 다음 카드뉴스에서는 Muon 옵티마이저와 AdamW의 결합 전략을 다룹니다.

#Python#GPT#Transformer#CausalSelfAttention#GroupedQueryAttention

댓글 (0)

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