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