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 npprint(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)의 로짓이 된다.
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생성 — 한 토큰씩 이어쓰기¶
언어모델의 출력은 "다음 토큰의 확률 분포(로짓)"입니다. 텍스트를 만들려면 마지막 위치의 로짓 다음 토큰 선택 입력에 붙이기를 반복합니다(자기회귀, 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 now1.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))히든 스테이트 어휘 로짓. 트랜스포머가 만든 마지막 히든 벡터 () 는 연속 벡터라 그대로 글자로 바꿀 수 없습니다. 어휘 크기 차원의 점수(로짓)로 투영해야 토큰을 고를 수 있습니다. 이 투영을 담당하는 마지막 선형층이 언어모델 헤드(lm_head) 입니다.
가중치 공유(weight tying). GPT-2는 이 헤드를 위해 별도 행렬을 두지 않고 토큰 임베딩 행렬 를 재사용합니다.
입력에서 "토큰 ID 벡터"였던 를, 출력에서는 전치해 "벡터 어휘 점수"로 씁니다. 그래서 우리 코드도 임베딩 모듈 안의
wte가중치를 전치해 곱합니다. 덕분에 파라미터를 만 개 절약하고, 입력·출력 어휘 표현이 한 공간을 공유합니다.forward와 generate의 차이. 우리
model(x)한 번 호출은 모든 위치의 로짓을 한꺼번에 내놓는 forward 입니다. 반면생성(...)은 마지막 위치 로짓만 받아 토큰을 고르고 이어 붙이기를 반복하는 generate 루프입니다. forward의 로짓을 위치별로argmax하면 "각 자리에서의 다음 토큰"을 병렬로 본 것일 뿐, 자연스러운 이어쓰기가 아닙니다.디코딩 전략.
생성()의 인자가 그 선택 규칙입니다.그리디(
온도=0): 항상 최댓값. 결정적이지만 같은 구절을 반복하기 쉽습니다(위 “Artificial intelligence is...” 예시처럼).온도(temperature): 로짓을 로 나눠 분포를 평탄(>1)하거나 뾰족(<1)하게.
top-k: 상위 개 토큰만 남기고 샘플링.
이 디코딩 파라미터들의 이론과 효과는 생성 파라미터 장에서 더 깊이 다룹니다.
3뜯어보기 ② — 계층 정규화와 잔차 스트림¶
헤드 바로 앞에는 계층 정규화(layer_norm) 가 있고, 그 앞은 12개 블록이 만들어 온 잔차 스트림(residual stream) 입니다.
블록의 구조를 다시 보면 핵심이 보입니다.
x = 입력 + 어텐션(정규화1) # 잔차 연결 1
x = x + 피드포워드(정규화2) # 잔차 연결 2잔차 연결(residual connection). 각 부품은 입력
x를 대체 하지 않고 거기에 더할 값 만 계산합니다(x + ...). 그래서x는 입력 임베딩에서 출력까지 끊김 없이 흐르는 "고속도로"가 되고, 각 블록은 그 위에 정보를 조금씩 보태는 역할을 합니다. 이 구조가 깊은 망에서도 그래디언트가 사라지지 않게 해 12층(혹은 수십 층) 학습을 가능하게 합니다.사전 정규화(pre-LN). GPT-2는 정규화를 부품 앞에 둡니다(
attn(정규화1)). 정규화는 각 토큰 벡터를 평균 0·분산 1로 맞춰(아래) 학습을 안정화합니다.여기서 값이 중요합니다. Keras
LayerNormalization의 기본값은 10-3 이라, GPT-2 규약인 10-5 로 명시하지 않으면 출력이 미세하게 어긋납니다(겉보기엔 멀쩡한 문장이 나와 놓치기 쉬운 함정입니다).계층 정규화. 잔차 스트림은 더하기만 반복돼 스케일이 커질 수 있으므로, 헤드에 넣기 직전 마지막으로 한 번 더 정규화해 로짓 스케일을 안정화합니다.
4뜯어보기 ③ — 인과적 자기어텐션 (핵심)¶
블록의 첫 부품이자 트랜스포머의 심장입니다. “각 토큰이 이전 토큰들을 둘러보고 필요한 정보를 가져오는” 연산입니다.
Q·K·V를 한 번에. 입력
x(B, T, C)에c_attn(Dense(3C))을 적용해 질의(Query)·키(Key)·값(Value)을 한꺼번에 만든 뒤 셋으로 나눕니다. 멀티헤드를 위해 각각을헤드수개로 쪼개(B, 헤드수, T, 헤드차원)으로 변형합니다.스케일드 닷-프로덕트. 토큰 의 질의와 토큰 의 키를 내적해 “가 를 얼마나 볼지” 점수를 만들고, 차원이 커질수록 값이 과도해지지 않도록 로 나눕니다.
인과 마스크 . 언어 생성 모델은 미래를 보면 안 됩니다. 토큰 는 만 참고해야 하므로, 점수 행렬의 위쪽 삼각(미래)을 로 막습니다.
ops.tril로 하삼각 1 행렬을 만들고, 나머지를 로 채우면 softmax 후 가중치가 0이 됩니다.
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]]값의 가중합. softmax로 정규화한 점수로 Value를 가중합하면, 각 토큰이 문맥에서 끌어온 정보가 됩니다. 헤드들을 다시 합치고
c_proj로 투영해 잔차 스트림에 더합니다.
이 한 연산 덕분에 모델은 멀리 떨어진 단어 사이의 관계(예: 대명사와 그 지시 대상)를 거리와 무관하게 직접 연결할 수 있습니다.
5뜯어보기 ④ — 위치별 피드포워드와 GELU¶
어텐션이 "토큰 간 정보 교환"이라면, MLP는 각 토큰을 독립적으로 비선형 변환하는 “생각하는” 단계입니다. 차원을 4배(7683072)로 넓혀 표현력을 키운 뒤 다시 좁힙니다.
GELU 활성화, 그중
gelu_new. GPT-2는 ReLU가 아니라 GELU를, 그것도 tanh 근사 버전(gelu_new)을 씁니다.Keras에서는
ops.gelu(x, approximate=True)가 이 식입니다.approximate=False(오차함수 기반 정확한 GELU)를 쓰면 문장은 그럴듯하게 나오지만 진짜 GPT-2와 로짓이 어긋납니다 — 이식 검증을 통과하려면 반드시 근사 버전을 써야 합니다.
샘플 = 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)토큰 임베딩
wte. 토큰 ID를 768차원 벡터로 바꾸는 조회표 입니다. 앞서 봤듯 이 행렬은 출력 헤드와 공유됩니다.위치 임베딩
wpe, 학습형. 어텐션은 순서를 모르므로(집합처럼 본다) 위치 정보를 더해 줘야 합니다. 원조 트랜스포머는 사인·코사인 함수를 썼지만, GPT-2는 위치마다의 벡터를 직접 학습합니다(Embedding(최대길이, 768)). 그래서 문맥 길이가 학습된 최대치(1,024)로 제한됩니다.둘을 더해 잔차 스트림의 초깃값으로 삼고, 이것이 12개 블록을 지나 다시 헤드로 돌아옵니다.
7이식의 함정 — 진짜 가중치를 옮길 때 어긋나는 네 곳¶
우리 모델이 단번에 GPT-2와 일치했던 것은 다음 네 가지를 정확히 맞췄기 때문입니다. 하나라도 틀리면 “그럴듯하지만 미묘하게 다른” 모델이 됩니다(문장만 보면 알아채기 어렵습니다 — 그래서 로짓 기준값으로 검증했습니다).
| 항목 | 함정 | 올바른 선택 |
|---|---|---|
| GELU | 정확한(erf) GELU를 쓰면 어긋남 | approximate=True (gelu_new) |
| LayerNorm | Keras 기본 10-3 | 10-5 로 명시 |
| 선형층 가중치 방향 | 반사적으로 전치하면 틀림 | Conv1D는 (입력, 출력) = Dense 커널과 동일 그대로 복사 |
| 가중치 공유 | 헤드용 별도 행렬을 만들면 다름 | 헤드 = 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())파라미터 수: 1251640328.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정리¶
GPT-2(124M)를 Keras 커스텀 레이어로 처음부터 조립하고, OpenAI 공개 가중치 파일을 직접 읽어 이식했습니다.
transformers라이브러리 없이 동작하며, 진짜 GPT-2와 로짓이 일치함을 기준값으로 검증했습니다.전체로 보면 GPT-2는 임베딩(토큰+학습형 위치) [정규화·인과 어텐션·잔차 / 정규화·GELU MLP·잔차] 계층 정규화 공유 가중치 헤드 의 단순·규칙적 구조입니다.
그리고 같은 코드 에 한국어 가중치(
transformer.접두사)와 메타스페이스 토크나이저만 바꿔 끼워 KoGPT2(한국어 GPT-2) 도 그대로 재현했습니다 — 모델·생성 코드는 그대로 두고 "지식"만 교체했습니다.
- 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