Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

GPT

2018년에 발표된 GPT (Generative Pre-trained Transformer) 는 OpenAI의 첫 번째 Transformer 기반 언어 생성 모델로, 자연어 처리(NLP)에 사전학습(Pre-training) + 미세조정(Fine-tuning) 접근을 처음 성공적으로 도입한 사례입니다. Radford et al. (2018) 이 모델은 후속 모델들(GPT-2, GPT-3 등)의 기반이 되었으며, "프롬프트 기반 생성 모델"의 시초로 평가받습니다.

1무엇을 만드는가 — 결과부터

먼저 완성된 모습을 봅시다. 이 장이 끝나면 다음이 가능해집니다.

프롬프트:  "The quick brown fox jumps over the lazy"
이어쓰기:  "The quick brown fox jumps over the lazy, lazy fox and they both fall to"

이 문장을 만든 것은 우리가 직접 정의한 Keras 모델이며, 그 안에 들어간 숫자는 OpenAI의 GPT-2 가중치입니다. 즉 “모델 구조는 우리 코드, 지식은 진짜 GPT-2” 입니다. 아래에서 (1) 모델을 조립하고, (2) 가중치를 이식하고, (3) 생성·검증한 뒤, 모델을 이루는 각 부품을 하나씩 자세히 들여다봅니다.

import keras
from keras import ops, layers
import numpy as np
print(f'Keras: {keras.__version__} (backend: {keras.backend.backend()})')
Keras: 3.10.0 (backend: torch)

1.1토크나이저

GPT-2는 바이트 수준 BPE(Byte-Pair Encoding) 토크나이저를 씁니다. 그 정확한 병합 규칙(vocab·merges)을 담은 경량 구현이 tiktoken이며, "gpt2" 인코딩이 OpenAI GPT-2의 토크나이저와 동일합니다. BPE 자체를 처음부터 구현하는 방법은 바이트 페어 인코딩 장에서 다루므로, 여기서는 모델에 집중하기 위해 tiktoken을 사용합니다.

토크나이저는 모델이 학습된 바로 그것 이어야 합니다. 잠시 뒤 보겠지만 임베딩 표(wte)는 토큰 ID로 직접 색인되므로, ID 체계가 조금이라도 다르면 모델이 엉뚱한 임베딩을 집어 출력이 망가집니다. tiktoken("gpt2") 와 우리가 이식할 가중치는 모두 같은 GPT-2 어휘를 쓰므로 ID가 정확히 맞아떨어집니다.

import tiktoken

enc = tiktoken.get_encoding("gpt2")
print("어휘 크기:", enc.n_vocab)

ids = enc.encode("Hello, GPT!")
print("토큰 ID :", ids)
print("복원    :", repr(enc.decode(ids)))
print("조각    :", [enc.decode([i]) for i in ids])
어휘 크기: 50257
토큰 ID : [15496, 11, 402, 11571, 0]
복원    : 'Hello, GPT!'
조각    : ['Hello', ',', ' G', 'PT', '!']

1.2모델 설정

GPT-2 base(=small)의 하이퍼파라미터입니다. 어휘 50,257개, 최대 문맥 1,024 토큰, 임베딩 차원 768, 트랜스포머 블록 12층, 어텐션 헤드 12개입니다.

from collections import namedtuple

설정 = namedtuple('설정', ['어휘수', '최대길이', '임베딩차원', '층수', '헤드수'])(
    어휘수=50257, 최대길이=1024, 임베딩차원=768, 층수=12, 헤드수=12)

1.3구성 요소 — 레이어와 모듈

GPT-2는 임베딩으로 토큰을 벡터로 바꾸고, 똑같이 생긴 디코더 블록(인과적 자기어텐션 + 피드포워드)을 12개 쌓아 처리한 뒤, 계층 정규화와 출력 헤드를 지납니다. 수식을 직접 드러내야 하는 부품(어텐션·PositionEmbedding)만 커스텀 레이어로 만들고, 나머지 조립은 Sequential·Functional로 합니다. 각 부품의 수학적 의미는 뒤에서 하나씩 짚어보고, 지금은 골격을 세웁니다.

먼저 임베딩입니다. 토큰 임베딩에 위치 임베딩을 더해 한 모듈로 묶습니다. 위치 임베딩은 입력 길이에 따라 정해지므로 커스텀 레이어(PositionEmbedding)가 처리합니다.

@keras.saving.register_keras_serializable(package="gpt")
class PositionEmbedding(layers.Layer):
    """입력 길이에 맞춰 학습형 위치 임베딩을 더한다."""
    def __init__(self, 최대길이, 임베딩차원, **kw):
        super().__init__(**kw)
        self.최대길이, self.임베딩차원 = 최대길이, 임베딩차원
        self.wpe = layers.Embedding(최대길이, 임베딩차원, name="wpe")

    def get_config(self):
        return {**super().get_config(), "최대길이": self.최대길이, "임베딩차원": self.임베딩차원}

    def call(self, 토큰임베딩):
        T = ops.shape(토큰임베딩)[1]
        위치 = self.wpe(ops.arange(0, T))
        return 토큰임베딩 + 위치


def 임베딩_모듈(어휘수, 최대길이, 임베딩차원, name="embedding"):
    토큰 = keras.Input(shape=(None,), dtype="int32")
    x = layers.Embedding(어휘수, 임베딩차원, name="wte")(토큰)        # 토큰 임베딩
    x = PositionEmbedding(최대길이, 임베딩차원, name="position")(x)           # + 위치 임베딩
    return keras.Model(토큰, x, name=name)

다음은 디코더 블록입니다. 인과적 자기어텐션과 피드포워드(MLP)를 각각 정규화·잔차 연결로 묶은 한 층이고, 이 블록을 똑같이 12개 쌓습니다. 어텐션만 수식을 드러내는 커스텀 레이어로 만들고, 블록 자체는 Functional 하위 모델로 만듭니다(피드포워드는 아주 기본적이라 블록 안에서 Dense 두 개와 ops.gelu 로 바로 조립합니다).

# register + get_config: 모델을 .keras 파일로 저장·로드할 때 필요 (아래 "저장과 로드" 참고)
@keras.saving.register_keras_serializable(package="gpt")
class CausalSelfAttention(layers.Layer):
    """과거·현재 토큰만 바라보는 멀티헤드 자기어텐션."""
    def __init__(self, 임베딩차원, 헤드수, **kw):
        super().__init__(**kw)
        self.임베딩차원, self.헤드수 = 임베딩차원, 헤드수
        self.헤드차원 = 임베딩차원 // 헤드수
        self.c_attn = layers.Dense(3 * 임베딩차원, name="c_attn")  # Q,K,V를 한 번에
        self.c_proj = layers.Dense(임베딩차원, name="c_proj")       # 출력 투영

    def get_config(self):   # __init__ 인자를 그대로 돌려줘 재구성 가능하게
        return {**super().get_config(), "임베딩차원": self.임베딩차원, "헤드수": self.헤드수}

    def call(self, x):
        B, T, C = ops.shape(x)[0], ops.shape(x)[1], ops.shape(x)[2]
        q, k, v = ops.split(self.c_attn(x), 3, axis=-1)            # 각각 (B, T, C)

        def 헤드분리(텐서):                                           # (B, T, C) -> (B, 헤드수, T, 헤드차원)
            텐서 = ops.reshape(텐서, (B, T, self.헤드수, self.헤드차원))
            return ops.transpose(텐서, (0, 2, 1, 3))
        q, k, v = 헤드분리(q), 헤드분리(k), 헤드분리(v)

        점수 = ops.matmul(q, ops.transpose(k, (0, 1, 3, 2)))        # 토큰 간 점수 (B, h, T, T)
        점수 = 점수 / ops.sqrt(float(self.헤드차원))                 # 스케일링
        마스크 = ops.tril(ops.ones((T, T)))                        # 하삼각: 미래 토큰 차단
        점수 = ops.where(마스크 == 0, float("-inf"), 점수)
        점수 = ops.softmax(점수, axis=-1)                            # 가중치 분포

        y = ops.matmul(점수, v)                                     # 값의 가중합 (B, h, T, 헤드차원)
        y = ops.transpose(y, (0, 2, 1, 3))
        y = ops.reshape(y, (B, T, C))                              # 헤드 다시 합치기
        return self.c_proj(y)


def 트랜스포머_블록(임베딩차원, 헤드수, name):
    """한 블록을 독립적인 Keras 모델로 — summary에 그대로 보인다."""
    입력 = keras.Input(shape=(None, 임베딩차원))
    정규화1 = layers.LayerNormalization(epsilon=1e-5, name="layer_norm1")(입력)
    어텐션 = CausalSelfAttention(임베딩차원, 헤드수, name="attn")(정규화1)
    x = 입력 + 어텐션                                                      # 잔차 연결 1

    정규화2 = layers.LayerNormalization(epsilon=1e-5, name="layer_norm2")(x)
    은닉 = layers.Dense(4 * 임베딩차원, name="c_fc")(정규화2)              # 4배 확장
    은닉 = ops.gelu(은닉, approximate=True)                                # GELU (GPT-2의 tanh 근사)
    은닉 = layers.Dense(임베딩차원, name="c_proj")(은닉)                   # 다시 축소
    x = x + 은닉                                                           # 잔차 연결 2
    return keras.Model(입력, x, name=name)

블록 하나를 독립적인 모델로 만들었으니, summary() 로 레이어가 어떻게 쌓이는지 바로 볼 수 있습니다. Connected to 열의 add 가 잔차 연결이 어디서 더해지는지 보여줍니다.

트랜스포머_블록(설정.임베딩차원, 설정.헤드수, "block").summary()
Model: "block"
┃ Layer (type)          ┃ Output Shape       ┃     Param # ┃ Connected to       
│ input_layer           │ (None, None, 768)  │           0 │ -                  
│ (InputLayer)          │                    │             │                    
├───────────────────────┼────────────────────┼─────────────┼────────────────────
│ layer_norm1           │ (None, None, 768)  │       1,536 │ input_layer[0][0]  
│ (LayerNormalization)  │                    │             │                    
├───────────────────────┼────────────────────┼─────────────┼────────────────────
│ attn                  │ (None, None, 768)  │   2,362,368 │ layer_norm1[0][0]  
│ (CausalSelfAttention) │                    │             │                    
├───────────────────────┼────────────────────┼─────────────┼────────────────────
│ add (Add)             │ (None, None, 768)  │           0 │ input_layer[0][0], 
│                       │                    │             │ attn[0][0]         
├───────────────────────┼────────────────────┼─────────────┼────────────────────
│ layer_norm2           │ (None, None, 768)  │       1,536 │ add[0][0]          
│ (LayerNormalization)  │                    │             │                    
├───────────────────────┼────────────────────┼─────────────┼────────────────────
│ c_fc (Dense)          │ (None, None, 3072) │   2,362,368 │ layer_norm2[0][0]  
├───────────────────────┼────────────────────┼─────────────┼────────────────────
│ gelu (Gelu)           │ (None, None, 3072) │           0 │ c_fc[0][0]         
├───────────────────────┼────────────────────┼─────────────┼────────────────────
│ c_proj (Dense)        │ (None, None, 768)  │   2,360,064 │ gelu[0][0]         
├───────────────────────┼────────────────────┼─────────────┼────────────────────
│ add_1 (Add)           │ (None, None, 768)  │           0 │ add[0][0],         
│                       │                    │             │ c_proj[0][0]       
└───────────────────────┴────────────────────┴─────────────┴────────────────────
 Total params: 7,087,872 (27.04 MB)

마지막으로 임베딩과 디코더 블록 12개(keras.Sequential)를 결합하고, 계층 정규화와 (토큰 임베딩과 가중치를 공유하는) 출력 헤드를 붙이면 GPT-2입니다.

def 출력_헤드(임베딩, 임베딩차원, name="head"):
    """계층 정규화 + (토큰 임베딩과 가중치를 공유하는) 언어모델 투영을 한 모듈로."""
    입력 = keras.Input(shape=(None, 임베딩차원))
    x = layers.LayerNormalization(epsilon=1e-5, name="layer_norm")(입력)
    공유가중치 = ops.transpose(임베딩.get_layer("wte").embeddings)   # weight tying
    로짓 = ops.matmul(x, 공유가중치)
    return keras.Model(입력, 로짓, name=name)


def GPT2(어휘수, 최대길이, 임베딩차원, 층수, 헤드수):
    토큰 = keras.Input(shape=(None,), dtype="int32")

    임베딩 = 임베딩_모듈(어휘수, 최대길이, 임베딩차원)
    x = 임베딩(토큰)                  # 토큰 -> 벡터

    디코더 = keras.Sequential(
        [트랜스포머_블록(임베딩차원, 헤드수, name=f"block_{i}") for i in range(층수)],
        name=f"decoder_x{층수}")
    x = 디코더(x)                     # 디코더 블록 12층

    헤드 = 출력_헤드(임베딩, 임베딩차원)
    로짓 = 헤드(x)                    # 계층 정규화 + 출력 헤드
    return keras.Model(토큰, 로짓, name="gpt2")
... (레이어별 표 생략) ...
model = GPT2(**설정._asdict())
model.summary()
Model: "gpt2"
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
│ input_layer (InputLayer)        │ (None, None)           │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ embedding (Functional)          │ (None, None, 768)      │    39,383,808 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ decoder_x12 (Sequential)        │ (None, None, 768)      │    85,054,464 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ head (Functional)               │ (None, None, 50257)    │         1,536 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 124,439,808 (474.70 MB)
 Trainable params: 124,439,808 (474.70 MB)
 Non-trainable params: 0 (0.00 B)

model.summary()의 총 파라미터가 약 1억 2,400만(124M)으로, GPT-2 base와 정확히 일치합니다. 단, 이 가중치는 아직 무작위 초기값입니다. 이제 진짜 GPT-2의 지식을 이식합니다.

1.4전체 구조 한눈에 보기

함수형 모델로 만들었으므로 keras.utils.plot_model 이 구조를 그대로 그려 줍니다.

keras.utils.plot_model(model, show_shapes=True)
GPT-2의 전체 구조. 입력 토큰이 임베딩(768차원)을 거쳐 디코더 블록 12개(decoder_x12)를 통과한 뒤, 계층 정규화와 (임베딩과 가중치를 공유하는) 출력 헤드(head)를 지나 어휘 크기(50,257)의 로짓이 된다.

GPT-2의 전체 구조. 입력 토큰이 임베딩(768차원)을 거쳐 디코더 블록 12개(decoder_x12)를 통과한 뒤, 계층 정규화와 (임베딩과 가중치를 공유하는) 출력 헤드(head)를 지나 어휘 크기(50,257)의 로짓이 된다.

1.5가중치 이식 — HF의 가중치 파일을 직접 읽기

OpenAI GPT-2의 학습된 가중치는 누구나 내려받을 수 있는 정적 파일(pytorch_model.bin)로 공개돼 있습니다. 우리는 이 파일만 가져와 텐서를 읽습니다 — 모델을 만들어 주는 transformers 라이브러리는 쓰지 않습니다. 파일 다운로드는 Keras 내장 유틸리티로, 텐서 로딩은 백엔드인 PyTorch의 torch.load로 처리합니다.

import torch

URL = "https://huggingface.co/openai-community/gpt2/resolve/main/pytorch_model.bin"
경로 = keras.utils.get_file("gpt2-pytorch_model.bin", URL)

# state_dict: {텐서이름: 가중치} 형태의 평범한 딕셔너리
상태사전 = {이름: 텐서.numpy() for 이름, 텐서 in
        torch.load(경로, map_location="cpu", weights_only=True).items()}

print("텐서 개수:", len(상태사전))
for 이름 in ["wte.weight", "wpe.weight", "h.0.attn.c_attn.weight", "ln_f.weight"]:
    print(f"  {이름:28s} {상태사전[이름].shape}")
텐서 개수: 160
  wte.weight                   (50257, 768)
  wpe.weight                   (1024, 768)
  h.0.attn.c_attn.weight       (768, 2304)
  ln_f.weight                  (768,)

체크포인트의 가중치 이름이 우리 부품과 자연스럽게 대응합니다(wte·wpe 임베딩, h.0.attn.c_attn 등 블록 내부, ln_f 계층 정규화). 대응 관계대로 set_weights로 부어 넣기만 하면 됩니다. GPT-2의 선형층은 PyTorch Conv1D 규약이라 가중치가 (입력, 출력) 모양인데, 이는 Keras Dense의 커널 모양과 동일하므로 전치 없이 그대로 복사합니다(자세한 주의점은 뒤의 “이식의 함정” 참고).

def 이식(model, 상태사전, 접두=""):          # 접두: 다른 체크포인트(KoGPT2) 재사용용
    임베딩 = model.get_layer("embedding")
    임베딩.get_layer("wte").set_weights([상태사전[접두+"wte.weight"]])
    임베딩.get_layer("position").wpe.set_weights([상태사전[접두+"wpe.weight"]])
    model.get_layer("head").get_layer("layer_norm").set_weights([상태사전[접두+"ln_f.weight"], 상태사전[접두+"ln_f.bias"]])
    for i, 블록 in enumerate(model.get_layer("decoder_x12").layers):   # Sequential 의 블록 12개
        접두사 = f"{접두}h.{i}."
        블록.get_layer("layer_norm1").set_weights([상태사전[접두사+"ln_1.weight"], 상태사전[접두사+"ln_1.bias"]])
        블록.get_layer("layer_norm2").set_weights([상태사전[접두사+"ln_2.weight"], 상태사전[접두사+"ln_2.bias"]])
        블록.get_layer("attn").c_attn.set_weights([상태사전[접두사+"attn.c_attn.weight"], 상태사전[접두사+"attn.c_attn.bias"]])
        블록.get_layer("attn").c_proj.set_weights([상태사전[접두사+"attn.c_proj.weight"], 상태사전[접두사+"attn.c_proj.bias"]])
        블록.get_layer("c_fc").set_weights([상태사전[접두사+"mlp.c_fc.weight"], 상태사전[접두사+"mlp.c_fc.bias"]])
        블록.get_layer("c_proj").set_weights([상태사전[접두사+"mlp.c_proj.weight"], 상태사전[접두사+"mlp.c_proj.bias"]])

이식(model, 상태사전)
print("이식 완료 — 이제 진짜 GPT-2입니다.")
이식 완료 — 이제 진짜 GPT-2입니다.

1.6생성 — 한 토큰씩 이어쓰기

언어모델의 출력은 "다음 토큰의 확률 분포(로짓)"입니다. 텍스트를 만들려면 마지막 위치의 로짓 \to 다음 토큰 선택 \to 입력에 붙이기를 반복합니다(자기회귀, autoregressive). 가장 단순한 선택은 매번 최댓값을 고르는 그리디(greedy) 방식입니다.

def 생성(model, encode, decode, 프롬프트, 길이, 온도=1.0, top_k=None, seed=0):
    rng = np.random.default_rng(seed)
    ids = encode(프롬프트)
    for _ in range(길이):
        x = np.array([ids[-설정.최대길이:]], dtype="int64")
        로짓 = model.predict(x, verbose=0)[0, -1]      # 마지막 위치 로짓 (어휘수,)
        if 온도 == 0:                                     # 그리디: 최댓값
            다음 = int(로짓.argmax())
        else:                                             # 샘플링: 온도/top-k
            로짓 = 로짓 / 온도
            if top_k:
                컷 = np.sort(로짓)[-top_k]
                로짓 = np.where(로짓 < 컷, -np.inf, 로짓)
            확률 = np.exp(로짓 - 로짓.max()); 확률 /= 확률.sum()
            다음 = int(rng.choice(len(확률), p=확률))
        ids.append(다음)
    return decode(ids)

print(생성(model, enc.encode, enc.decode, "The quick brown fox jumps over the lazy", 8, 온도=0))
print(생성(model, enc.encode, enc.decode, "Artificial intelligence is", 30, 온도=0))
The quick brown fox jumps over the lazy, lazy fox and they both fall to
Artificial intelligence is a new field of research that has been in the works for a while now. It is a field that has been in the works for a while now

1.7검증 — transformers 없이 올바름을 확인

이식이 정말 정확한지(한 글자도 틀리지 않았는지) 확인합니다. 정답을 외부 라이브러리로 비교하는 대신, 진짜 GPT-2에서 미리 기록해 둔 기준값(golden values) 과 맞춰 봅니다. 같은 프롬프트에서 마지막 위치의 최상위 후보 토큰들과 최대 로짓이 일치하면, 모든 층의 가중치가 제자리에 들어갔다는 강한 증거입니다.

x = np.array([enc.encode("The quick brown fox jumps over the lazy")], dtype="int64")
로짓 = model.predict(x, verbose=0)[0, -1]

top5 = 로짓.argsort()[-5:][::-1]
print("상위 5개 토큰:", [enc.decode([int(t)]) for t in top5])
print("최대 로짓    :", round(float(로짓.max()), 4))

# 진짜 GPT-2에서 얻은 기준값 (이 장과 독립적으로 기록)
assert list(top5) == [11, 21831, 7586, 2330, 3290]          # ',', ' fox', ' brown', ' white', ' dog'
assert abs(float(로짓.max()) - (-80.9343)) < 0.05
print("\n검증 통과 — 우리 Keras 모델은 진짜 GPT-2와 동일하게 동작합니다.")
상위 5개 토큰: [',', ' fox', ' brown', ' white', ' dog']
최대 로짓    : -80.9343

검증 통과 — 우리 Keras 모델은 진짜 GPT-2와 동일하게 동작합니다.

1.8모델 저장과 로드

가중치 이식은 한 번만 하면 됩니다. 이식한 모델을 파일로 저장해 두면, 다음부터는 다운로드·이식 과정 없이 곧바로 불러올 수 있습니다. Keras의 표준 형식인 .keras 한 파일에 구조 + 가중치 가 함께 담깁니다.

# 1) 전체 저장(.keras): 구조 + 가중치를 한 파일로
model.save("gpt2.keras")

# 2) 로드: transformers 도, 가중치 이식도 다시 필요 없음
model2 = keras.models.load_model("gpt2.keras")

# 3) 로드한 모델이 원본과 동일한지 확인
import numpy as np
x = np.array([enc.encode("The quick brown fox jumps over the lazy")], dtype="int64")
assert np.allclose(model.predict(x, verbose=0),
                   model2.predict(x, verbose=0), atol=1e-5)
print("로드한 모델 == 원본 모델")
로드한 모델 == 원본 모델

가중치만 따로 저장하는 방법(model.save_weights("gpt2.weights.h5"))도 있습니다. 이때는 파일에 구조가 없으므로, 로드 전에 GPT2(**설정._asdict())같은 구조를 코드로 먼저 만든 뒤 load_weights 로 채웁니다. 이 경우 get_config 는 필요 없지만, 모델을 만드는 코드가 항상 있어야 합니다.

여기까지가 "결과"입니다. 무작위로 초기화된 우리 구조에 공개 가중치를 부어 넣었더니, 외부 모델링 라이브러리 없이도 GPT-2가 그대로 재현되었습니다. 이제 각 부품이 그렇게 생겼는지 하나씩 자세히 들여다봅니다. 방금 생성에 직접 쓴 언어모델 헤드부터 시작합니다.

2뜯어보기 ① — 언어모델 헤드와 가중치 공유

모델의 마지막 줄을 다시 봅니다.

return ops.matmul(x, ops.transpose(self.임베딩.get_layer("wte").embeddings))

3뜯어보기 ② — 계층 정규화와 잔차 스트림

헤드 바로 앞에는 계층 정규화(layer_norm) 가 있고, 그 앞은 12개 블록이 만들어 온 잔차 스트림(residual stream) 입니다. 블록의 구조를 다시 보면 핵심이 보입니다.

x = 입력 + 어텐션(정규화1)   # 잔차 연결 1
x = x + 피드포워드(정규화2)                     # 잔차 연결 2

4뜯어보기 ③ — 인과적 자기어텐션 (핵심)

블록의 첫 부품이자 트랜스포머의 심장입니다. “각 토큰이 이전 토큰들을 둘러보고 필요한 정보를 가져오는” 연산입니다.

T = 5
마스크 = ops.tril(ops.ones((T, T)))
print(ops.convert_to_numpy(마스크).astype(int))   # 1=참조 가능(과거·현재), 0=차단(미래)
[[1 0 0 0 0]
 [1 1 0 0 0]
 [1 1 1 0 0]
 [1 1 1 1 0]
 [1 1 1 1 1]]

이 한 연산 덕분에 모델은 멀리 떨어진 단어 사이의 관계(예: 대명사와 그 지시 대상)를 거리와 무관하게 직접 연결할 수 있습니다.

5뜯어보기 ④ — 위치별 피드포워드와 GELU

어텐션이 "토큰 간 정보 교환"이라면, MLP는 각 토큰을 독립적으로 비선형 변환하는 “생각하는” 단계입니다. 차원을 4배(768\to3072)로 넓혀 표현력을 키운 뒤 다시 좁힙니다.

샘플 = ops.convert_to_tensor([-3.0, -1.0, 0.0, 1.0, 3.0])
print("gelu_new   :", np.round(ops.convert_to_numpy(ops.gelu(샘플, approximate=True)), 4))
print("정확한 gelu:", np.round(ops.convert_to_numpy(ops.gelu(샘플, approximate=False)), 4))
gelu_new   : [-0.0036 -0.1588  0.      0.8412  2.9964]
정확한 gelu: [-0.004  -0.1587  0.      0.8413  2.996 ]

6뜯어보기 ⑤ — 임베딩

마지막으로 스트림의 출발점입니다.

토큰임베딩 = layers.Embedding(어휘수, 임베딩차원)(토큰들)   # wte
x = 토큰임베딩 + self.wpe(ops.arange(0, T))                # + 위치(wpe)

7이식의 함정 — 진짜 가중치를 옮길 때 어긋나는 네 곳

우리 모델이 단번에 GPT-2와 일치했던 것은 다음 네 가지를 정확히 맞췄기 때문입니다. 하나라도 틀리면 “그럴듯하지만 미묘하게 다른” 모델이 됩니다(문장만 보면 알아채기 어렵습니다 — 그래서 로짓 기준값으로 검증했습니다).

항목함정올바른 선택
GELU정확한(erf) GELU를 쓰면 어긋남approximate=True (gelu_new)
LayerNorm ε\varepsilonKeras 기본 10-310-5 로 명시
선형층 가중치 방향반사적으로 전치하면 틀림Conv1D는 (입력, 출력) = Dense 커널과 동일 \to 그대로 복사
가중치 공유헤드용 별도 행렬을 만들면 다름헤드 = wte 전치, 위치는 학습형(wpe)

세 번째가 특히 헷갈립니다. nanoGPT 같은 구현이 가중치를 전치하는 것은 PyTorch nn.Linear 가 (출력, 입력) 모양이기 때문이며, 우리처럼 Keras Dense(= (입력, 출력))에 GPT-2의 Conv1D( (입력, 출력) ) 가중치를 넣을 때는 전치하지 않습니다. 레이어 규약을 확인하고 옮기는 것이 핵심입니다.

8한국어로 — KoGPT2

지금까지 만든 GPT-2 코드는 영어 전용이 아닙니다. 가중치와 토크나이저만 바꿔 끼우면 한국어 GPT-2가 됩니다 — 모델 코드는 한 줄도 손대지 않습니다. SKT가 공개한 KoGPT2 가 GPT-2(base)와 구조가 같고 어휘 크기만 다르기 때문입니다(50,257 → 51,200).

8.1같은 모델, 다른 어휘

어휘 크기만 51,200으로 줘서 똑같이 만듭니다(GPT2 는 위에서 정의한 그대로입니다).

한국어_model = GPT2(어휘수=51200, 최대길이=1024, 임베딩차원=768, 층수=12, 헤드수=12)
print("파라미터 수:", 한국어_model.count_params())
파라미터 수: 125164032

8.2가중치 이식 — transformer. 접두사

KoGPT2 가중치도 공개 파일(pytorch_model.bin)로 받습니다. 체크포인트가 언어모델 헤드까지 포함한 형태라, 본체 텐서 이름 앞에 transformer. 접두사가 붙는 것만 다릅니다(transformer.wte.weight 등). 그래서 위의 이식 함수를 접두="transformer."그대로 재사용 합니다.

URL = "https://huggingface.co/skt/kogpt2-base-v2/resolve/main/pytorch_model.bin"
경로 = keras.utils.get_file("kogpt2-pytorch_model.bin", URL)
한국어_상태사전 = {이름: 텐서.numpy() for 이름, 텐서 in
        torch.load(경로, map_location="cpu", weights_only=True).items()}

이식(한국어_model, 한국어_상태사전, 접두="transformer.")
print("이식 완료 — 이제 진짜 KoGPT2입니다.")
이식 완료 — 이제 진짜 KoGPT2입니다.

8.3토크나이저 — 바이트가 아니라 메타스페이스

여기가 영어 GPT-2와 가장 크게 다른 부분입니다. 바이트 페어 인코딩 장의 GPT-2 토크나이저는 텍스트를 UTF-8 바이트 로 바꿔 병합했습니다(그래서 OOV가 없었습니다). KoGPT2는 SentencePiece 계열의 메타스페이스(Metaspace) 방식입니다: 바이트가 아니라 글자 를 병합하고, 공백을 특수 기호 (U+2581)로 표시합니다. 대신 어휘에 없는 글자는 더 쪼갤 수단이 없어 <unk>(미등록 토큰) 가 됩니다 — 바이트 단위 BPE에는 없던 한계입니다.

규칙(어휘와 병합)은 tokenizer.json 한 파일에 들어 있습니다.

import json, urllib.request, unicodedata

TOK_URL = "https://huggingface.co/skt/kogpt2-base-v2/resolve/main/tokenizer.json"
정의 = json.load(urllib.request.urlopen(TOK_URL))["model"]
어휘 = 정의["vocab"]                                   # 토큰 문자열 -> ID
역어휘 = {토큰id: 토큰 for 토큰, 토큰id in 어휘.items()}
순위 = {tuple(규칙.split(" ", 1)): 등수                # 병합 우선순위
       for 등수, 규칙 in enumerate(정의["merges"])}    # 공백 토큰 때문에 1번만 분할
UNK = 어휘["<unk>"]
메타기호 = "\u2581"                                    # ▁ (공백 표시)
print("어휘 크기:", len(어휘), "| 병합 규칙 수:", len(순위))
어휘 크기: 51200 | 병합 규칙 수: 42185

병합 알고리즘은 바이트 페어 인코딩 장의 것과 똑같습니다 — 우선순위가 가장 높은 인접 쌍부터 합칩니다.

def 인접쌍(단어):
    return set(zip(단어[:-1], 단어[1:]))

def bpe(조각):
    단어 = tuple(조각); 쌍 = 인접쌍(단어)
    while 쌍:
        후보 = min(쌍, key=lambda 한쌍: 순위.get(한쌍, float("inf")))
        if 후보 not in 순위:
            break
        앞, 뒤 = 후보; 새단어 = []; i = 0
        while i < len(단어):
            try:
                j = 단어.index(앞, i); 새단어.extend(단어[i:j]); i = j
            except ValueError:
                새단어.extend(단어[i:]); break
            if 단어[i] == 앞 and i < len(단어) - 1 and 단어[i + 1] == 뒤:
                새단어.append(앞 + 뒤); i += 2
            else:
                새단어.append(단어[i]); i += 1
        단어 = tuple(새단어)
        if len(단어) == 1:
            break
        쌍 = 인접쌍(단어)
    return list(단어)

다른 점은 앞단계(전처리)미등록 글자 처리 뿐입니다. 바이트로 바꾸는 대신, NFKC로 정규화하고 공백을 로 바꾼 뒤 로 시작하는 조각으로 나눕니다.

def encode(text):
    text = unicodedata.normalize("NFKC", text)
    meta = 메타기호 + text.replace(" ", 메타기호)             # 공백 -> ▁, 맨 앞에도 ▁
    조각들 = [메타기호 + p for p in meta.split(메타기호) if p]  # ▁로 시작하는 단어들
    return [어휘.get(t, UNK) for 조각 in 조각들 for t in bpe(조각)]

def decode(ids):
    return "".join(역어휘.get(i, "<unk>") for i in ids).replace(메타기호, " ").lstrip()

토큰화 예를 봅니다.

예문 = "딥러닝 언어 모델"
ids = encode(예문)
print("토큰:", [역어휘[i] for i in ids])
print("ID  :", ids)
print("복원:", repr(decode(ids)))
토큰: ['▁', '딥', '러', '닝', '▁언어', '▁모델']
ID  : [739, 7299, 7397, 7180, 10239, 14652]
복원: '딥러닝 언어 모델'

드문 단어 "딥러닝"은 ▁ 딥 러 닝으로 잘게 쪼개지고, 자주 쓰는 “언어”·"모델"은 ▁언어·▁모델 한 토큰입니다. 어휘에 없는 글자는 <unk> 가 됩니다 — 바이트 단위 BPE와의 결정적 차이입니다.

print("∮ 인코딩:", [역어휘[i] for i in encode("∮")])
∮ 인코딩: ['▁', '<unk>']

8.4한국어 이어쓰기

생성도 위의 생성 함수를 그대로 씁니다 — 한국어 토크나이저(encode/decode)만 넘기면 됩니다.

print(생성(한국어_model, encode, decode, "근육이 커지기 위해서는", 20, 온도=0))
근육이 커지기 위해서는 무엇보다 규칙적인 생활습관이 중요하다.
특히, 아침식사는 단백질과 비타민, 무기질 등 영양소가

8.5검증 — transformers 없이

진짜 KoGPT2에서 미리 기록해 둔 기준값과 맞춰 봅니다. 모델과 토크나이저가 모두 정확해야 토큰 ID와 다음 토큰이 일치합니다.

x = np.array([encode("근육이 커지기 위해서는")], dtype="int64")
로짓 = 한국어_model.predict(x, verbose=0)[0, -1]
print("토큰 ID  :", encode("근육이 커지기 위해서는"))
print("다음 토큰:", repr(decode([int(로짓.argmax())])))

assert encode("근육이 커지기 위해서는") == [33245, 10114, 12748, 11357]
assert decode([int(로짓.argmax())]) == "무엇보다"
print("\n검증 통과 — 모델·토크나이저 모두 진짜 KoGPT2와 일치합니다.")
토큰 ID  : [33245, 10114, 12748, 11357]
다음 토큰: '무엇보다'

검증 통과 — 모델·토크나이저 모두 진짜 KoGPT2와 일치합니다.

9정리

References
  1. Radford, A., Narasimhan, K., Salimans, T., & Sutskever, I. (2018). Improving language understanding by generative pre-training. https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf