이미지 로딩 중...

바닥부터 만드는 ChatGPT 5편 - GPT 트랜스포머 아키텍처 설계 - 슬라이드 1/8
A

AI Generated

2025. 11. 11. · 5 Views

바닥부터 만드는 ChatGPT 5편 - GPT 트랜스포머 아키텍처 설계

GPT의 핵심인 트랜스포머 아키텍처를 처음부터 구현해봅니다. Multi-Head Attention, Feed-Forward Network, Positional Encoding 등 핵심 컴포넌트들을 직접 코드로 작성하며, 실제 ChatGPT가 어떻게 작동하는지 깊이 있게 이해할 수 있습니다.


목차

  1. Positional_Encoding
  2. Multi_Head_Attention
  3. Feed_Forward_Network
  4. Layer_Normalization
  5. Residual_Connection
  6. Transformer_Block
  7. GPT_Model_Architecture

1. Positional_Encoding

시작하며

여러분이 "고양이가 쥐를 잡았다"와 "쥐가 고양이를 잡았다"라는 두 문장을 AI에게 학습시킨다고 생각해보세요. 같은 단어들이지만 순서가 바뀌면 완전히 다른 의미가 되죠.

그런데 트랜스포머 모델은 기본적으로 단어의 순서를 구분하지 못합니다. 이런 문제는 자연어 처리에서 치명적입니다.

단어의 순서는 문장의 의미를 결정하는 핵심 요소이기 때문입니다. RNN이나 LSTM은 순차적으로 처리하기 때문에 자연스럽게 위치 정보를 가지지만, 트랜스포머는 모든 단어를 동시에 처리합니다.

바로 이럴 때 필요한 것이 Positional Encoding입니다. 각 단어의 위치 정보를 수학적으로 인코딩하여 모델에 주입함으로써, 트랜스포머가 단어의 순서를 이해할 수 있게 만들어줍니다.

개요

간단히 말해서, Positional Encoding은 각 단어의 위치를 고유한 벡터로 변환하여 임베딩에 더해주는 기법입니다. 실제 GPT나 BERT 같은 대규모 언어 모델에서 필수적으로 사용됩니다.

예를 들어, 문장의 첫 번째 단어와 열 번째 단어는 서로 다른 위치 벡터를 가지게 되어, 모델이 이들을 구분할 수 있게 됩니다. 기존에는 학습 가능한 위치 임베딩을 사용했다면, 트랜스포머에서는 고정된 수학 함수(sin, cos)를 사용합니다.

이렇게 하면 학습 데이터보다 긴 문장도 처리할 수 있습니다. 핵심 특징은 첫째, 각 위치마다 고유한 패턴을 생성한다는 점입니다.

둘째, sin과 cos 함수를 번갈아 사용하여 상대적 위치 관계를 표현할 수 있습니다. 셋째, 모델 차원의 절반은 sin, 나머지 절반은 cos를 사용하여 풍부한 위치 정보를 담아냅니다.

코드 예제

import numpy as np

def positional_encoding(seq_len, d_model):
    # 위치 인덱스 생성 (0부터 seq_len-1까지)
    position = np.arange(seq_len)[:, np.newaxis]
    # 차원 인덱스 생성 (짝수 인덱스만 사용)
    div_term = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model))

    # Positional Encoding 행렬 초기화
    pe = np.zeros((seq_len, d_model))
    # 짝수 인덱스에는 sin 함수 적용
    pe[:, 0::2] = np.sin(position * div_term)
    # 홀수 인덱스에는 cos 함수 적용
    pe[:, 1::2] = np.cos(position * div_term)

    return pe

# 예제: 시퀀스 길이 100, 모델 차원 512
pos_encoding = positional_encoding(100, 512)

설명

이것이 하는 일: Positional Encoding은 각 토큰의 위치를 고유한 벡터로 변환하여, 모델이 시퀀스 내에서 단어의 상대적/절대적 위치를 파악할 수 있게 합니다. 첫 번째로, position 배열을 생성하여 각 토큰의 절대 위치(0, 1, 2, ...)를 나타냅니다.

그리고 div_term을 계산하는데, 이는 각 차원마다 다른 주파수를 가지도록 합니다. 낮은 차원은 빠르게 변하는 패턴을, 높은 차원은 천천히 변하는 패턴을 가지게 됩니다.

이렇게 하는 이유는 가까운 위치와 먼 위치를 모두 효과적으로 구분하기 위함입니다. 그 다음으로, pe 행렬을 초기화하고 짝수 인덱스에는 sin, 홀수 인덱스에는 cos를 적용합니다.

sin과 cos는 주기 함수이기 때문에, 상대적 위치 관계를 선형 변환으로 표현할 수 있다는 수학적 특성을 가집니다. 즉, PE(pos+k)는 PE(pos)의 선형 함수로 표현 가능합니다.

마지막으로, 이렇게 생성된 위치 인코딩은 단어 임베딩과 더해져서 모델에 입력됩니다. 예를 들어, "I love AI"라는 문장이 있다면, "I"의 임베딩 + 0번째 위치 인코딩, "love"의 임베딩 + 1번째 위치 인코딩, "AI"의 임베딩 + 2번째 위치 인코딩이 각각 입력으로 들어갑니다.

여러분이 이 코드를 사용하면 임의의 길이의 시퀀스에 대해 위치 정보를 부여할 수 있고, 학습 없이도 긴 문장을 처리할 수 있으며, 모델이 단어의 순서를 정확히 이해하게 됩니다. 특히 기계 번역, 텍스트 생성, 문서 요약 등 순서가 중요한 모든 NLP 태스크에서 필수적입니다.

실전 팁

💡 d_model(모델 차원)은 반드시 짝수여야 합니다. sin과 cos를 번갈아 적용하기 때문에 홀수 차원을 사용하면 마지막 차원이 누락됩니다.

💡 10000이라는 상수는 원 논문에서 경험적으로 선택된 값입니다. 더 긴 시퀀스를 다룬다면 이 값을 늘려볼 수 있습니다.

💡 학습 가능한 위치 임베딩 대신 고정 함수를 사용하면, 훈련 데이터보다 긴 시퀀스도 추론 시점에 처리할 수 있습니다.

💡 위치 인코딩을 임베딩에 더할 때, 같은 스케일을 유지하기 위해 임베딩에 sqrt(d_model)을 곱해주는 것이 일반적입니다.

💡 시각화를 통해 패턴을 확인해보세요. 각 위치마다 고유한 "지문" 같은 패턴이 생성되는 것을 볼 수 있습니다.


2. Multi_Head_Attention

시작하며

여러분이 "그는 은행에 갔다"라는 문장을 읽을 때, '은행'이 금융기관인지 강가인지 어떻게 판단하나요? 문맥을 다양한 각도에서 살펴보며 의미를 파악하죠.

하지만 단순한 Attention 메커니즘은 한 가지 방식으로만 문맥을 바라봅니다. 이런 제한은 복잡한 언어 이해에 걸림돌이 됩니다.

실제로 단어의 의미는 문법적 관계, 의미적 유사성, 거리 등 여러 요소에 의해 결정됩니다. 하나의 Attention만으로는 이 모든 측면을 포착하기 어렵습니다.

바로 이럴 때 필요한 것이 Multi-Head Attention입니다. 여러 개의 Attention을 병렬로 실행하여, 각각이 서로 다른 관점에서 문맥을 파악하게 만듭니다.

마치 여러 명의 전문가가 동시에 문장을 분석하는 것과 같습니다.

개요

간단히 말해서, Multi-Head Attention은 입력을 여러 부분공간으로 나누어 각각 독립적인 Attention을 수행한 후, 결과를 합치는 메커니즘입니다. GPT, BERT, T5 등 모든 현대 트랜스포머 모델의 핵심입니다.

예를 들어, 8개의 헤드를 사용한다면, 각 헤드는 구문 구조, 의미적 유사성, 위치 관계 등 서로 다른 패턴을 학습할 수 있습니다. 기존에는 단일 Attention으로 모든 정보를 포착하려 했다면, 이제는 여러 헤드가 전문화되어 다양한 측면을 동시에 파악합니다.

첫 번째 헤드는 주어-동사 관계를, 두 번째 헤드는 수식 관계를 학습하는 식입니다. 핵심 특징은 첫째, 표현력이 크게 향상됩니다.

둘째, 각 헤드의 차원을 줄여서(d_model / num_heads) 계산 비용이 단일 큰 Attention과 비슷합니다. 셋째, 앙상블 효과로 인해 더 robust한 표현을 학습합니다.

코드 예제

import torch
import torch.nn as nn
import torch.nn.functional as F

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super().__init__()
        # d_model이 num_heads로 나누어떨어져야 함
        assert d_model % num_heads == 0

        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads  # 각 헤드의 차원

        # Query, Key, Value 변환을 위한 선형 레이어
        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        # 최종 출력 변환 레이어
        self.W_o = nn.Linear(d_model, d_model)

    def forward(self, query, key, value, mask=None):
        batch_size = query.size(0)

        # 1. Q, K, V 계산 및 헤드로 분할
        Q = self.W_q(query).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        K = self.W_k(key).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        V = self.W_v(value).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)

        # 2. Scaled Dot-Product Attention 계산
        scores = torch.matmul(Q, K.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.d_k, dtype=torch.float32))
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
        attention = F.softmax(scores, dim=-1)

        # 3. Value와 곱하고 헤드 합치기
        context = torch.matmul(attention, V).transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)

        # 4. 최종 선형 변환
        output = self.W_o(context)
        return output

설명

이것이 하는 일: Multi-Head Attention은 입력 시퀀스를 여러 표현 부분공간으로 투영하여, 각각 독립적으로 Attention을 계산한 후 결과를 결합합니다. 이를 통해 다양한 문맥 정보를 동시에 포착할 수 있습니다.

첫 번째로, 입력을 세 개의 선형 변환(W_q, W_k, W_v)을 통해 Query, Key, Value로 변환합니다. 그런 다음 각각을 num_heads 개의 작은 벡터로 분할합니다.

예를 들어, d_model=512, num_heads=8이라면, 각 헤드는 64차원의 Q, K, V를 가지게 됩니다. 이렇게 나누는 이유는 각 헤드가 전체 정보의 부분집합에 전문화되도록 하기 위함입니다.

그 다음으로, 각 헤드에서 독립적으로 Scaled Dot-Product Attention을 수행합니다. Q와 K의 내적으로 유사도를 계산하고, sqrt(d_k)로 나누어 스케일링합니다.

이는 내적 값이 너무 커져서 softmax의 그래디언트가 사라지는 것을 방지합니다. mask가 제공되면 미래 토큰을 보지 못하도록 하거나, 패딩 토큰을 무시할 수 있습니다.

softmax로 정규화한 후 Value와 곱하여 가중 합을 구합니다. 마지막으로, 모든 헤드의 출력을 다시 하나로 합칩니다(concatenate).

이때 transpose와 contiguous를 사용하여 텐서의 메모리 배치를 조정합니다. 합쳐진 결과는 W_o 선형 레이어를 통과하여 최종 출력이 됩니다.

이 마지막 변환은 각 헤드의 정보를 효과적으로 통합하는 역할을 합니다. 여러분이 이 코드를 사용하면 단어 간의 복잡한 관계를 다각도로 파악할 수 있고, 장거리 의존성도 효과적으로 모델링할 수 있으며, 기계 번역, 요약, 질의응답 등 다양한 태스크에서 최고 수준의 성능을 얻을 수 있습니다.

실제로 GPT-3는 96개의 헤드를 사용하여 엄청나게 풍부한 표현을 학습합니다.

실전 팁

💡 num_heads는 보통 8, 12, 16 같은 값을 사용합니다. d_model을 num_heads로 나눈 d_k가 너무 작아지지 않도록 주의하세요(최소 32~64 권장).

💡 mask를 활용하면 디코더에서 미래 정보 유출을 방지(causal masking)하고, 패딩 토큰의 영향을 제거할 수 있습니다.

💡 Attention weights를 시각화하면 모델이 어떤 단어에 집중하는지 해석할 수 있습니다. 디버깅과 모델 이해에 매우 유용합니다.

💡 대규모 모델에서는 Flash Attention 같은 최적화 기법을 사용하여 메모리와 속도를 개선할 수 있습니다.

💡 각 헤드가 정말 다른 패턴을 학습하는지 확인하려면, Attention 분포의 엔트로피를 분석해보세요. 유사한 패턴을 학습한다면 헤드 수를 줄일 수 있습니다.


3. Feed_Forward_Network

시작하며

여러분이 Attention으로 문맥을 파악한 후, 이 정보를 어떻게 더 깊이 있게 변환할까요? Attention은 입력 간의 관계를 포착하지만, 본질적으로 가중 평균에 가깝습니다.

비선형적이고 복잡한 패턴을 학습하기에는 한계가 있습니다. 이런 문제는 모델의 표현력을 제한합니다.

실제로 언어는 매우 비선형적인 구조를 가지고 있어서, 단순한 선형 결합만으로는 충분하지 않습니다. 더 복잡한 특징을 추출하고 변환할 수 있는 메커니즘이 필요합니다.

바로 이럴 때 필요한 것이 Feed-Forward Network입니다. Attention 이후에 각 위치마다 독립적으로 적용되는 비선형 변환 레이어로, 모델의 표현력을 극적으로 향상시킵니다.

개요

간단히 말해서, FFN은 각 토큰 위치에 동일하게 적용되는 2층 신경망으로, 입력을 더 높은 차원으로 확장했다가 다시 원래 차원으로 축소합니다. 모든 트랜스포머 블록에서 Attention 다음에 배치됩니다.

예를 들어, d_model=512라면, 먼저 2048 같은 더 큰 차원(보통 4배)으로 확장하고, ReLU나 GELU 활성화 함수를 적용한 후, 다시 512로 축소합니다. 기존에는 RNN이 순차적으로 복잡한 변환을 수행했다면, 트랜스포머에서는 FFN이 위치별로 병렬적으로 처리합니다.

이렇게 하면 GPU 병렬화가 극대화되어 학습과 추론이 훨씬 빠릅니다. 핵심 특징은 첫째, 위치별로 독립적이라 병렬 처리가 가능합니다.

둘째, 중간 차원을 크게 확장하여(보통 4배) 모델 용량을 늘립니다. 셋째, 비선형 활성화 함수가 복잡한 특징 변환을 가능하게 합니다.

이 세 가지가 결합되어 트랜스포머의 강력한 표현력이 만들어집니다.

코드 예제

import torch
import torch.nn as nn

class FeedForwardNetwork(nn.Module):
    def __init__(self, d_model, d_ff, dropout=0.1):
        super().__init__()
        # 첫 번째 선형 변환: d_model -> d_ff (확장)
        self.linear1 = nn.Linear(d_model, d_ff)
        # 두 번째 선형 변환: d_ff -> d_model (축소)
        self.linear2 = nn.Linear(d_ff, d_model)
        # Dropout으로 과적합 방지
        self.dropout = nn.Dropout(dropout)
        # GELU 활성화 함수 (GPT에서 사용)
        self.activation = nn.GELU()

    def forward(self, x):
        # x shape: (batch_size, seq_len, d_model)
        # 1. 차원 확장 및 활성화
        x = self.linear1(x)  # (batch, seq_len, d_ff)
        x = self.activation(x)
        x = self.dropout(x)

        # 2. 원래 차원으로 축소
        x = self.linear2(x)  # (batch, seq_len, d_model)
        x = self.dropout(x)

        return x

# 예제: d_model=512, d_ff=2048
ffn = FeedForwardNetwork(d_model=512, d_ff=2048)
sample_input = torch.randn(32, 100, 512)  # (batch, seq_len, d_model)
output = ffn(sample_input)

설명

이것이 하는 일: FFN은 Attention이 포착한 문맥 정보를 더 복잡하고 추상적인 표현으로 변환합니다. 각 토큰 위치에 동일한 네트워크를 적용하되, 위치 간에는 독립적으로 작동합니다.

첫 번째로, linear1을 통해 입력 차원을 d_model에서 d_ff로 확장합니다. 예를 들어, 512에서 2048로 4배 확장하는 것이 일반적입니다.

이렇게 확장하는 이유는 더 넓은 특징 공간에서 복잡한 패턴을 학습할 수 있기 때문입니다. 그 다음 GELU 활성화 함수를 적용합니다.

GELU는 ReLU보다 부드러운 비선형성을 제공하여, 특히 언어 모델에서 좋은 성능을 보입니다. 그 다음으로, dropout을 적용하여 과적합을 방지합니다.

이는 학습 시 일부 뉴런을 무작위로 비활성화하여, 모델이 특정 특징에 과도하게 의존하지 않게 만듭니다. Attention과 FFN 모두에 dropout을 사용하는 것이 트랜스포머의 정규화 전략입니다.

마지막으로, linear2를 통해 다시 원래 차원인 d_model로 축소합니다. 확장된 공간에서 학습한 복잡한 특징을 원래 표현 공간으로 압축하는 과정입니다.

이 과정에서 중요한 정보는 보존되고 불필요한 정보는 걸러집니다. 마지막에도 dropout을 적용하여 추가적인 정규화를 수행합니다.

여러분이 이 코드를 사용하면 Attention으로 파악한 문맥을 더 깊이 있는 표현으로 변환할 수 있고, 모델의 비선형성과 용량을 크게 늘릴 수 있으며, 복잡한 언어 패턴과 추상적 개념을 학습할 수 있습니다. GPT-3의 경우 d_ff가 d_model의 4배인 49152까지 확장되어 엄청난 표현력을 가집니다.

실전 팁

💡 d_ff는 보통 d_model의 4배로 설정합니다. 메모리가 부족하면 2~3배로 줄일 수 있지만, 성능이 저하될 수 있습니다.

💡 GELU는 GPT에서, ReLU는 BERT에서 주로 사용됩니다. GELU가 일반적으로 더 나은 성능을 보이지만, ReLU가 더 빠릅니다.

💡 FFN은 전체 모델 파라미터의 약 2/3를 차지합니다. 모델 압축 시 FFN을 먼저 타겟으로 하면 효과적입니다.

💡 SwiGLU나 GeGLU 같은 최신 활성화 함수를 사용하면 성능을 더 향상시킬 수 있습니다.

💡 각 레이어 후에 dropout을 적용하는 것이 일반적이지만, 추론 시에는 자동으로 비활성화됩니다(model.eval() 모드).


4. Layer_Normalization

시작하며

여러분이 딥러닝 모델을 학습시킬 때, 레이어를 거칠수록 값의 분포가 크게 변하는 경험을 해보셨나요? 어떤 뉴런은 너무 큰 값을, 어떤 뉴런은 너무 작은 값을 출력하면서 학습이 불안정해집니다.

이런 문제는 깊은 신경망에서 치명적입니다. 그래디언트가 폭발하거나 소실되어 학습이 느려지거나 아예 수렴하지 못할 수 있습니다.

특히 트랜스포머처럼 많은 레이어를 쌓는 구조에서는 이 문제가 더욱 심각합니다. 바로 이럴 때 필요한 것이 Layer Normalization입니다.

각 레이어의 출력을 정규화하여 평균 0, 분산 1로 만들어주고, 학습 가능한 파라미터로 스케일과 이동을 조정합니다. 이를 통해 안정적이고 빠른 학습이 가능해집니다.

개요

간단히 말해서, Layer Normalization은 각 샘플의 특징 차원에 대해 평균과 분산을 계산하여 정규화하는 기법입니다. 트랜스포머의 모든 서브레이어(Attention, FFN) 앞이나 뒤에 배치됩니다.

예를 들어, (batch_size, seq_len, d_model) 형태의 텐서가 있다면, 각 (seq_len, d_model) 위치에 대해 d_model 차원의 평균과 분산을 구해 정규화합니다. 기존 Batch Normalization은 배치 차원에 대해 정규화했다면, Layer Normalization은 특징 차원에 대해 정규화합니다.

이렇게 하면 배치 크기나 시퀀스 길이에 무관하게 작동하여, RNN이나 트랜스포머에 더 적합합니다. 핵심 특징은 첫째, 배치 크기에 독립적이라 작은 배치에도 안정적입니다.

둘째, 각 샘플을 독립적으로 정규화하여 추론 시 별도의 통계 관리가 필요 없습니다. 셋째, 학습 가능한 gamma와 beta 파라미터로 네트워크가 필요한 스케일을 스스로 학습할 수 있습니다.

코드 예제

import torch
import torch.nn as nn

class LayerNormalization(nn.Module):
    def __init__(self, d_model, eps=1e-6):
        super().__init__()
        # 스케일 파라미터 (학습 가능)
        self.gamma = nn.Parameter(torch.ones(d_model))
        # 이동 파라미터 (학습 가능)
        self.beta = nn.Parameter(torch.zeros(d_model))
        # 수치 안정성을 위한 작은 값
        self.eps = eps

    def forward(self, x):
        # x shape: (batch_size, seq_len, d_model)
        # 마지막 차원(d_model)에 대해 평균 계산
        mean = x.mean(dim=-1, keepdim=True)
        # 마지막 차원에 대해 분산 계산
        var = x.var(dim=-1, keepdim=True, unbiased=False)

        # 정규화: (x - mean) / sqrt(var + eps)
        x_norm = (x - mean) / torch.sqrt(var + self.eps)

        # 스케일 및 이동 적용
        output = self.gamma * x_norm + self.beta

        return output

# 예제 사용
layer_norm = LayerNormalization(d_model=512)
sample_input = torch.randn(32, 100, 512)  # (batch, seq_len, d_model)
normalized = layer_norm(sample_input)

설명

이것이 하는 일: Layer Normalization은 각 레이어의 출력 분포를 안정화하여, 깊은 네트워크에서도 그래디언트가 원활하게 흐르도록 합니다. 이는 학습 속도를 높이고 더 깊은 모델을 학습 가능하게 만듭니다.

첫 번째로, 입력 x의 마지막 차원(특징 차원)에 대해 평균과 분산을 계산합니다. keepdim=True를 사용하여 브로드캐스팅이 가능하도록 차원을 유지합니다.

예를 들어, 512차원 벡터의 평균과 분산을 구하면, 512개 값의 분포가 어떤지 알 수 있습니다. 이 통계량은 각 토큰, 각 샘플마다 독립적으로 계산됩니다.

그 다음으로, 계산된 평균을 빼고 표준편차로 나누어 정규화합니다. eps(epsilon)를 더하는 이유는 분산이 0에 가까울 때 나눗셈이 불안정해지는 것을 방지하기 위함입니다.

이제 x_norm은 평균 0, 분산 1의 분포를 가지게 됩니다. 이렇게 정규화하면 모든 특징이 비슷한 스케일을 가져서 학습이 균형있게 진행됩니다.

마지막으로, 학습 가능한 파라미터 gamma와 beta를 적용합니다. gamma는 스케일을, beta는 이동을 조정합니다.

이들은 모두 1과 0으로 초기화되지만, 학습 과정에서 네트워크가 필요한 분포를 스스로 찾아갑니다. 예를 들어, 어떤 레이어는 더 큰 분산이 필요할 수 있고, 다른 레이어는 평균이 0이 아닌 것이 좋을 수 있습니다.

여러분이 이 코드를 사용하면 깊은 트랜스포머 모델을 안정적으로 학습할 수 있고, 학습률을 더 크게 설정하여 빠른 수렴이 가능하며, 그래디언트 폭발/소실 문제를 크게 완화할 수 있습니다. GPT-3처럼 96층이나 되는 깊은 모델도 Layer Normalization 덕분에 학습 가능합니다.

실전 팁

💡 Pre-LN(정규화를 서브레이어 앞에 적용)이 Post-LN보다 안정적입니다. 최신 모델들은 대부분 Pre-LN을 사용합니다.

💡 eps 값은 보통 1e-6에서 1e-5 사이를 사용합니다. 너무 작으면 수치 불안정, 너무 크면 정규화 효과가 약해집니다.

💡 RMSNorm(Root Mean Square Normalization)은 평균을 계산하지 않고 RMS만 사용하여 더 빠른 대안으로 주목받고 있습니다.

💡 학습 중 gamma와 beta 값을 모니터링하면, 어떤 레이어가 얼마나 중요한지 파악할 수 있습니다.

💡 추론 시에도 학습 때와 똑같이 작동하므로, Batch Normalization처럼 별도의 통계를 저장할 필요가 없습니다.


5. Residual_Connection

시작하며

여러분이 100층짜리 신경망을 학습시키려고 하면 어떤 일이 벌어질까요? 이론상으로는 더 깊을수록 좋지만, 실제로는 그래디언트가 역전파되면서 점점 작아져 초기 레이어까지 거의 전달되지 않습니다.

이런 문제는 그래디언트 소실(vanishing gradient)이라 불리며, 깊은 네트워크의 가장 큰 걸림돌이었습니다. 레이어가 깊어질수록 성능이 오히려 나빠지는 degradation 현상도 관찰됩니다.

이는 단순히 과적합이 아니라, 최적화 자체가 어려워지는 문제입니다. 바로 이럴 때 필요한 것이 Residual Connection(잔차 연결)입니다.

각 서브레이어의 입력을 출력에 직접 더해줌으로써, 그래디언트가 여러 레이어를 건너뛰어 흐를 수 있는 "고속도로"를 만들어줍니다.

개요

간단히 말해서, Residual Connection은 서브레이어의 출력에 입력을 그대로 더하는 것으로, 수식으로는 output = SubLayer(x) + x입니다. ResNet에서 처음 제안되어 컴퓨터 비전을 혁신했고, 이제는 트랜스포머의 필수 구성 요소입니다.

예를 들어, Multi-Head Attention의 출력에 원본 입력을 더하고, FFN의 출력에도 그 입력을 더합니다. 이렇게 하면 각 레이어는 전체 변환이 아닌 "잔차(residual)"만 학습하면 됩니다.

기존에는 각 레이어가 최종 출력을 직접 생성해야 했다면, 이제는 이전 레이어의 출력을 개선하는 것만 학습하면 됩니다. 예를 들어, 입력이 이미 좋은 표현이라면, 레이어는 거의 0에 가까운 변환을 학습하여 입력을 보존할 수 있습니다.

핵심 특징은 첫째, 그래디언트가 덧셈을 통해 직접 흐를 수 있어 소실이 방지됩니다. 둘째, 항등 함수를 쉽게 학습할 수 있어 깊은 모델이 얕은 모델보다 나빠지지 않습니다.

셋째, 앙상블 효과로 다양한 깊이의 네트워크가 암묵적으로 결합됩니다.

코드 예제

import torch
import torch.nn as nn

class ResidualConnection(nn.Module):
    def __init__(self, d_model, dropout=0.1):
        super().__init__()
        # Layer Normalization과 함께 사용
        self.norm = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, sublayer):
        """
        x: 입력 텐서 (batch, seq_len, d_model)
        sublayer: 적용할 서브레이어 함수 (Attention, FFN 등)
        """
        # Pre-LN 방식: Normalize -> SubLayer -> Dropout -> Add
        # 원본 입력을 보존
        residual = x
        # 정규화 후 서브레이어 적용
        x = self.norm(x)
        x = sublayer(x)
        # Dropout 적용
        x = self.dropout(x)
        # 잔차 연결
        return residual + x

# 예제 사용
class DummySubLayer(nn.Module):
    def __init__(self, d_model):
        super().__init__()
        self.linear = nn.Linear(d_model, d_model)
    def forward(self, x):
        return self.linear(x)

residual_conn = ResidualConnection(d_model=512)
sublayer = DummySubLayer(d_model=512)
sample_input = torch.randn(32, 100, 512)
output = residual_conn(sample_input, sublayer)

설명

이것이 하는 일: Residual Connection은 각 서브레이어가 전체 변환이 아닌 입력에 대한 "보정"만 학습하도록 만듭니다. 이는 최적화를 훨씬 쉽게 만들고, 그래디언트가 네트워크 전체에 원활하게 흐르도록 보장합니다.

첫 번째로, 입력 x를 residual 변수에 저장해둡니다. 이것이 "고속도로"의 시작점입니다.

그 다음 Pre-LN 방식에 따라 먼저 Layer Normalization을 적용합니다. 최신 연구에 따르면 정규화를 먼저 하는 것이 학습을 더 안정화시킵니다.

정규화된 입력은 서브레이어(Attention이나 FFN)를 통과합니다. 그 다음으로, 서브레이어의 출력에 dropout을 적용합니다.

이는 정규화 효과를 제공하며, 학습 중 일부 경로를 무작위로 차단하여 과적합을 방지합니다. Dropout은 실제로 residual path의 중요성을 더 높이는 효과도 있습니다.

마지막으로, 가장 중요한 단계인 덧셈이 이루어집니다. residual + x는 원본 입력과 변환된 출력을 결합합니다.

역전파 시 이 덧셈 노드를 통해 그래디언트가 직접 residual로 흐를 수 있습니다. 수학적으로 보면, ∂(residual + x)/∂residual = 1이므로, 그래디언트가 감쇠 없이 전달됩니다.

여러분이 이 코드를 사용하면 100층 이상의 매우 깊은 모델도 안정적으로 학습할 수 있고, 학습 초기부터 그래디언트가 모든 레이어에 전달되며, 모델이 필요한 만큼만 각 레이어를 활용하는 적응적 깊이 조절이 가능합니다. GPT-3의 96층 구조도 Residual Connection 덕분에 학습 가능합니다.

실전 팁

💡 Pre-LN(정규화 먼저)이 Post-LN보다 훨씬 안정적입니다. 매우 깊은 모델에서는 Pre-LN이 필수적입니다.

💡 Residual path와 서브레이어 출력의 크기가 같아야 하므로, d_model을 일정하게 유지해야 합니다.

💡 학습 초기에는 서브레이어가 거의 0을 출력하고 residual에 의존하다가, 점차 의미 있는 변환을 학습합니다.

💡 Dropout rate는 보통 0.1을 사용하지만, 매우 큰 모델에서는 0.0~0.05로 낮춰도 됩니다.

💡 역전파 시 그래디언트가 어떻게 흐르는지 이해하면, 왜 residual connection이 효과적인지 직관적으로 알 수 있습니다.


6. Transformer_Block

시작하며

여러분이 지금까지 배운 Multi-Head Attention, FFN, Layer Normalization, Residual Connection을 어떻게 조합해야 할까요? 각각은 강력한 구성 요소지만, 이들을 올바른 순서로 결합해야 진정한 트랜스포머 블록이 됩니다.

이런 조합은 트랜스포머의 핵심 빌딩 블록입니다. 실제로 GPT는 이 블록을 수십 개 쌓아서 만들어집니다.

각 블록은 이전 블록의 출력을 받아 더 추상적이고 고수준의 표현을 생성합니다. 바로 이것이 Transformer Block입니다.

Attention으로 문맥을 파악하고, FFN으로 비선형 변환을 수행하며, 중간중간 정규화와 잔차 연결로 안정성을 확보합니다. 이 블록을 반복적으로 쌓아서 매우 깊고 강력한 모델을 만듭니다.

개요

간단히 말해서, Transformer Block은 Multi-Head Attention과 Feed-Forward Network를 Residual Connection과 Layer Normalization으로 연결한 하나의 완전한 변환 단위입니다. GPT, BERT, T5 등 모든 트랜스포머 모델의 기본 구성 단위입니다.

예를 들어, GPT-3는 96개의 이런 블록을 쌓아서 만들어졌습니다. 각 블록은 입력을 받아 (1) 자기 자신과의 Attention을 계산하고, (2) FFN으로 추가 변환을 수행합니다.

기존 RNN이나 LSTM이 순차적으로 처리했다면, Transformer Block은 전체 시퀀스를 병렬로 처리합니다. 한 블록의 출력은 다음 블록의 입력이 되며, 이렇게 쌓인 블록들이 점진적으로 더 복잡한 패턴을 학습합니다.

핵심 특징은 첫째, Self-Attention이 문맥 정보를 모으고, FFN이 이를 변환합니다. 둘째, 각 서브레이어마다 Residual Connection으로 안정성을 보장합니다.

셋째, 이 블록을 여러 개 쌓아 깊이를 만들며, 각 블록은 이전 블록보다 더 추상적인 표현을 학습합니다.

코드 예제

import torch
import torch.nn as nn

class TransformerBlock(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
        # Multi-Head Attention
        self.attention = nn.MultiheadAttention(
            embed_dim=d_model,
            num_heads=num_heads,
            dropout=dropout,
            batch_first=True
        )
        # Feed-Forward Network
        self.ffn = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(d_ff, d_model),
            nn.Dropout(dropout)
        )
        # Layer Normalization (Pre-LN 방식)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, mask=None):
        # x shape: (batch_size, seq_len, d_model)

        # 1. Multi-Head Self-Attention with Residual
        residual = x
        x = self.norm1(x)  # Pre-LN
        attn_output, _ = self.attention(x, x, x, attn_mask=mask)
        x = residual + self.dropout(attn_output)

        # 2. Feed-Forward Network with Residual
        residual = x
        x = self.norm2(x)  # Pre-LN
        ffn_output = self.ffn(x)
        x = residual + ffn_output

        return x

# 예제: GPT-2 Small 크기
block = TransformerBlock(d_model=768, num_heads=12, d_ff=3072)
sample_input = torch.randn(32, 128, 768)  # (batch, seq_len, d_model)
output = block(sample_input)

설명

이것이 하는 일: Transformer Block은 입력 시퀀스를 받아 (1) Self-Attention으로 전체 문맥을 고려한 표현을 만들고, (2) FFN으로 각 위치의 표현을 비선형 변환하여, 더 풍부하고 추상적인 표현을 생성합니다. 첫 번째로, Multi-Head Self-Attention 단계가 실행됩니다.

입력 x를 세 번 사용하여 query, key, value를 만듭니다(Self-Attention). 이는 "이 단어는 문장의 다른 어떤 단어들과 관련이 있는가?"를 학습합니다.

Pre-LN 방식에 따라 norm1을 먼저 적용한 후 Attention을 수행합니다. 출력에 dropout을 적용하고 원본 residual과 더합니다.

이 과정에서 각 토큰은 문맥 정보를 얻게 됩니다. 그 다음으로, Feed-Forward Network 단계가 이어집니다.

Attention으로 문맥을 모은 표현을 받아, 각 위치마다 독립적으로 비선형 변환을 수행합니다. d_model에서 d_ff(보통 4배)로 확장했다가 다시 축소하는 과정에서 복잡한 패턴을 학습합니다.

마찬가지로 norm2를 먼저 적용하고, FFN 출력에 residual을 더합니다. 마지막으로, 이 블록의 출력은 다음 블록의 입력이 됩니다.

첫 번째 블록은 단어 수준의 패턴을, 중간 블록은 구문 수준의 패턴을, 마지막 블록은 의미 수준의 패턴을 학습하는 경향이 있습니다. 이런 계층적 표현 학습이 트랜스포머의 강력함의 비밀입니다.

여러분이 이 코드를 사용하면 완전한 트랜스포머 레이어를 구현할 수 있고, 이를 원하는 만큼 쌓아 GPT 같은 대규모 언어 모델을 만들 수 있으며, 각 블록이 점진적으로 더 추상적인 표현을 학습하는 것을 관찰할 수 있습니다. 실제로 GPT-2는 이런 블록 12개(small), 24개(medium), 36개(large), 48개(XL)를 쌓아 만들어졌습니다.

실전 팁

💡 num_heads는 d_model의 약수여야 합니다. d_model=768이면 num_heads=12 (768/12=64 per head)가 일반적입니다.

💡 d_ff는 d_model의 4배가 표준이지만, 메모리 제약이 있다면 2~3배로 줄일 수 있습니다.

💡 블록을 쌓을 때, 각 블록의 파라미터를 독립적으로 초기화해야 합니다. 가중치 공유는 일반적으로 사용하지 않습니다.

💡 매우 깊은 모델(50+ 블록)을 학습한다면, Gradient Checkpointing을 사용하여 메모리를 절약할 수 있습니다.

💡 각 블록의 Attention 패턴을 시각화하면, 하위 블록은 구문 관계를, 상위 블록은 의미 관계를 포착하는 것을 볼 수 있습니다.


7. GPT_Model_Architecture

시작하며

여러분이 지금까지 배운 모든 구성 요소를 어떻게 조합하면 실제 GPT 모델이 될까요? Token Embedding, Positional Encoding, 여러 개의 Transformer Block, 그리고 최종 출력 레이어를 어떤 순서로 연결해야 할지 궁금하셨을 겁니다.

이런 전체 구조를 이해하는 것은 단순히 부품을 아는 것과 완전히 다릅니다. 실제로 ChatGPT나 GPT-4 같은 모델이 어떻게 작동하는지, 텍스트를 어떻게 생성하는지를 완전히 이해할 수 있게 됩니다.

바로 이것이 GPT Model Architecture입니다. 입력 토큰을 임베딩으로 변환하고, 위치 정보를 더한 후, 여러 개의 Transformer Block을 통과시켜, 최종적으로 다음 단어를 예측하는 확률 분포를 생성합니다.

개요

간단히 말해서, GPT는 Token Embedding + Positional Encoding → N개의 Transformer Blocks → Layer Norm → Linear (Language Model Head) 구조를 가진 자기회귀 언어 모델입니다. 모든 GPT 계열 모델(GPT-2, GPT-3, GPT-4, ChatGPT)의 기본 구조입니다.

예를 들어, "안녕하세요"라는 입력이 들어오면, 각 토큰을 임베딩하고, 여러 블록을 거쳐 변환한 후, "반갑습니다"같은 다음 토큰의 확률 분포를 출력합니다. 기존 BERT는 양방향으로 문맥을 본다면, GPT는 이전 토큰만 보는 단방향(causal) 모델입니다.

이렇게 하면 자연스럽게 텍스트 생성이 가능합니다. 훈련 시에는 "다음 단어 맞추기"를 수십억 번 반복하며, 이 과정에서 언어의 패턴, 문법, 심지어 추론 능력까지 학습합니다.

핵심 특징은 첫째, 자기회귀(autoregressive) 방식으로 한 번에 한 토큰씩 생성합니다. 둘째, Causal Mask로 미래 토큰을 보지 못하게 합니다.

셋째, 같은 모델로 훈련과 생성을 모두 수행할 수 있습니다. 넷째, 스케일이 커질수록(파라미터, 데이터, 계산량) 놀라운 emergent abilities가 나타납니다.

코드 예제

import torch
import torch.nn as nn
import math

class GPTModel(nn.Module):
    def __init__(self, vocab_size, d_model, num_heads, d_ff, num_layers, max_seq_len, dropout=0.1):
        super().__init__()
        self.d_model = d_model

        # Token Embedding
        self.token_embedding = nn.Embedding(vocab_size, d_model)
        # Positional Encoding (학습 가능한 방식)
        self.positional_encoding = nn.Embedding(max_seq_len, d_model)

        # N개의 Transformer Blocks
        self.blocks = nn.ModuleList([
            TransformerBlock(d_model, num_heads, d_ff, dropout)
            for _ in range(num_layers)
        ])

        # 최종 Layer Normalization
        self.ln_f = nn.LayerNorm(d_model)
        # Language Model Head (출력 레이어)
        self.lm_head = nn.Linear(d_model, vocab_size, bias=False)

        # Dropout
        self.dropout = nn.Dropout(dropout)

        # 가중치 초기화
        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, input_ids):
        # input_ids: (batch_size, seq_len)
        batch_size, seq_len = input_ids.size()

        # 1. Token Embedding
        token_emb = self.token_embedding(input_ids)  # (batch, seq_len, d_model)

        # 2. Positional Encoding
        positions = torch.arange(0, seq_len, device=input_ids.device).unsqueeze(0)
        pos_emb = self.positional_encoding(positions)  # (1, seq_len, d_model)

        # 3. Embedding = Token + Position
        x = self.dropout(token_emb + pos_emb)

        # 4. Causal Mask 생성 (미래 토큰 가리기)
        causal_mask = torch.triu(torch.ones(seq_len, seq_len), diagonal=1).bool()
        causal_mask = causal_mask.to(input_ids.device)

        # 5. Transformer Blocks 통과
        for block in self.blocks:
            x = block(x, mask=causal_mask)

        # 6. 최종 Layer Norm
        x = self.ln_f(x)

        # 7. Language Model Head (다음 토큰 예측)
        logits = self.lm_head(x)  # (batch, seq_len, vocab_size)

        return logits

    @torch.no_grad()
    def generate(self, input_ids, max_new_tokens, temperature=1.0, top_k=None):
        """텍스트 생성 함수"""
        for _ in range(max_new_tokens):
            # 현재 시퀀스로 예측
            logits = self.forward(input_ids)
            # 마지막 토큰의 logits만 사용
            logits = logits[:, -1, :] / temperature

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

            # 확률 분포로 변환 및 샘플링
            probs = torch.softmax(logits, dim=-1)
            next_token = torch.multinomial(probs, num_samples=1)

            # 생성된 토큰을 시퀀스에 추가
            input_ids = torch.cat([input_ids, next_token], dim=1)

        return input_ids

# 예제: GPT-2 Small 크기의 모델
model = GPTModel(
    vocab_size=50257,    # GPT-2 vocabulary
    d_model=768,
    num_heads=12,
    d_ff=3072,
    num_layers=12,
    max_seq_len=1024,
    dropout=0.1
)

# 훈련 예제
input_ids = torch.randint(0, 50257, (8, 128))  # (batch=8, seq_len=128)
logits = model(input_ids)
print(f"Output shape: {logits.shape}")  # (8, 128, 50257)

# 생성 예제
start_tokens = torch.tensor([[50256]])  # <|endoftext|> 토큰
generated = model.generate(start_tokens, max_new_tokens=50, temperature=0.8, top_k=40)

설명

이것이 하는 일: GPT 모델은 입력 토큰 시퀀스를 받아, 각 위치에서 다음에 올 토큰의 확률 분포를 예측합니다. 훈련 시에는 이 예측을 정답과 비교하여 학습하고, 생성 시에는 이 확률에서 샘플링하여 텍스트를 만듭니다.

첫 번째로, 입력 토큰 ID를 임베딩 벡터로 변환합니다. 예를 들어, "안녕"이라는 토큰이 ID 1234라면, 이것이 768차원 벡터로 변환됩니다.

동시에 각 위치의 positional embedding을 더합니다. 이렇게 하면 같은 단어도 위치에 따라 다른 표현을 가지게 됩니다.

Dropout을 적용하여 과적합을 방지합니다. 그 다음으로, Causal Mask를 생성합니다.

이는 상삼각 행렬로, i번째 토큰이 j번째 토큰을 볼 수 있는지를 결정합니다(i >= j일 때만 True). 이 마스크 덕분에 모델은 미래 토큰을 보지 못하고, 이전 토큰만으로 다음 토큰을 예측해야 합니다.

그런 다음 여러 개의 Transformer Block을 순차적으로 통과합니다. 각 블록은 표현을 점점 더 추상화하고 풍부하게 만듭니다.

마지막으로, 최종 Layer Normalization을 적용하고, Language Model Head(선형 레이어)를 통해 vocab_size 크기의 logits를 생성합니다. 각 logits[i]는 "i번째 토큰 다음에 각 단어가 올 확률"을 나타냅니다.

생성 시에는 이 logits에서 temperature와 top-k 샘플링을 사용하여 다음 토큰을 선택하고, 이를 입력에 추가하여 반복합니다. 여러분이 이 코드를 사용하면 처음부터 GPT를 구현하고 훈련할 수 있으며, 텍스트 생성, 대화, 요약, 번역 등 다양한 태스크에 사용할 수 있고, temperature와 top-k 같은 하이퍼파라미터를 조정하여 생성 품질을 제어할 수 있습니다.

이것이 바로 ChatGPT의 핵심 구조입니다!

실전 팁

💡 모델 크기를 늘릴 때는 d_model, num_heads, d_ff, num_layers를 함께 늘립니다. GPT-2 Medium은 1024/16/4096/24를 사용합니다.

💡 Temperature는 생성의 다양성을 조절합니다. 0.70.9는 창의적, 0.10.3은 결정적 생성에 적합합니다.

💡 Top-k 샘플링(k=40)과 Top-p 샘플링(p=0.9)을 결합하면 더 나은 생성 품질을 얻을 수 있습니다.

💡 실제 훈련에는 AdamW 옵티마이저와 Cosine Learning Rate Schedule을 사용하며, Gradient Clipping으로 안정성을 확보합니다.

💡 메모리가 부족하다면, Gradient Accumulation으로 큰 배치 크기 효과를 내거나, Mixed Precision Training (FP16)을 사용하세요.

💡 Pre-training에는 수십억 토큰이 필요하지만, Fine-tuning은 훨씬 적은 데이터로 가능합니다. 특정 도메인에 맞추려면 fine-tuning을 활용하세요.


#Python#Transformer#Attention#DeepLearning#GPT#ai

댓글 (0)

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