GPT를 밑바닥부터: microgpt.py 완전 해설
Andrej Karpathy의 microgpt.py (2026.02) — 순수 파이썬(os, math, random) 약 243줄, 외부 ML 라이브러리 0개. 저자 소개, 데이터 파이프라인, 토크나이저, Autograd 엔진, GPT 아키텍처, 학습 루프, Adam 옵티마이저, 추론, 현대 LLM 비교까지 한 페이지에서 완전 해설합니다.
개요
microgpt.py는 Andrej Karpathy가 2026년 2월 공개한 "가장 원자적인 GPT 구현"입니다.
os, math, random 세 표준 모듈만으로 GPT를 완성합니다.
| 항목 | 값 | 의미 |
|---|---|---|
| 파라미터 | 4,192개 | 학습 가능한 숫자 전체 |
| 코드 줄 수 | 약 243줄 | 데이터부터 추론까지 한 파일 |
| 학습 문서(이름) | 32,033개 | names.txt 영어 이름 데이터셋 |
| 외부 ML 라이브러리 | 0개 | os, math, random만 사용 |
| 학습 결과 | loss 3.30 → 2.37 | Perplexity 27 → 10.7 (약 2.5배 향상) |
이 페이지는 다음 순서로 해설합니다:
저자 — Andrej Karpathy
- 학력
- Stanford PhD — Fei-Fei Li 지도 하에 Computer Vision · Deep Learning 전공. 박사 논문 주제: "Connecting Images and Natural Language" (이미지-언어 연결). 재학 중 Google Brain · DeepMind에서 인턴.
- 커리어
-
OpenAI 창립 멤버(2015) — GPT 시리즈 초기 연구에 직접 참여.
Tesla AI 디렉터(2017~2022) — Autopilot의 신경망 인식 시스템 총괄. "Data Engine" 개념(모델이 스스로 학습 데이터를 생성)을 실제 자율주행에 적용.
OpenAI 복귀(2023) — AGI 안전성 연구.
독립(2024~) — AI 교육 콘텐츠에 집중. - 교육 철학
- "Everything else is just efficiency" — 복잡한 AI를 의존성 없는 가장 원자적 형태로 단순화함으로써, 블랙박스를 투명하게 만드는 교육자. "이해하려면 직접 만들어봐야 한다"는 원칙으로 수만 명에게 딥러닝의 문을 열었습니다.
프로젝트 계보 — 10년의 단순화 여정
각 프로젝트는 "한 가지를 더 단순하게"라는 목표로 이어집니다.
| 프로젝트 | 연도 | 핵심 아이디어 | microgpt와의 관계 |
|---|---|---|---|
| micrograd | 2020 | 스칼라 Autograd — Value 클래스로 역전파 구현 | microgpt Value 클래스의 직접적 원형 |
| makemore | 2022 | 문자 수준 언어 모델, names.txt 데이터셋 | 데이터셋·토크나이저 구조 계승 |
| nanoGPT | 2022 | PyTorch+GPU 기반 실용 GPT 훈련 코드베이스 — GPT-2(124M) 완전 재현, Shakespeare 예제, DDP 분산학습, Flash Attention | GPT 아키텍처의 "Production 최소 구현" — microgpt가 배운 원리를 실제 규모로 확장 |
| llm.c | 2024 | C/CUDA로 LLM 훈련 — NumPy·PyTorch 없이 | 의존성 제거 철학 계승 |
| microgpt | 2026 | 순수 파이썬 약 243줄 — 모든 것을 하나의 파일에 | 집대성 |
전체 코드 흐름
【입력】 names.txt (32,033개 이름)
│
▼
【Phase 1 — 데이터 파이프라인】 (~20줄)
├─ import os/math/random
├─ random.shuffle(docs) ← 학습 순서 무작위화
├─ uchars = sorted(set(''.join(docs))) ← 고유 문자 추출
└─ vocab_size = 27 (a~z + BOS)
│ 각 이름 "emma" → [26, 4, 12, 12, 0, 26]
▼
【Phase 2 — Autograd 엔진】 (~40줄)
├─ class Value: data, grad, _children, _local_grads
├─ 6가지 연산: add, mul, pow, log, exp, relu
└─ backward(): 위상 정렬 → 연쇄 법칙 적용
│ 모든 숫자가 미분 가능한 Value 객체가 됨
▼
【Phase 3 — 신경망 정의】 (~50줄)
├─ state_dict: 4,192개 Value 파라미터
├─ linear(), softmax(), rmsnorm()
└─ gpt(token_id, pos_id, keys, values) → logits[27]
│ 토큰 → 다음 토큰 확률 분포
▼
【Phase 4 — 학습 루프 (1,000 스텝)】 (~30줄)
├─ 순전파: loss = CrossEntropy(gpt(tokens), targets)
├─ 역전파: loss.backward()
├─ Adam: p.data -= lr * m_hat / (v_hat**0.5 + ε)
└─ lr 선형 감쇠: 0.01 → 0.00001
│ loss: 3.30 → 2.37
▼
【Phase 5 — 추론 (이름 생성)】
BOS → 't' → 'o' → 'm' → BOS → "tom"
microgpt.py 5단계 파이프라인: 데이터·토크나이저 → Autograd → 신경망 정의 → 학습 → 추론
임포트 & 데이터 로딩
microgpt.py가 사용하는 임포트는 단 세 줄입니다. ML 라이브러리가 없다는 것 자체가 이 프로젝트의 핵심 철학입니다.
# 수학적 원리를 라이브러리 뒤에 숨기지 않고 직접 보여주기 위함
import os # os.path.exists — 파일 존재 여부 확인
import math # math.log, math.exp — 수학 함수
import random # random.seed, random.gauss, random.choices
random.seed(42) # 재현 가능한 결과 — 동일한 random.seed → 동일한 학습 결과
데이터셋 — makemore 저장소
데이터 출처: names.txt는 Karpathy의 makemore 프로젝트의 영어 이름 데이터셋입니다.
약 32,000개의 고유 이름이 포함되어 있습니다.
if not os.path.exists('input.txt'):
import urllib.request
names_url = 'https://raw.githubusercontent.com/karpathy/makemore/988aa59/names.txt'
urllib.request.urlretrieve(names_url, 'input.txt')
docs = [line.strip() for line in open('input.txt') if line.strip()]
random.shuffle(docs) # 순서 섞기 — 학습 편향 방지
print(f"num docs: {len(docs)}") # → num docs: 32033
input.txt — 각 줄이 하나의 이름(= 하나의 훈련 문서):
emma ← 4글자
olivia ← 6글자
ava ← 3글자
isabella ← 8글자
sophia ← 6글자
...
(총 32,033줄)
| 특성 | 값 | 이유 |
|---|---|---|
| 총 이름 수 | 32,033개 | 미국 신생아 이름 통계 기반 |
| 문자 종류 | 소문자 a~z (26종) | 대소문자·숫자·특수문자 없음 |
| 최단 이름 | 2글자 (예: "jo") | – |
| 최장 이름 | 15글자 | block_size=16 설정 근거 |
| 평균 길이 | 약 5~6글자 | – |
random.shuffle — 왜 섞는가?
학습 루프는 docs[step % len(docs)]로 순환합니다.
섞지 않으면 처음 1,000 스텝 동안 항상 같은 이름 순서로 학습하게 됩니다.
데이터셋의 앞부분에 특정 패턴(예: 짧은 이름, 특정 알파벳으로 시작하는 이름)이
몰려 있으면 모델이 그 패턴에 편향됩니다.
random.seed(42) # ← 먼저 시드를 설정한 뒤
random.shuffle(docs) # ← 섞어야 재현 가능한 순서가 됩니다
배치 크기 1 — 왜 이름 하나씩 학습하는가?
microgpt는 한 번에 이름 하나(batch_size=1)를 학습합니다. 배치 크기가 크면 GPU 메모리와 병렬 연산이 필요하지만, microgpt는 CPU에서 스칼라 연산으로 작동하므로 배치 크기 1이 가장 단순합니다.
왜 이름 데이터인가?
- 검증이 쉽다: 생성된 이름이 발음 가능한지 사람이 즉시 판단 가능.
- 규모가 적당하다: 32,033개 × 평균 5글자 = 약 160,000 토큰 — 노트북으로 1분 내 학습.
- 패턴이 명확하다: 이름은 모음-자음 교대, 특정 접미사(-a, -on, -er) 등 학습 가능한 통계 패턴이 있음.
- 범용성: "이름"이나 "대화"나 GPT 입장에서는 모두 토큰 시퀀스. 원리는 동일.
토크나이저 구축
텍스트를 숫자로, 숫자를 다시 텍스트로 변환하는 번역기를 만듭니다. microgpt.py는 문자 수준(character-level) 토크나이저를 사용합니다.
# 모든 이름을 합쳐 고유한 문자만 추출 (정렬)
uchars = sorted(set(''.join(docs)))
# 결과: ['a', 'b', 'c', ..., 'z'] → 26개 소문자
BOS = len(uchars) # = 26, Beginning of Sequence 특수 토큰 ID
vocab_size = len(uchars) + 1 # = 27 (a~z 26개 + BOS 1개)
# 인코딩: uchars.index(ch) 디코딩: uchars[token_id]
# 예시: "emma" → [BOS, e, m, m, a, BOS]
name = "emma"
tokens = [BOS] + [uchars.index(ch) for ch in name] + [BOS]
# → [26, 4, 12, 12, 0, 26]
# 디코딩: 정수 → 문자 (BOS 제외)
decoded = ''.join(uchars[t] for t in tokens if t != BOS)
# → "emma"
BOS 래핑 — 문서 경계 표현
"emma" → tokens = [26, 4, 12, 12, 0, 26]
【학습 입력/타겟 쌍】
pos 0: 입력=BOS(26) → 타겟='e'(4) ← "이름의 시작엔 무엇이 오는가?"
pos 1: 입력='e'(4) → 타겟='m'(12) ← "e 다음엔 무엇이 오는가?"
pos 2: 입력='m'(12) → 타겟='m'(12) ← "em 다음엔?"
pos 3: 입력='m'(12) → 타겟='a'(0) ← "emm 다음엔?"
pos 4: 입력='a'(0) → 타겟=BOS(26) ← "emma 다음엔? → 끝!"
"emma" 토크나이저 변환 파이프라인: 문자열 → 문자 분해 → 인덱스 조회 → BOS 래핑 → 토큰 시퀀스
토큰 ID 구조 — 27개 심볼
| ID | 심볼 | ID | 심볼 | ID | 심볼 |
|---|---|---|---|---|---|
| 0 | 'a' | 9 | 'j' | 18 | 's' |
| 1 | 'b' | 10 | 'k' | 19 | 't' |
| 2 | 'c' | 11 | 'l' | 20 | 'u' |
| 3 | 'd' | 12 | 'm' | 21 | 'v' |
| 4 | 'e' | 13 | 'n' | 22 | 'w' |
| 5 | 'f' | 14 | 'o' | 23 | 'x' |
| 6 | 'g' | 15 | 'p' | 24 | 'y' |
| 7 | 'h' | 16 | 'q' | 25 | 'z' |
| 8 | 'i' | 17 | 'r' | 26 | BOS |
| 항목 | microgpt (문자 수준) | tiktoken / BPE (프로덕션) |
|---|---|---|
| 어휘집 크기 | 27개 (a~z + BOS) | ~100,000개 |
| 특징 | 구현 단순, 이해 쉬움 | 위치당 더 많은 의미 압축 |
| 인코딩 방식 | uchars.index(ch) 사용 | 자주 쓰이는 문자열 병합(BPE) |
| 인코딩 복잡도 | O(n) 선형 탐색 | O(1) 딕셔너리 룩업 |
| 용도 | 소규모 교육용에 적합 | GPT-4 등 실제 LLM에서 사용 |
흔한 실수들
-
Off-by-One 오류:
어휘집 크기를 26으로 잘못 지정하면 BOS(ID=26)를 임베딩 테이블에서 찾지 못해 IndexError 발생.
wte = matrix(vocab_size, n_embd)에서vocab_size=27이어야 합니다. -
일관성 없는 어휘집:
소문자로 훈련된 모델에 대문자("Emma")를 입력하면
uchars.index('E')가 ValueError를 냅니다. 모든 이름은 소문자 전처리 후 토크나이저에 입력해야 합니다. -
uchars.index() 시간 복잡도:
list.index()는 O(n) 선형 탐색입니다. 어휘 크기가 27개로 작기 때문에 문제없지만, vocab_size가 50,000이라면 반드시 dict 기반 룩업테이블로 바꿔야 합니다.
Autograd 엔진 — 학습의 심장
PyTorch의 핵심 기능인 자동 미분(Automatic Differentiation)을 밑바닥부터 직접 구현합니다.
모든 숫자가 그냥 숫자가 아니라, 자신이 어디서 왔는지를 기억하는 Value 객체가 됩니다.
연쇄 법칙 (Chain Rule)
dy/dx = (dy/du) · (du/dx) — 미적분학의 핵심 — 복합 함수의 미분
self.grad— 전역 미분값- d(Loss)/d(self) — 역전파 시 계산됨
_local_grads— 지역 미분값- d(self)/d(child) — 순전파 시 계산됨
6가지 연산의 지역 기울기 — 한눈에 보기
| 연산 | 순전파 f(a, b) | 지역 기울기 ∂f/∂a | 직관 |
|---|---|---|---|
| add | a + b | 1 (∂f/∂b = 1) | 덧셈은 기울기를 그대로 흘림 |
| mul | a × b | b (∂f/∂b = a) | 상대방 값이 기울기 배율 |
| pow | an | n · an−1 | 지수 법칙 |
| log | ln(a) | 1 / a | 로그의 도함수 |
| exp | ea | ea (자기 자신!) | e^x의 도함수 = e^x |
| relu | max(0, a) | 1 if a > 0 else 0 | 양수만 기울기 통과 |
Value 클래스 전체 코드
class Value:
__slots__ = ('data', 'grad', '_children', '_local_grads')
def __init__(self, data, children=(), local_grads=()):
self.data = data # float: 순전파 결과값
self.grad = 0 # float: d(Loss)/d(self), 역전파 시 채워짐
self._children = children # tuple[Value]: 이 노드를 만든 입력들
self._local_grads = local_grads # tuple[float]: d(self)/d(child)
# ── 핵심 이진 연산 두 개 (나머지는 모두 이 두 개의 조합) ──
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
# f = a + b → ∂f/∂a = 1, ∂f/∂b = 1
return Value(self.data + other.data, (self, other), (1, 1))
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
# f = a × b → ∂f/∂a = b, ∂f/∂b = a
return Value(self.data * other.data, (self, other), (other.data, self.data))
# ── 나머지 기본 연산 ──
def __pow__(self, other):
# f = a^n → ∂f/∂a = n·a^(n-1)
return Value(self.data**other, (self,), (other * self.data**(other-1),))
def log(self):
# f = ln(a) → ∂f/∂a = 1/a
return Value(math.log(self.data), (self,), (1/self.data,))
def exp(self):
# f = e^a → ∂f/∂a = e^a (도함수가 자기 자신!)
return Value(math.exp(self.data), (self,), (math.exp(self.data),))
def relu(self):
# f = max(0, a) → ∂f/∂a = 1 if a>0 else 0
return Value(max(0, self.data), (self,), (float(self.data > 0),))
# ── 파생 연산 (위 연산들의 조합으로 자동 처리) ──
def __neg__(self): return self * -1 # -a = a × (-1)
def __sub__(self, other): return self + (-other) # a - b = a + (-b)
def __rsub__(self, other): return other + (-self) # b - a
def __radd__(self, other): return self + other # b + a (sum()에서 필요)
def __rmul__(self, other): return self * other # b × a
def __truediv__(self, other): return self * other**-1 # a/b = a × b^(-1)
def __rtruediv__(self, other): return other * self**-1 # b/a
__slots__ — 메모리 최적화
파이썬 객체는 기본적으로 __dict__라는 딕셔너리로 속성을 저장합니다.
__slots__를 선언하면 이 딕셔너리 대신 고정된 슬롯(C 배열)을 사용합니다.
| 방식 | 인스턴스당 메모리 | 4,192개 기준 |
|---|---|---|
| __dict__ (기본) | ~200~400 bytes | ~1.5 MB |
| __slots__ (microgpt) | ~100~150 bytes | ~0.6 MB |
학습 중에는 순전파 시 파라미터 수의 수십~수백 배의 중간 Value 노드가 생성됩니다. 메모리 절약 외에도 속성 접근 속도도 향상됩니다.
__radd__ 가 필요한 이유
# Python의 sum()은 내부적으로 0 + first_element 를 실행합니다
# → 0.__add__(Value) → int는 Value를 모르므로 NotImplemented 반환
# → Python이 Value.__radd__(0) 를 대신 호출
# → self + other = Value + 0 → 정상 작동
total = sum(losses) # sum([Value, Value, ...]) → 내부적으로 0 + Value(...) 실행
# __radd__ 없으면 TypeError!
역전파 — backward()
def backward(self):
# 1) 위상 정렬(Topological Sort)로 그래프 순회
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._children:
build_topo(child)
topo.append(v) # 자식 처리 후 자신을 추가 (후위 순회)
build_topo(self)
# 2) 역순으로 기울기 전파
self.grad = 1 # d(Loss)/d(Loss) = 1 (역전파 출발점)
for v in reversed(topo):
for child, local_grad in zip(v._children, v._local_grads):
child.grad += local_grad * v.grad # += 누적! (다변수 연쇄 법칙)
torch.autograd가
내부적으로 수행하는 작업과 동일한 원리입니다.
역전파 단계별 추적 — L = (a * b) + a
a = 2.0, b = 3.0인 경우. 수학적으로 dL/da = b + 1 = 4.0, dL/db = a = 2.0
# ── 순전파 ──
a = Value(2.0) # 파라미터
b = Value(3.0) # 파라미터
c = a * b # c.data=6.0, c._children=(a,b), c._local_grads=(3.0, 2.0)
# ∂c/∂a = b.data = 3.0, ∂c/∂b = a.data = 2.0
L = c + a # L.data=8.0, L._children=(c,a), L._local_grads=(1, 1)
# ∂L/∂c = 1, ∂L/∂a = 1
# ── 위상 정렬 결과 (DFS 후위 순회) ──
# topo = [a, b, c, L] ← 의존성 순서
# reversed(topo) = [L, c, b, a] ← 역전파 순서
# ── 역전파 ──
L.grad = 1 # d(L)/d(L) = 1 (출발점)
# v=L: children=(c,a), local_grads=(1, 1)
# c.grad += 1 * L.grad = 1 * 1 = 1.0
# a.grad += 1 * L.grad = 1 * 1 = 1.0 (L = c + a 에서 a 경로)
# v=c: children=(a,b), local_grads=(3.0, 2.0)
# a.grad += 3.0 * c.grad = 3.0 * 1.0 → a.grad = 1.0 + 3.0 = 4.0
# b.grad += 2.0 * c.grad = 2.0 * 1.0 → b.grad = 0.0 + 2.0 = 2.0
# 최종: a.grad=4.0 ✓, b.grad=2.0 ✓ (수학 계산과 일치)
계산 그래프 시각화
a(2.0) b(3.0)
│ \ │
│ ×─────┘ ← __mul__: local_grads=(b, a)=(3.0, 2.0)
│ \
│ c(6.0)
│ │
└──────+ ← __add__: local_grads=(1, 1)
│
L(8.0) ← loss.backward() 시작점, L.grad=1
역전파 방향: L → c,a → a,b (화살표 반대 방향으로 grad 전파)
L = (a×b) + a 의 계산 그래프와 역전파. a는 두 경로에서 기울기를 받아 grad=4.0(누적)
재귀 위상 정렬의 동작 원리
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._children:
build_topo(child) # 자식들을 먼저 처리
topo.append(v) # 자식 처리 후 자신을 추가 (후위 순회)
# L의 경우:
# build_topo(L)
# build_topo(c) ← L의 child
# build_topo(a) ← c의 child → visited, topo=[a]
# build_topo(b) ← c의 child → visited, topo=[a,b]
# topo=[a,b,c]
# build_topo(a) ← L의 child → already visited, skip
# topo=[a, b, c, L]
# reversed → [L, c, b, a] ← 역전파 순서
a가 두 연산(a*b와 c+a)에서 사용될 때,
각 경로에서 오는 기울기를 더해야(+=) 합니다.
이것이 미적분의 "다변수 연쇄 법칙(multivariable chain rule)"입니다.
PyTorch에서도 동일하게 .grad는 누적됩니다
(이 때문에 매 스텝마다 optimizer.zero_grad()를 호출해야 합니다).
모델 설계도 (Model Blueprint)
microgpt의 하이퍼파라미터를 정의합니다. 이 숫자들이 모델의 크기와 능력을 결정합니다.
# Model Blueprint
n_embd = 16 # 임베딩 차원 (각 토큰을 16차원 벡터로 표현)
n_head = 4 # 어텐션 헤드 수 (16 / 4 = 헤드당 4차원)
n_layer = 1 # 트랜스포머 블록 수
block_size = 16 # 최대 시퀀스 길이 (컨텍스트 윈도우)
head_dim = n_embd // n_head # = 4
| 파라미터 | 값 | 선택 이유 | 더 크게 하면? |
|---|---|---|---|
| n_embd | 16 | 27개 토큰을 표현하기에 충분 | 표현력↑ 속도↓ 메모리↑ |
| n_head | 4 | head_dim = 16/4 = 4 (정수 분할) | 다양한 어텐션 패턴 가능↑ |
| n_layer | 1 | 단순성 최대화, 이름 생성엔 충분 | 더 깊은 추론 가능↑ |
| block_size | 16 | 최장 이름이 15글자 + BOS | 더 긴 시퀀스 처리 가능 |
| head_dim | 4 | = n_embd / n_head (파생값) | 어텐션 해상도↑ |
n = min(block_size, len(tokens) - 1)으로 잘립니다.
파라미터 계산 — 4,192개의 숫자
| 레이어 | Shape | 파라미터 수 |
|---|---|---|
| wte (토큰 임베딩) | 27 × 16 | 432 |
| wpe (위치 임베딩) | 16 × 16 | 256 |
| lm_head (언임베딩) | 27 × 16 | 432 ※ |
| attn_wq (Query) | 16 × 16 | 256 |
| attn_wk (Key) | 16 × 16 | 256 |
| attn_wv (Value) | 16 × 16 | 256 |
| attn_wo (출력 프로젝션) | 16 × 16 | 256 |
| mlp_fc1 (확장) | 64 × 16 | 1,024 |
| mlp_fc2 (축소) | 16 × 64 | 1,024 |
| 총합 | — | 4,192 |
wte와 lm_head는
각각 별도로 초기화되어 있지만, 개념적으로 공유 가능합니다.
nanoGPT에서는 명시적으로 lm_head.weight = transformer.wte.weight로 공유하여
432개 파라미터를 절약합니다. 이 아이디어는 Press & Wolf (2016)에서 제안된 것으로,
입력 임베딩과 출력 임베딩이 같은 공간에 있다는 직관에 기반합니다.RMSNorm 파라미터 없음: microgpt의
rmsnorm(x)는 학습 가능한 γ(scale) 파라미터 없이
정규화만 수행합니다. LLaMA 등 프로덕션 모델의 RMSNorm과의 차이입니다.
state_dict — 가중치 초기화
# N(0, 0.08²) 가우시안으로 초기화된 행렬 생성 함수
matrix = lambda nout, nin, std=0.08: \
[[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)]
state_dict = {
'wte': matrix(vocab_size, n_embd), # 27×16 = 432
'wpe': matrix(block_size, n_embd), # 16×16 = 256
'lm_head': matrix(vocab_size, n_embd), # 27×16 = 432
}
for i in range(n_layer):
state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd) # 16×16
state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd)
state_dict[f'layer{i}.mlp_fc1'] = matrix(4*n_embd, n_embd) # 64×16
state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4*n_embd) # 16×64
params = [p for mat in state_dict.values() for row in mat for p in row]
# len(params) == 4192
std=0.08 선택 이유 — Xavier vs He vs microgpt
# 표준 초기화 비교 (n_in=16, n_out=16인 경우)
# Xavier (Glorot) 초기화: 순전파/역전파 분산 균형
# std = sqrt(2 / (n_in + n_out)) = sqrt(2/32) ≈ 0.25
# He (Kaiming) 초기화: ReLU 네트워크에 최적화
# std = sqrt(2 / n_in) = sqrt(2/16) ≈ 0.35
# microgpt 선택: std = 0.08 (더 작게 시작)
# 이유: bias 없는 네트워크는 초기값이 너무 크면
# softmax가 포화(saturation)되기 쉬움.
# 작은 초기값으로 시작해 부드럽게 학습.
# 또한 약 243줄의 단순 모델에서 안정적 수렴을 위해.
| 초기화 방법 | std 값 | 대상 활성화 | 단점 |
|---|---|---|---|
| Xavier | ≈ 0.25 | sigmoid, tanh | ReLU에는 부적합 |
| He (Kaiming) | ≈ 0.35 | ReLU 계열 | bias 없으면 초기 포화 위험 |
| microgpt | 0.08 (고정) | ReLU | 단순하고 작은 모델에 최적 |
임베딩 & 핵심 헬퍼 함수
출력 레이어 구조 — lm_head
최종 출력은 두 단계입니다: lm_head linear 변환 후 Softmax로 확률로 변환.
# hidden state x (16차원) → linear(lm_head) → softmax → 확률
# 16차원 → 27개 logit (어휘집 크기)
# logit → 합이 1인 확률 분포
logits = linear(x, state_dict['lm_head']) # 16 → 27
probs = softmax(logits) # 27개 확률, 합=1
# Softmax 강화 효과:
# z = [10, 12, 8] → Softmax(z) ≈ [12%, 87%, 2%]
# 입력 차이가 작아도 Softmax는 승자를 확실히 가림
# 10과 12의 차이는 작지만, 87%라는 압도적 확률로 12가 선택됨
미니 PyTorch — 3가지 헬퍼 함수
# ① linear: 행렬-벡터 곱셈 (bias 없음)
# x: [n_in] → return: [n_out]
def linear(x, w):
return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]
# ② softmax: 로짓 → 확률 분포 (수치 안정성을 위해 max 차감)
def softmax(logits):
max_val = max(val.data for val in logits) # overflow 방지
exps = [(val - max_val).exp() for val in logits]
total = sum(exps)
return [e / total for e in exps]
# ③ rmsnorm: 평균 없이 RMS로만 정규화 (학습 가능 파라미터 없음)
def rmsnorm(x):
ms = sum(xi * xi for xi in x) / len(x) # 평균 제곱
scale = (ms + 1e-5) ** -0.5 # 역수 RMS
return [xi * scale for xi in x]
linear()— Wx (bias 없음)- 행·열 내적으로 차원 변환. PyTorch의
nn.Linear(bias=False)에 해당. softmax()— ex / Σex- 로짓을 확률로 변환.
max_val차감은 수치 안정성을 위한 필수 트릭 —e^(큰 수)의 overflow를 방지합니다. rmsnorm()— x / √(mean(x²) + ε)- RMS로만 정규화. 학습 가능한 γ(scale) 파라미터가 없습니다. LLaMA 등 프로덕션 모델의 RMSNorm과 달리, microgpt는 정규화만 수행합니다. ε=1e-5로 수치 안정성 확보.
토큰 + 위치 임베딩
wte (토큰 임베딩): "이 문자가 무엇인가" — 문자의 정체성. 같은 문자는 항상 같은 임베딩 행을 가져옵니다. 학습 후 모음('a','e','i','o','u')들은 벡터 공간에서 서로 가까워질 것입니다.
wpe (위치 임베딩): "이 문자가 몇 번째 위치에 있는가" — 어텐션은 위치를 모르므로(내적 연산은 순서가 없음) 위치 임베딩을 더함으로써 모델이 "첫 번째 글자", "마지막 글자" 같은 위치 패턴을 학습합니다.
# "emma"의 BOS 처리 예시 (token_id=26, pos_id=0)
tok_emb = state_dict['wte'][26] # BOS 임베딩 → [0.03, -0.07, 0.11, ...] (16차원)
pos_emb = state_dict['wpe'][0] # 위치 0 임베딩 → [0.05, 0.02, -0.08, ...]
x = [t + p for t, p in zip(tok_emb, pos_emb)] # 원소별 덧셈 → 16차원
x = rmsnorm(x) # 크기 정규화
# 왜 concatenate가 아닌 add인가?
# concat → 32차원 → 파라미터 2배 (wq, wk, wv도 32×32 필요)
# add → 16차원 유지, 두 정보를 같은 공간에서 합산, 파라미터 절약
# 이론: 두 임베딩이 같은 공간(16차원)에서 "어떤 문자" + "어느 위치" 정보를 합산
Attention = 내부 검색 엔진
어텐션은 "현재 내가 처리 중인 토큰과 관련이 있는 이전 토큰들을 찾아라"는 내부 검색 엔진입니다.
- Query (Q) — 검색어
- "나는 어떤 정보를 찾고 있나?" — 현재 처리 중인 토큰이 묻는 질문
- Key (K) — 색인
- "나는 어떤 정보를 가지고 있나?" — 각 토큰이 제공하는 레이블
- Value (V) — 내용물
- "매칭 시 실제로 전달할 정보" — 검색 결과
# Q, K, V 행렬을 각각 가중치로 선형 변환
q = linear(x, state_dict[f'layer{li}.attn_wq']) # 질의: "나는 무엇을 찾나?"
k = linear(x, state_dict[f'layer{li}.attn_wk']) # 키: "나는 무엇을 가졌나?"
v = linear(x, state_dict[f'layer{li}.attn_wv']) # 값: "매칭 시 무엇을 줄까?"
Scaled Dot-Product — 왜 √head_dim으로 나누나?
# score = Q · K / √head_dim
# head_dim = 4인 경우
# Q = [0.1, -0.3, 0.5, 0.2] (현재 토큰의 Query 벡터)
# K = [0.4, 0.2, -0.1, 0.3] (과거 토큰의 Key 벡터)
# 내적 = 0.1×0.4 + (-0.3)×0.2 + 0.5×(-0.1) + 0.2×0.3
# = 0.04 - 0.06 - 0.05 + 0.06 = -0.01
# 스케일: -0.01 / sqrt(4) = -0.01 / 2 = -0.005
Multi-head Attention — 왜 여러 헤드가 필요한가?
# head_dim = n_embd // n_head = 16 // 4 = 4
for h in range(n_head): # h = 0, 1, 2, 3
hs = h * head_dim # 헤드 시작 인덱스: 0, 4, 8, 12
q_h = q[hs:hs+head_dim] # 각 헤드의 Query 4차원 슬라이스
k_h = [ki[hs:hs+head_dim] for ki in keys[li]] # 각 헤드의 Key [T, 4]
v_h = [vi[hs:hs+head_dim] for vi in values[li]] # 각 헤드의 Value [T, 4]
각 헤드가 서로 다른 패턴을 학습합니다:
- 헤드 0: "첫 글자 패턴" — BOS 다음에 어떤 자음이 자주 오는가
- 헤드 1: "모음 연속 패턴" — 모음 뒤에 모음이 오는지 자음이 오는지
- 헤드 2: "어미 패턴" — 이름 끝부분의 통계적 규칙
- 헤드 3: "위치 정보" — 지금 시퀀스의 어느 위치인가
Multi-head Attention: n_embd=16을 4개 헤드가 4차원씩 분담 → 각각 독립적 패턴 학습 → Concat → 선형 변환
"emma" — 어텐션 동작 단계별 추적 (pos=4)
# 입력 토큰 시퀀스: [BOS(26), e(4), m(12), m(12), a(0)]
# pos_id: [ 0, 1, 2, 3, 4 ]
# pos=4, token='a' 처리 시:
# keys[0] = [K_BOS, K_e, K_m, K_m, K_a] (5개 누적)
# values[0] = [V_BOS, V_e, V_m, V_m, V_a]
#
# 헤드 h=0 (4차원):
# Q = q[0:4] ← 'a'의 Query 벡터
#
# scores = [
# dot(Q, K_BOS) / 2, # BOS와의 연관성
# dot(Q, K_e) / 2, # 'e'와의 연관성
# dot(Q, K_m) / 2, # 첫 번째 'm'과의 연관성
# dot(Q, K_m) / 2, # 두 번째 'm'과의 연관성
# dot(Q, K_a) / 2, # 'a' 자신과의 연관성 (현재 위치도 포함!)
# ]
#
# weights = softmax(scores)
# = [0.05, 0.20, 0.35, 0.30, 0.10] ← 가중치 합=1.0
# (두 'm'에 0.65 집중 → 'emma'에서 'mm' 패턴 주목)
#
# head_out = 0.05×V_BOS + 0.20×V_e + 0.35×V_m + 0.30×V_m + 0.10×V_a
# = 가중 평균 벡터 (4차원)
# 4개 헤드 각각 head_out 4차원 계산 → x_attn.extend(head_out)
# 결과: x_attn = [h0의 4차원, h1의 4차원, h2의 4차원, h3의 4차원] = 16차원
인과적 마스킹 (Causal Masking) — 미래를 못 보게 하기
GPT는 자기회귀(autoregressive) 모델 — 미래 토큰을 보면 안 됩니다. GPT-2 원본에서는 어텐션 행렬에 "-∞" 마스크를 명시적으로 적용합니다. microgpt에서는 이것이 자동으로 처리됩니다:
# 학습 시: pos_id=2를 처리할 때
# keys[li]에는 이미 [K_pos0, K_pos1, K_pos2]만 있습니다.
# K_pos3, K_pos4는 아직 append되지 않았습니다.
# → 명시적 마스킹 없이도 미래 정보를 볼 수 없습니다!
for pos_id in range(n):
logits = gpt(token_id, pos_id, keys, values)
# 내부에서 keys[li].append(k)가 실행됨
# 다음 pos_id 처리 시 현재 K도 포함됨
KV Cache — 추론 속도의 비밀
# 추론/학습 시작 시 빈 KV 캐시 초기화
keys = [[] for _ in range(n_layer)] # 레이어별 Key 리스트
values = [[] for _ in range(n_layer)] # 레이어별 Value 리스트
# gpt() 내부: 이번 토큰의 K,V를 캐시에 누적
keys[li].append(k) # k = linear(x, attn_wk), 모든 헤드 포함 [n_embd]
values[li].append(v) # v = linear(x, attn_wv)
# 어텐션 계산: 과거 모든 K,V를 헤드별로 분리해서 사용
k_h = [ki[hs:hs+head_dim] for ki in keys[li]] # T개 과거 Key 헤드
v_h = [vi[hs:hs+head_dim] for vi in values[li]] # T개 과거 Value 헤드
attn_logits = [
sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5
for t in range(len(k_h)) # 현재까지 누적된 토큰 수만큼
]
KV 캐시 누적: 각 스텝마다 새 K·V만 추가 — 기존 K·V 재계산 없이 재사용. 인과적 마스킹도 자동 구현.
잔차 연결 (Residual Connection) — 잊어버리지 않기
# 잔차 연결 패턴 (Attention 블록 예시)
x_residual = x # ① 원본 x 저장
x = rmsnorm(x) # ② 정규화
x = attention(x) # ... # ③ Attention 처리
x = [a + b for a, b in zip(x, x_residual)] # ④ 원본 + 결과
잔차 연결을 빠뜨리면?
모델이 "나는 B라는 글자를 읽고 있었지"라는 사실을 잊어버리고,
"나는 A와 관련이 있어"라는 어텐션 출력만 기억하게 됩니다.
우리는 원본 정보(Content)와 새로운 컨텍스트(Context) 둘 다 필요합니다.
x = x + attention_output — 이 한 줄이 핵심입니다.
잔차 연결의 또 다른 효과:
- 기울기 소실 방지 — 깊은 네트워크에서 역전파 시 기울기가 층마다 곱해져 소실되는 문제를 해결합니다. 잔차 경로를 통해 기울기가 직접 흐릅니다.
- 항등 함수 학습 용이 — 레이어가 아무것도 하지 않아도 되면(정보 변환 불필요), 출력 = 0 + x_residual이 됩니다.
- 모든 현대 트랜스포머의 표준 — GPT-2 이후 모든 대형 언어 모델이 채택
Scaled Dot-Product Attention 흐름: Q·Kᵀ/√d → softmax → 가중합 × V
트랜스포머 블록
트랜스포머의 핵심 반복 단위입니다. 각 레이어는 Attention → MLP 순서로 처리합니다.
입력 x → Attention(컨텍스트 수집) → + → MLP(의미 처리) → + → 출력
트랜스포머 블록: Pre-LN → Multi-head Attention → 잔차합 → Pre-LN → MLP → 잔차합. 점선은 잔차(bypass) 경로.
MLP — 비선형성과 4× 확장의 이유
# fc1: 16차원 → 64차원 (4× 확장)
x = linear(x, state_dict[f'layer{li}.mlp_fc1']) # [n_embd] → [4*n_embd]
x = [xi.relu() for xi in x] # 비선형성: max(0, x)
# fc2: 64차원 → 16차원 (원상복구)
x = linear(x, state_dict[f'layer{li}.mlp_fc2']) # [4*n_embd] → [n_embd]
왜 4× 확장인가? 이 "bottleneck" 구조(좁게 → 넓게 → 좁게)는 GPT-2 원논문에서 정의된 표준입니다. 중간 64차원에서 더 많은 특징 조합을 만든 뒤, 다시 16차원으로 압축합니다. 4× 비율은 실험적으로 좋은 성능을 보인 경험적 수치이며, GPT-4도 동일한 비율을 씁니다.
GELU → ReLU — 의도적 단순화
- GELU (표준, GPT-2/3) — x · Φ(x)
- 정규분포의 누적분포함수 사용. 수식 복잡 (
0.5 × x × (1 + tanh(√(2/π) × (x + 0.044715x³)))). 확률론적 해석으로 더 부드러운 활성화. 하지만 순수 파이썬으로 구현하면 느리고 복잡합니다. - ReLU (microgpt) — max(0, x)
- 단순하지만 충분히 효과적.
xi.relu()단 한 줄. 순수 파이썬 구현에 최적. 이름 생성이라는 단순한 태스크에는 ReLU도 충분합니다.
Pre-LN vs Post-LN — microgpt의 선택
| 구분 | Post-LN (GPT-2 원본) | Pre-LN (microgpt) |
|---|---|---|
| 순서 | x → Attention → x+residual → Norm | x → Norm → Attention → x+residual |
| 잔차 경로 | 정규화된 값이 잔차로 흐름 | 원본 x가 그대로 잔차로 흐름 ✓ |
| 학습 안정성 | 레이어 수 많으면 불안정 | 더 안정적 (LLaMA, PaLM 채택) |
| 초기화 의존 | 높음 | 낮음 |
# microgpt의 Pre-LN 패턴:
x_residual = x # ← 정규화 전 원본 저장
x = rmsnorm(x) # ← 정규화 후 Attention/MLP에 입력
x = attention(x) # ...
x = [a + b for a, b in zip(x, x_residual)] # ← 원본 + 결과
RMSNorm vs LayerNorm
| 항목 | LayerNorm (기존) | RMSNorm (LLaMA 등) | RMSNorm (microgpt) |
|---|---|---|---|
| 수식 | (x − μ) / √(σ² + ε) | x / √(RMS² + ε) | x / √(RMS² + ε) |
| 학습 파라미터 | γ + β (2개) | γ만 (1개) | 없음 (정규화만) |
| 계산 비용 | 평균·분산 계산 | 분산만 계산 | 분산만 계산 (γ 없음) |
RMSNorm은 평균 계산이 필요 없어 LayerNorm 대비 7~64% 더 빠르고, 성능은 거의 동일합니다.
완전한 gpt() 함수 — GPT 파이프라인
def gpt(token_id, pos_id, keys, values):
# ─── ① 임베딩: 토큰 + 위치 합산 ─────────────────────
tok_emb = state_dict['wte'][token_id] # 16차원 벡터
pos_emb = state_dict['wpe'][pos_id] # 16차원 벡터
x = [t + p for t, p in zip(tok_emb, pos_emb)]
x = rmsnorm(x) # ← 입력 정규화 (잔차 경로 때문에 필요)
# ─── ② 트랜스포머 레이어 × n_layer ──────────────────
for li in range(n_layer):
# Attention Block: Pre-LN 방식
x_residual = x
x = rmsnorm(x)
q = linear(x, state_dict[f'layer{li}.attn_wq']) # Q: [n_embd]
k = linear(x, state_dict[f'layer{li}.attn_wk']) # K: [n_embd]
v = linear(x, state_dict[f'layer{li}.attn_wv']) # V: [n_embd]
keys[li].append(k) # KV 캐시에 현재 토큰 저장
values[li].append(v)
# 멀티헤드 어텐션 (헤드별로 분리 계산)
x_attn = []
for h in range(n_head):
hs = h * head_dim
q_h = q[hs:hs+head_dim] # [head_dim]
k_h = [ki[hs:hs+head_dim] for ki in keys[li]] # [T, head_dim]
v_h = [vi[hs:hs+head_dim] for vi in values[li]] # [T, head_dim]
# Scaled Dot-Product: Q·Kᵀ / √head_dim
attn_logits = [
sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5
for t in range(len(k_h))
]
attn_weights = softmax(attn_logits) # [T]
# Weighted sum of Values
head_out = [
sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h)))
for j in range(head_dim)
]
x_attn.extend(head_out)
# 출력 프로젝션 + 잔차 연결
x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
x = [a + b for a, b in zip(x, x_residual)] # x = attn_out + x_residual
# MLP Block: Pre-LN 방식
x_residual = x
x = rmsnorm(x)
x = linear(x, state_dict[f'layer{li}.mlp_fc1']) # 16 → 64
x = [xi.relu() for xi in x] # 비선형성
x = linear(x, state_dict[f'layer{li}.mlp_fc2']) # 64 → 16
x = [a + b for a, b in zip(x, x_residual)] # x = mlp_out + x_residual
# ─── ③ 언임베딩: 16차원 → 27개 로짓 ─────────────────
logits = linear(x, state_dict['lm_head'])
return logits
x = rmsnorm(x)가 임베딩 직후 한 번 더 있습니다.
이것은 Karpathy가 주석에서 언급한 부분 — "잔차 경로(residual stream) 덕분에 불필요하지 않다."
잔차 연결은 원본 임베딩을 그대로 더하므로, 첫 번째 rmsnorm이 없으면
비정규화된 임베딩이 그대로 잔차로 흘러들어갑니다.
임베딩의 크기가 제각각이면 잔차 경로 전체가 불안정해집니다.
이 함수 하나가 토큰 ID와 위치 ID를 받아 27개 어휘에 대한 확률 분포(로짓)를 반환합니다.
트랜스포머 4대 구성요소
- Attention — 컨텍스트 수집 (다른 토큰의 정보를 Gather)
- MLP — 의미 처리 (수집된 정보를 Process)
- 잔차 연결(Residuals) — 안정적인 학습 + 기울기 소실 방지
- RMSNorm — 연산 안정성 확보 (LayerNorm 대비 단순화)
Cross-Entropy 손실 계산
Cross-Entropy는 정보 이론에서 나온 개념입니다. 모델의 예측 분포 P가 정답에서 얼마나 멀리 있는지를 측정합니다.
# 수식: L = -log(P(정답 토큰))
# 코드: loss_t = -probs[target_id].log()
# 직관: 정답 토큰에 높은 확률 부여 → log(높은 값) → 음수 작음 → loss 작음
# P(target) = 0.9 → loss = -log(0.9) ≈ 0.105 (잘 맞춤)
# P(target) = 0.5 → loss = -log(0.5) ≈ 0.693
# P(target) = 0.1 → loss = -log(0.1) ≈ 2.303 (많이 틀림)
# P(target) = 0.01 → loss = -log(0.01) ≈ 4.605 (매우 틀림)
# 전체 이름에 대한 평균:
# loss = (1/n) * Σ -log(P(tokens[pos+1] | tokens[:pos+1]))
위치별 손실 계산 예시 — "ab" 이름
이름 "ab"를 BOS로 감싸면 토큰 시퀀스: [BOS, a, b, BOS]
| Pos | Input | Target | P(target) 예시 | Loss ≈ |
|---|---|---|---|---|
| 0 | BOS | 'a' | 0.10 | 2.30 |
| 1 | 'a' | 'b' | 0.50 | 0.69 |
| 2 | 'b' | BOS | 0.20 | 1.61 |
| 평균 | — | — | — | L₀ = (2.30 + 0.69 + 1.61) / 3 ≈ 1.53 |
랜덤 베이스라인 — 학습 시작점 3.30
# 완전 랜덤 모델: 27개 토큰에 균등 확률 1/27 ≈ 0.037
# 랜덤 베이스라인 손실 = -log(1/27) = log(27) ≈ 3.296
# 정보 이론적 해석: 27개 심볼의 엔트로피 = log₂(27) ≈ 4.75 bits
# (완전히 무작위인 시스템의 불확실성)
# Perplexity = exp(loss)
# 랜덤 Perplexity = exp(3.30) = 27 (= 어휘 크기, 직관적!)
# 학습 후 Perplexity = exp(2.37) ≈ 10.7
# → 모델이 평균적으로 약 11개 후보 중 정답을 고르는 수준
손실 궤적 — 3.30 → 2.37 (1000 스텝)
| 스텝 | Loss | Perplexity | 의미 |
|---|---|---|---|
| 0 | 3.30 | 27.1 | 완전 랜덤 (초기화 직후) |
| 100 | ~2.90 | ~18 | 기본 통계 습득 시작 |
| 500 | ~2.60 | ~13 | 모음-자음 패턴 학습 중 |
| 1000 | 2.37 | 10.7 | 이름 구조 패턴 학습 완료 |
손실 궤적: 1,000 스텝 학습 — 3.30(랜덤 초기화) → 2.37(학습 완료). 점선은 랜덤 베이스라인.
손실 3.30→2.37, 개선폭 0.93 — Perplexity 27→10.7으로 약 2.5배 향상. 4,192개 파라미터만으로 이름의 통계적 패턴을 학습한 결과입니다.
완전한 학습 루프
learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
m = [0.0] * len(params) # 1차 모멘텀 (방향)
v = [0.0] * len(params) # 2차 모멘텀 (크기)
num_steps = 1000
for step in range(num_steps):
# ① 문서 선택: 셔플된 docs를 순환 (step % len(docs))
doc = docs[step % len(docs)]
tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
n = min(block_size, len(tokens) - 1) # 최대 block_size까지만
# ② 순전파: 각 위치에서 다음 토큰 예측 + loss 누적
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
losses = []
for pos_id in range(n):
token_id, target_id = tokens[pos_id], tokens[pos_id + 1]
logits = gpt(token_id, pos_id, keys, values)
probs = softmax(logits)
losses.append(-probs[target_id].log()) # cross-entropy (스칼라)
loss = (1 / n) * sum(losses) # 위치별 평균 loss
# ③ 역전파: 모든 파라미터의 기울기 계산
loss.backward()
# ④ Adam 파라미터 업데이트 (기울기 초기화 포함)
lr_t = learning_rate * (1 - step / num_steps) # 선형 감쇠
for i, p in enumerate(params):
m[i] = beta1 * m[i] + (1 - beta1) * p.grad
v[i] = beta2 * v[i] + (1 - beta2) * p.grad**2
m_hat = m[i] / (1 - beta1**(step+1)) # 바이어스 보정
v_hat = v[i] / (1 - beta2**(step+1))
p.data -= lr_t * m_hat / (v_hat**0.5 + eps_adam)
p.grad = 0 # ← 기울기 초기화 (업데이트 직후)
p.grad = 0은 업데이트 후에 수행됩니다
(인덱스 기반 for 루프이므로 별도의 초기화 루프가 필요 없습니다).
경사 하강법 관점으로 다시 보기
위 루프는 구현이 길어 보이지만, 수학적으로는 경사 하강법 한 줄을 반복하는 과정입니다.
# 경사 하강법 핵심식
θ_(t+1) = θ_t - η_t * ∇L(θ_t)
# microgpt에서의 대응 관계
# θ_t -> params 리스트의 각 p.data
# ∇L(θ_t) -> loss.backward() 이후의 p.grad
# η_t -> lr_t (선형 감쇠 학습률)
# 업데이트 -> Adam 식으로 보정 후 p.data 감소
microgpt 학습 루프는 경사 하강법을 Adam 방식으로 안정화한 구현입니다.
학습률 감쇠 (Linear Learning Rate Decay)
# 선형 학습률 감쇠: step이 진행될수록 lr 감소
lr_t = learning_rate * (1 - step / num_steps)
# step=0: lr_t = 0.01 × (1 - 0/1000) = 0.01000 ← 최대
# step=500: lr_t = 0.01 × (1 - 500/1000) = 0.00500 ← 중간
# step=999: lr_t = 0.01 × (1 - 999/1000) = 0.00001 ← 최소
# 주의: step=1000 (num_steps)에 도달하면 lr_t=0이 되므로
# 실제로는 step 0~999 (총 1000번)만 실행됩니다.
Adam 옵티마이저
단순한 SGD(확률적 경사 하강법) 대신, 적응적 학습률을 갖는 Adam을 구현합니다. Adam = Adaptive Moment estimation.
| 옵티마이저 | 업데이트 규칙 | 단점 |
|---|---|---|
| SGD | p -= lr × grad | 학습률 수동 조정, 느린 수렴 |
| Momentum SGD | v = β×v + grad; p -= lr×v | 학습률 여전히 전역 고정 |
| RMSProp | v = β×v + (1-β)×grad²; p -= lr×grad/√v | 모멘텀 없음 |
| Adam ✓ | m, v 둘 다 추적; 바이어스 보정 | 메모리 2배 (m, v 버퍼) |
Adam 단계별 코드 해설
# ── 초기화 (학습 루프 전) ──
m = [0.0] * len(params) # 1차 모멘텀 버퍼 (지수 이동 평균 of grad)
v = [0.0] * len(params) # 2차 모멘텀 버퍼 (지수 이동 평균 of grad²)
beta1, beta2 = 0.85, 0.99
# ── 각 스텝의 업데이트 ──
for i, p in enumerate(params):
g = p.grad
# ─ 1단계: 1차 모멘텀 (방향) ─
# 현재 기울기 g와 이전 방향 m[i]의 지수 가중 평균
m[i] = beta1 * m[i] + (1 - beta1) * g
# beta1=0.85 → 현재 기울기에 15% 가중, 이전 방향에 85% 가중
# (기본값 0.9보다 작아 더 빠르게 현재 기울기에 반응)
# ─ 2단계: 2차 모멘텀 (크기) ─
# 기울기 제곱의 지수 이동 평균 → "이 파라미터의 기울기가 얼마나 크게 변하는가"
v[i] = beta2 * v[i] + (1 - beta2) * g**2
# beta2=0.99 → 기울기 크기의 장기 기억
# ─ 3단계: 바이어스 보정 ─
# 초기(step 작을 때) m,v가 0으로 초기화되어 있어 편향됨을 보정
t = step + 1
m_hat = m[i] / (1 - beta1**t) # step=1: 1/(1-0.85)=6.67배 증폭
v_hat = v[i] / (1 - beta2**t) # step=1: 1/(1-0.99)=100배 증폭
# ─ 4단계: 파라미터 업데이트 ─
# 효과적 학습률 = lr_t / √v_hat → 기울기가 크게 변하는 파라미터는 작게 업데이트
p.data -= lr_t * m_hat / (v_hat**0.5 + eps_adam)
p.grad = 0 # 초기화
바이어스 보정 상세 — 왜 필요한가?
# step=1, beta1=0.85, grad=1.0 인 경우:
m[i] = 0.85 * 0.0 + 0.15 * 1.0 = 0.15 # 실제 gradient=1.0인데 m=0.15만 저장됨
m_hat = 0.15 / (1 - 0.85**1) = 0.15 / 0.15 = 1.0 # 보정 후 1.0 ← 올바른 값
# step=100, beta1=0.85:
# 1 - 0.85**100 ≈ 1.0 (완전 포화)
# → 보정 인자가 거의 1이 되어 바이어스 보정이 자동으로 무력화됨
# → 초반 스텝에서만 중요하고, 충분한 스텝 후엔 영향 없음
| 파라미터 | 기본값 (논문) | microgpt | 효과 |
|---|---|---|---|
| beta1 | 0.9 | 0.85 | 현재 기울기에 15% (기본 10%) → 더 빠른 방향 전환 |
| beta2 | 0.999 | 0.99 | 기울기 크기 기억 더 짧게 → 더 빠른 적응 |
1,000 스텝 밖에 없는 짧은 학습에서는 빠른 적응이 중요합니다. 기본값(β1=0.9, β2=0.999)은 수십만 스텝의 장기 학습을 위한 값입니다.
lr / √v_hat에서 v_hat(기울기 제곱 평균)이 크면 분모가 커져 실제 학습률이 줄어듭니다.
파라미터마다 각자에게 맞는 학습률을 자동 조절합니다.
추론 (Inference) & 이름 생성
# may the model babble back to us
temperature = 0.5 # (0, 1] — 낮을수록 보수적, 높을수록 다양한 출력
for sample_idx in range(20): # 총 20개의 이름 생성
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
token_id = BOS # 시작 토큰
sample = []
# BOS → 첫 글자 → 두 번째 글자 → ... → BOS(종료)
for pos_id in range(block_size):
logits = gpt(token_id, pos_id, keys, values)
probs = softmax([l / temperature for l in logits])
token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0]
if token_id == BOS: break
sample.append(uchars[token_id])
print(f"sample {sample_idx+1:2d}: {''.join(sample)}")
자기회귀 생성 — "kamon" 단계별 추적
# "kamon" 생성 과정 (temperature=0.5 적용)
keys = [[] for _ in range(n_layer)]
values = [[] for _ in range(n_layer)]
token_id = BOS # 26
# ── step 0 (pos=0) ──
logits = gpt(26, 0, keys, values) # BOS 입력
probs = softmax([l/0.5 for l in logits]) # temperature=0.5 적용
# probs 중 'k'(10)의 확률이 가장 높다고 가정
token_id = random.choices(range(27), weights=[p.data for p in probs])[0]
# → token_id = 10 ('k')
# ── step 1 (pos=1) ──
logits = gpt(10, 1, keys, values) # 'k' 입력, pos=1
# keys[0]에는 이제 K_BOS, K_k 두 개가 있음
# → 'a'(0)가 높은 확률로 샘플링됨 → token_id = 0
# ── step 2 (pos=2) ── 'm'(12) 샘플링
# keys[0] = [K_BOS, K_k, K_a] (3개 누적)
# ── step 3 (pos=3) ── 'o'(14) 샘플링
# ── step 4 (pos=4) ── 'n'(13) 샘플링
# ── step 5 (pos=5) ── BOS(26)가 샘플링되면 break → "kamon" 완성
# → sample = ['k', 'a', 'm', 'o', 'n'] → "kamon"
자기회귀 생성 루프: BOS → gpt() → softmax/temperature → 샘플링 → BOS이면 종료, 아니면 반복
block_size=16 제한의 의미
for pos_id in range(block_size): # 0~15, 최대 16 스텝
logits = gpt(token_id, pos_id, keys, values)
# ...
if token_id == BOS: break # 보통 여기서 멈춤
# 만약 BOS가 16 스텝 안에 생성되지 않으면:
# pos_id가 block_size에 도달해 루프 종료 → 이름이 잘림
# names.txt의 최장 이름이 15글자이므로, 정상 이름은 항상 16 이내에 BOS 생성
# 반면 wpe(위치 임베딩)는 0~15만 정의됨
# → pos_id=16 이상을 입력하면 IndexError 발생
Temperature — 창의성 조절
# 원본 logits (학습된 모델 출력, 일부):
# 'a'=2.1, 'k'=1.8, 'e'=1.5, 나머지≈0
# Temperature T=1.0 (원본 유지):
# softmax([2.1, 1.8, 1.5, ...]) ≈ [0.30, 0.22, 0.17, ...]
# → 적당히 다양한 선택
# Temperature T=0.5 (낮게, 로짓 2배 증폭):
# softmax([4.2, 3.6, 3.0, ...]) ≈ [0.52, 0.28, 0.14, ...]
# → 'a'에 집중, 더 안정적
# Temperature T=0.1 (매우 낮게, 로짓 10배 증폭):
# softmax([21, 18, 15, ...]) ≈ [0.95, 0.05, ~0, ...]
# → 거의 항상 'a' 선택 (greedy에 가까움)
# Temperature T=2.0 (높게, 로짓 절반):
# softmax([1.05, 0.9, 0.75, ...]) ≈ [0.20, 0.18, 0.16, ...]
# → 거의 균등 → 무작위에 가까움
- Temperature = 0.1 — 보수적 (Greedy에 가까움)
- 로짓에 10을 곱한 효과 → 가장 높은 확률 토큰에 95%+ 집중. 반복적이지만 안정적. 동일한 BOS에서 항상 같은 이름이 나올 수 있음.
- Temperature = 0.5 (microgpt.py 기본값) — 품질과 다양성의 균형
- 로짓에 2를 곱한 효과 → 상위 토큰들 간의 차이를 2배로 강조. 발음 가능하고 다양한 이름 생성.
- Temperature = 1.0 — 원본 확률 그대로
- 모델이 학습한 분포를 그대로 사용. 이론적으로 "올바른" 샘플링. 가끔 발음 어려운 이름도 생성.
- Temperature > 1.0 — 창의적 (과도한 무작위)
- 로짓 차이를 줄여 균등 분포에 가까워짐. 이상한 결과 빈도 증가.
logits / 0은 Python에서 ZeroDivisionError를 냅니다.
Greedy Decoding을 원한다면 temperature 나눗셈을 제거하고
token_id = max(range(vocab_size), key=lambda i: logits[i].data)를 사용하세요.
실제 생성 결과 — 1000 스텝 학습 후 샘플 20개
| 샘플 | 이름 | 특징 |
|---|---|---|
| 1~5 | kamon, ann, karai, jaire, vialan | 모음-자음 교대 패턴 |
| 6~10 | karia, yeran, anna, areli, kaina | "ar-", "an-" 흔한 이름 조각 |
| 11~15 | konna, keylen, liole, alerin, earan | 2~6글자 적절한 길이 |
| 16~20 | lenne, kana, lara, alela, anton | 발음 가능한 이름 구조 |
학습 vs 추론의 차이점
| 측면 | 학습 (Training) | 추론 (Inference) |
|---|---|---|
| 입력 방식 | 이름 전체 토큰을 위치별로 순전파 | BOS부터 시작, 토큰 하나씩 생성 |
| 역전파 | O (loss.backward()) | X (grad 불필요) |
| KV 캐시 | 매 이름마다 초기화 | 매 샘플마다 초기화 |
| 종료 조건 | 모든 위치 처리 완료 | BOS 샘플링 or block_size 도달 |
| 결정성 | 결정적 (seed 고정) | 확률적 (temperature 샘플링) |
MicroGPT vs 현대 LLM
| 항목 | microgpt.py | Modern LLMs (GPT-4 등) |
|---|---|---|
| 파라미터 | 4,192개 | 수조(Trillions)개 |
| 데이터 | 32,033개의 이름 | 인터넷 전체 텍스트 (수조 토큰) |
| 하드웨어 | 노트북 CPU, 수 분 | 수천 개의 H100 GPU |
| 코드 | 약 243줄 순수 파이썬 | 수백만 줄, 수천억 원 학습 비용 |
| 목적 | 영어 이름 생성 | 범용 언어 이해 & 생성 |
구성요소별 상세 비교
| 구성요소 | microgpt.py | GPT-4 / LLaMA 등 |
|---|---|---|
| 데이터 | 32,033개의 이름 (names.txt) | 인터넷 전체 텍스트 (수조 토큰) |
| 토크나이저 | 26 알파벳 + BOS = 27개 토큰 | BPE/SentencePiece, 50K~256K 토큰 |
| Autograd | 스칼라 Value 클래스, 순수 Python | PyTorch/JAX 텐서 자동미분 (GPU 병렬) |
| 아키텍처 | n_embd=16, n_head=4, n_layer=1 | n_embd=12288, n_head=96, n_layer=96+ |
| 파라미터 | 4,192개 | 수천억~수조 개 |
| 학습 | 1000 스텝, 노트북 CPU, 수 분 | 수천억 스텝, H100 수천 개, 수개월 |
| 정규화 | RMSNorm (학습 파라미터 없음) | LayerNorm or RMSNorm (γ 파라미터 있음) |
| 활성화 | ReLU | GELU (GPT-2/3), SwiGLU (LLaMA) |
| 사후 학습 | 없음 (순수 사전학습만) | SFT + RLHF/DPO (인간 피드백) |
| 추론 최적화 | KV캐시 + temperature 샘플링 | Flash Attention, 양자화, 분산 추론 |
nanoGPT — microgpt의 Production 확장판
nanoGPT는 Karpathy가 2022년 12월 공개한 "가장 간결하고 빠른 GPT 훈련 코드베이스"입니다. OpenAI GPT-2(124M 파라미터)를 완전히 재현하고, 단일 GPU에서 실용적인 언어 모델을 훈련할 수 있게 설계되었습니다. microgpt가 "이해를 위한 원자적 구현"이라면, nanoGPT는 "실제 사용을 위한 최소 구현"입니다.
그림 1: nanoGPT 아키텍처 — GPT-2 (124M) 구조
nanoGPT 설치 및 실행
nanoGPT는 약 300줄의 모델 정의와 300줄의 훈련 루프로 구성되어 있어 코드를 읽고 이해하기 매우 좋습니다.
# 1. 저장소 클론
git clone https://github.com/karpathy/nanoGPT.git
cd nanoGPT
# 2. 의존성 설치
pip install torch numpy tiktoken tqdm transformers datasets
# 3. Shakespeare 데이터로 훈련 (문자 수준, 입문용)
python train.py config/train_shakespeare_char.py
# 4. 텍스트 생성
python sample.py --out_dir=out-shakespeare-char
# 5. GPT-2 가중치로 시작 (파인튜닝)
python train.py --init_from=gpt2
# 6. 분산학습 (4 GPU)
torchrun --standalone --nproc_per_node=4 train.py
# 7. torch.compile으로 최적화 (PyTorch 2.0+)
python train.py --compile=True
핵심 커맨드라인 옵션
| 옵션 | 기본값 | 설명 |
|---|---|---|
--init_from | scratch | scratch(처음부터), resume(계속), gpt2/GPT-2 가중치 |
--batch_size | 12 | 배치 크기 (마이크로 배치) |
--max_iters | 600000 | 총 훈련 반복 횟수 |
--learning_rate | 6e-4 | 최대 학습률 |
--compile | False | torch.compile으로 그래프 최적화 |
--dtype | bfloat16 | float16/bfloat16/float32 |
--wandb_log | False | Weights & Biases 로깅 활성화 |
microgpt vs nanoGPT 핵심 차이점
| 항목 | microgpt.py | nanoGPT |
|---|---|---|
| 연산 단위 | 스칼라 Value 객체 | PyTorch 텐서 (GPU 병렬 연산) |
| 자동미분 | 수동 구현 (_backward, 위상 정렬) | PyTorch .backward() 완전 자동화 |
| 토크나이저 | 26 알파벳 + BOS = vocab_size 27 (문자 수준) | tiktoken BPE — vocab_size 50,257 (서브워드) |
| 아키텍처 | n_layer=1, n_head=4, n_embd=16 | n_layer=12, n_head=12, n_embd=768 (GPT-2 small) |
| 파라미터 수 | 4,192개 | 124,439,808개 (GPT-2 small) ~ 1.5B (XL) |
| 배치 크기 | 1 (이름 1개/스텝) | 32~512+ (gradient accumulation) |
| 컨텍스트 길이 | 최대 15 토큰 (가장 긴 이름) | 1,024 토큰 (GPT-2 기본) |
| Attention 구현 | 스칼라 루프 기반 수동 계산 | Flash Attention (F.scaled_dot_product_attention) |
| 정규화 | RMSNorm (수동 구현) | LayerNorm (nn.LayerNorm) + Dropout |
| 활성화 함수 | ReLU | GELU (F.gelu) — GPT-2/3 표준 |
| 옵티마이저 | Adam 수동 구현 (β1=0.85, β2=0.99) | PyTorch optim.AdamW + weight decay (β1=0.9, β2=0.95) |
| LR 스케줄 | 선형 감쇠 | Cosine decay with linear warmup (GPT-3 방식) |
| Gradient Clipping | 없음 | clip_grad_norm_ (max_norm=1.0) |
| 분산학습 | 없음 (단일 프로세스) | PyTorch DDP — 다중 GPU/노드 |
| Checkpoint | 없음 | 자동 저장/복원, best val loss 기준 |
| Weight Tying | wte/lm_head 별도 초기화 | lm_head.weight = transformer.wte.weight 명시적 공유 |
| 외부 의존성 | os, math, random (표준 라이브러리만) | torch, tiktoken, numpy, datasets 등 |
nanoGPT 파일 구조
| 파일 | 역할 | microgpt 대응 |
|---|---|---|
model.py |
GPT 모델 정의 — CausalSelfAttention, MLP, Block, GPT 클래스, Flash Attention 지원 |
def gpt() 함수 전체 |
train.py |
훈련 루프 — DDP 분산학습, gradient accumulation, cosine LR, checkpoint 저장/복원 | for step in range(num_steps): 루프 |
data/ |
데이터 준비 스크립트 — Shakespeare, OpenWebText 다운로드·토크나이징·바이너리 저장 | 코드 내 names.txt 직접 로딩 3줄 |
config/ |
사전 정의 훈련 설정 — GPT-2 small/medium/large/XL, Shakespeare 등 하이퍼파라미터 세트 | 코드 내 하드코딩된 n_embd=16 등 |
sample.py |
텍스트 샘플링 — checkpoint 로드 후 temperature·top_k 기반 텍스트 생성 |
for sample_idx in range(20): 루프 |
bench.py |
성능 벤치마크 — throughput(token/s) 측정, Flash Attention 효과 비교 | 해당 없음 |
nanoGPT model.py — 클래스 구조
microgpt의 함수형 구현과 달리 nanoGPT는 PyTorch nn.Module 클래스 계층 구조를 사용합니다.
아키텍처는 동일하지만, 텐서화·자동미분·GPU 최적화가 추가됩니다.
# nanoGPT model.py 클래스 구조 (요약)
class CausalSelfAttention(nn.Module):
# Q, K, V 프로젝션을 단일 c_attn(3*n_embd)로 통합 — 효율적 행렬 곱
# Flash Attention: F.scaled_dot_product_attention() 자동 사용
# microgpt: 스칼라 루프로 직접 qkv 계산
# attn_dropout + resid_dropout 정규화
class MLP(nn.Module):
# Linear(n_embd → 4*n_embd) → GELU → Linear(4*n_embd → n_embd)
# microgpt: ReLU 사용, nanoGPT: GELU (GPT-2/3 표준)
# dropout으로 과적합 방지
class Block(nn.Module):
# Pre-LayerNorm 구조: ln_1 → attention → residual
# ln_2 → mlp → residual
# microgpt: RMSNorm 사용, nanoGPT: LayerNorm
class GPT(nn.Module):
# transformer.wte : 토큰 임베딩 [vocab_size, n_embd]
# transformer.wpe : 위치 임베딩 [block_size, n_embd] ← 학습 가능
# transformer.drop: Dropout
# transformer.h : ModuleList([Block(...)] * n_layer)
# transformer.ln_f: 최종 LayerNorm
# lm_head : Linear(n_embd → vocab_size, bias=False)
# → wte와 가중치 공유(weight tying)
#
# generate(): top_k 샘플링 + temperature 스케일링
# from_pretrained(): HuggingFace 없이 GPT-2 가중치 직접 로드
nanoGPT는 토큰 임베딩(
wte)과 LM 헤드(lm_head)가 동일한 가중치를 공유합니다.lm_head.weight = transformer.wte.weight — 124M 모델의 경우 약 100M 파라미터 절약 (vocab_size × n_embd = 50257 × 768 ≈ 38M).수학적 의미: 임베딩 층은 "단어를 벡터로 변환"하고, LM 헤드는 "벡터를 단어로 변환"하는 역연산입니다. 두 작업이 유사한 구조이므로 가중치를 공유하면:
• 파라미터 30% 절약 • 학습 안정성 향상 • 사전학습-미세조정 일관성 유지
microgpt → nanoGPT 확장 경로 — 6단계 로드맵
-
① 스칼라 → NumPy 벡터화
Value스칼라 루프 → NumPy 배열 연산 → 속도 10~100배 향상.
역전파도 NumPy 브로드캐스팅으로 표현 가능. 텐서 차원 개념 도입. -
② 배치(Batch) 처리 도입
이름 1개/스텝 → 32개/스텝 미니배치 → 그래디언트 분산 감소, 학습 안정화.
텐서 차원이[T]에서[B, T]로 확장됨. -
③ PyTorch 텐서 교체
NumPy → PyTorch Tensor →.backward()자동미분, GPU 가속.
수동으로 작성한_backward체인이 완전히 자동화됨. -
④ 표준 모듈 & 정규화 적용
수동 구현한linear,rmsnorm,Adam→nn.Linear,nn.LayerNorm,optim.AdamW.
Dropout, gradient clipping, weight decay 정규화 기법 추가. -
⑤ 아키텍처 확장
n_layer=1 → 12+, n_embd=16 → 768+, vocab_size=27 → 50,257 (BPE).
Flash Attention, 컨텍스트 길이 1024+ 지원, top-k 샘플링 추가. -
⑥ 분산학습 & 생산화
DDP로 다중 GPU 학습, gradient accumulation, cosine LR with warmup,
자동 checkpoint 저장/복원 → 실제 GPT-2 수준 모델 훈련 가능.
nanoGPT 대표 사용 예시
- Shakespeare 문자 수준 언어 모델
- 셰익스피어 전집(~1MB)으로 훈련 → 셰익스피어 스타일 문장 생성. 단일 GPU로 수 분~수 시간 안에 완료. GPT의 원리를 실감하는 입문 예제. microgpt의 names.txt → 셰익스피어 텍스트로 도메인만 교체한 확장 버전.
- GPT-2 (124M) 완전 재현
- OpenWebText(~38GB) 데이터셋으로 훈련 → OpenAI GPT-2와 동등한 검증 손실(val loss ≈ 2.85) 달성. A100 GPU 8개로 약 4일 소요. Karpathy가 직접 재현 결과를 공개함.
- GPT-2 공식 가중치 파인튜닝
- OpenAI 공개 GPT-2 가중치를 HuggingFace 없이 직접 로드.
--init_from=gpt2플래그 하나로 gpt2/gpt2-medium/gpt2-large/gpt2-xl 중 선택. 특정 도메인 텍스트로 파인튜닝하여 도메인 특화 모델 제작 가능. - FineWeb-Edu (10B 토큰) 대규모 훈련
- Karpathy가 공개한 고품질 교육용 웹 데이터셋.
fineweb.py스크립트로 HuggingFace에서 직접 다운로드 및 토크나이징. 100M 토큰 × 100개Shard로 분할 저장. GPT-2 재현보다 10배 많은 토큰으로 훈련. - OpenWebText 재현 훈련
- GPT-2 학습에 사용된 OpenWebText 데이터셋을 직접 수집·전처리. CC-BY-SA 라이선스. 약 38GB 규모의 웹 텍스트. 다운로드 후 tiktoken으로 BPE 토크나이징, 바이너리 형태로 저장.
• Shakespeare (1MB) — 수분~수시간, 디버깅/입문용
• FineWeb-Edu (10B) — 고품질 교육용, GPT-2 재현 이상
• OpenWebText (38GB) — GPT-2 완전 재현을 위한 정식 데이터
build-nanogpt — 2024년 새로운 비디오 강의
2024년 6월, Karpathy는 build-nanogpt 저장소를 공개하며 "Zero to Hero" 시리즈의 후속 강의를 시작했습니다. 이 저장소는 빈 파일에서 시작해 단계별로 GPT-2 (124M)를 재현하는 전체 과정을 담고 있습니다.
• Git 커밋 단위로 진행되는 단계별 튜토리얼
• YouTube 비디오 강의와 1:1 매핑
• 빈 디렉토리에서 시작해 완전한 GPT-2 훈련까지
• GPT-2 (~1시간, 약 $10) 재현 가능
• 더 많은 토큰/시간 투자시 GPT-3 규모로 확장 가능
build-nanogpt 커밋 로그 예시
# 저장소 클론 후 커밋 히스토리로 학습 진행
git clone https://github.com/karpathy/build-nanogpt.git
cd build-nanogpt
git log --oneline
# 각 커밋에서 특정 기능 확인 가능
# 1. Character-level BigramLanguageModel
# 2. Add batch sampling (get_batch)
# 3. Add the GPT model itself
# 4. Add data preprocessing (encode/decode)
# 5. Add train/val split
# 6. Add self-attention (Q, K, V)
# ... 최종: Full GPT-2 (124M)
훈련 결과 예시 (build-nanogpt)
10B 토큰 훈련 후 "Hello, I'm a language model" 프롬프트로 생성된 텍스트:
Hello, I'm a language model, and my goal is to make English as easy
and fun as possible for everyone, and to find out what we can do
to make the world a better place.
40B 토큰 훈련 후 동일 프롬프트:
Hello, I'm a language model, a model of computer science,
and this is a way to program computer programs using a formal
language that is based on mathematics and logic.
다양한 실행 환경
| 환경 | 특징 | 권장 사용처 |
|---|---|---|
| 로컬 MacBook/GPU | CPU 또는 CUDA, 소규모 데이터 | 디버깅, Shakespeare 예제 |
| Lambda Cloud | A100 80GB, 시간당 약 $1 | GPT-2 수준 훈련 |
| Modal | 서버리스 GPU, 자동 스케일링 | 간헐적 훈련, 프로토타입 |
| Codeanywhere | NVIDIA T4 16GB, 즉시 사용 | 교육, 빠른 실습 |
| Google Colab Pro | V100/A100, 종량제 | 개인 실험 |
| 항목 | 계산식 | 예상 값 (BF16) |
|---|---|---|
| 모델 가중치 | 124M × 2 bytes | ~250 MB |
| 옵티마이저 상태 (AdamW) | 124M × 2 × 2 (m+v) × 2 bytes | ~1 GB |
| 활성화 (Forward) | batch × seq × layers × hidden² × 4 | ~2-4 GB |
| 그래디언트 | 124M × 2 bytes | ~250 MB |
| 총계 (단일 GPU) | ~4-6 GB |
RTX 3060 (12GB) 가능, RTX 3080 (10GB) 빠듯함, RTX 3090/4090 (24GB) 여유.
gradient_accumulation으로 Effective Batch Size 512+ 시뮬레이션 가능.
하이퍼파라미터 상세 설명
GPT-2 (124M) 재현을 위한 기본 설정:
| 파라미터 | 값 | 설명 |
|---|---|---|
n_layer | 12 | Transformer 블록 수 ( depth ) |
n_head | 12 | 어텐션 헤드 수 (각각 n_embd/12) |
n_embd | 768 | 임베딩 차원 |
block_size | 1024 | 최대 컨텍스트 길이 |
vocab_size | 50257 | GPT-2 BPE 어휘 크기 |
dropout | 0.0 | pretraining 시 0 권장 |
bias | False | LayerNorm/Linear bias (GPT-2와 동일) |
learning_rate | 6e-4 | 최대 학습률 |
beta2 | 0.95 | AdamW 2차 모멘텀 감쇠 |
weight_decay | 0.1 | 가중치 감쇠 (L2 정규화) |
warmup_iters | 2000 | 워밍업 스텝 수 |
lr_decay_iters | 600000 | 학습률 감쇠 시작점 |
min_lr | 6e-5 | 최소 학습률 (lr/10) |
grad_clip | 1.0 | 그래디언트 클리핑 임계값 |
성능 최적화 기법
- torch.compile: PyTorch 2.0+ 그래프 최적화로 2~3배 속도 향상. CUDA 커널 분리·병합으로 GPU 활용도 극대화.
- Flash Attention:
F.scaled_dot_product_attention- O(N²) → O(N) 메모리 절약. IO-aware 알고리즘으로 HBM 접근 최소화.- 표준 Attention: QKᵀ 계산 시 N×N 행렬 HBM에 저장 → 1024컨텍스트시 4GB 메모리
- Flash Attention: Online softmax 방식으로 HBM 저장 없이 레지스터에서 연산
- 자동 사용: PyTorch 2.0+는
F.scaled_dot_product_attention호출 시 자동 적용
- Mixed Precision (BF16/FP16): GPU 메모리 50% 절약, 연산 속도 향상. bf16은 fp32 대비 정밀도 유지하면서 메모리 절약.
- Gradient Accumulation: 작은 GPU로 큰 배치 시뮬레이션. effective_batch_size = batch_size × grad_accum_steps.
- DDP (Distributed Data Parallel): 다중 GPU 병렬 훈련. 각 GPU는 모델 복사본 보유, 그래디언트 동기로 평균.
- Gradient Checkpointing: 메모리 절약 대용량 모델용. 순전파 시 중간 활성화 저장 대신 역전파 시 재계산.
• NVIDIA GPU (Ampere 이상 — A100, RTX 30xx, 40xx)
• PyTorch 2.0+ 또는 flash-attn 패키지 설치
• HeadDim ≤ 128 (그 이상은 표준 구현 사용)
• fp16/bf16 권장 (fp32는 자동 비활성화)
흔한 오류 및 해결 방법
| 오류 | 원인 | 해결 방법 |
|---|---|---|
| uint16 → long 변환 오류 | 이전 PyTorch 버전 | npt = npt.astype(np.int32) 후 변환 |
| OOM (Out of Memory) | 배치 크기 과대 | batch_size 감소 또는 gradient_accumulation_steps 증가 |
| loss NaN | 학습률 과고/수치 불안정 | 학습률 감소, grad_clip 적용 |
| GPU 미인식 | CUDA 설치 문제 | torch.cuda.is_available() 확인 |
| tokenizer 경고 | tiktoken 버전 | pip install tiktoken --upgrade |
관련 Karpathy 프로젝트
- micrograd — autograd 전신
- 스칼라 autograd 엔진 (Value 클래스) — microgpt Value 클래스의 직접적 원형. 역전파 원리를 이해하기 위한 가장 작은 구현. 약 150줄.
- makemore — 데이터셋·토크나이저 전신
- names.txt 데이터셋 출처, 문자 수준 언어 모델. Bigram부터 Transformer까지 단계적으로 발전시키는 강의 시리즈. microgpt 토크나이저 구조 계승.
- nanoGPT — 실용 확장 버전
- microgpt에서 배운 원리를 실제 규모로 구현. PyTorch + GPU 기반, GPT-2 완전 재현, 분산학습 지원. 연구/실험용 GPT 훈련의 현실적 최소 기준점.
- llm.c — 의존성 최소화 극한
- C/CUDA로만 LLM 훈련 — PyTorch조차 제거. microgpt의 "의존성 없이 직접 구현" 철학을 시스템 레벨까지 밀고 나간 프로젝트.
243줄 코드로 얻은 것들
| 구성요소 | microgpt 코드 | 현대 프레임워크 등가 |
|---|---|---|
| Autograd 엔진 | class Value | PyTorch torch.autograd |
| 신경망 레이어 | linear, softmax, rmsnorm | PyTorch nn.Linear, nn.Softmax, nn.RMSNorm |
| GPT 아키텍처 | def gpt(...) | HuggingFace GPT2Model |
| Adam 옵티마이저 | m, v 리스트 + 4줄 업데이트 | PyTorch optim.Adam |
"우리는 실행 규칙이 아니라, 학습 규칙을 정의한 것입니다."
GPT-4도, LLaMA도, 우리의 microgpt도 — 모두 손실을 줄이는 숫자들을 조정합니다. 4,192개(또는 수조 개)의 차이일 뿐, 원리는 완전히 동일합니다. 블랙박스는 열렸습니다.
실험 아이디어 — 파라미터를 바꿔보자
| 실험 | 변경 내용 | 예상 효과 |
|---|---|---|
| 더 깊게 | n_layer = 4 | 파라미터 4배↑ 더 자연스러운 이름 |
| 더 넓게 | n_embd = 64, n_head = 8 | 파라미터 ~16배↑ 표현력↑ |
| 더 오래 | num_steps = 10000 | 더 낮은 loss, 더 정확한 이름 패턴 |
| 다른 데이터 | 셰익스피어 텍스트 | 문장 수준 언어 생성 |
| 온도 실험 | temperature = 0.1, 0.5, 1.0, 2.0 | 창의성 vs 안정성 트레이드오프 |
| 단일 헤드 | n_head = 1 | 멀티헤드 이점 체감 — loss 소폭 상승 예상 |
| bias 추가 | linear: +b 항 추가 | 파라미터 +16개, 수렴 속도 변화 관찰 |
| std 비교 | std=0.01 vs 0.08 vs 0.3 | 초기화 크기와 수렴 안정성의 상관관계 |
| Adam → SGD | Adam 업데이트를 p.data -= lr * p.grad로 대체 | 적응적 학습률의 중요성 실감 |
수치 안정성 — 미묘한 함정들
순수 파이썬으로 부동소수점 연산을 구현할 때 마주치는 세 가지 핵심 문제를 다룹니다. 실제 ML 라이브러리들도 동일한 문제를 내부에서 처리합니다.
log(0) 문제 — 교차 엔트로피의 약점
Cross-Entropy 손실 -log(P(target))에서 P(target) = 0이면
math.log(0)은 수학적으로 -∞이고 Python에서는 ValueError를 냅니다.
# 언제 발생하는가?
# 학습 초기에는 거의 없지만, 극단적 상황에서 발생 가능:
#
# 1. Softmax 포화(saturation): logit 차이가 매우 크면
# softmax([100, 0, 0, ...]) → [1.0, 0.0, 0.0, ...]
# target이 index 1이라면: probs[1] = 0.0 → log(0) → ValueError!
#
# 2. Temperature가 매우 작으면:
# softmax([10, 1, 0] / 0.001) = softmax([10000, 1000, 0])
# → probs ≈ [1.0, 0.0, 0.0] → 같은 문제 발생
# microgpt의 자연스러운 방어:
# 1) softmax 내 max 차감 → overflow 방지 (exp(큰 수) 방지)
# 2) std=0.08 작은 초기값 → 초기 logit 포화 방지
# 3) Adam 학습률 감쇠 → 학습 후반 포화 방지
# 명시적 방어가 필요한 경우 (microgpt에는 없지만 추가 가능):
def safe_log(x, eps=1e-8):
return (x + eps).log() # p가 0이어도 log(ε) ≈ -18 (유한)
# 또는 log-softmax 직접 계산 (수치적으로 더 안정적):
def log_softmax(logits):
max_val = max(val.data for val in logits)
log_sum_exp = (sum([(v - max_val).exp() for v in logits])).log()
return [(v - max_val) - log_sum_exp for v in logits]
# log_softmax(logits)[target] 를 직접 손실로 사용하면 log(prob) 보다 안정적
Softmax Max 차감 — 수학적 동치 증명
microgpt의 softmax()는 max_val을 먼저 뺍니다. 왜 결과가 같은지 수학적으로 보입니다.
# 원본 Softmax: σ(z)ᵢ = exp(zᵢ) / Σⱼ exp(zⱼ)
#
# 임의의 상수 c를 빼도 결과가 같음을 증명:
# σ(z - c)ᵢ = exp(zᵢ - c) / Σⱼ exp(zⱼ - c)
# = exp(zᵢ) × exp(-c) / [Σⱼ exp(zⱼ) × exp(-c)]
# = exp(zᵢ) / Σⱼ exp(zⱼ) ← exp(-c)가 약분됨!
# = σ(z)ᵢ ✓
#
# 실용적 의미:
# z = [1000, 999, 998] (큰 수)
# 직접 계산: exp(1000) = 5×10^434 → Python float overflow → inf
# inf / inf = nan → 완전히 망가짐
#
# max 차감: c = 1000
# z - c = [0, -1, -2]
# exp([0, -1, -2]) = [1.0, 0.368, 0.135] → 완전 안전!
float은 IEEE 754 배정밀도 (64비트). 최대 표현 값은 약 1.8 × 10^308.
math.exp(710) 이상이면 OverflowError 발생.
logit이 710을 넘으면 위험합니다 — max 차감이 이를 방어합니다.
Python 재귀 깊이 한계 — build_topo 주의
# Python 기본 재귀 한계: sys.getrecursionlimit() = 1000
#
# microgpt에서 build_topo 재귀 깊이:
# loss = (1/n) × sum(losses)
# losses = n개 cross-entropy 값
# 각 loss = softmax + log 연산 체인
#
# 한 스텝 처리 시 생성되는 Value 노드 수:
# - 임베딩: 16 nodes
# - Attention (n_head=4, T 토큰): T × (4×head_dim + softmax) nodes
# - MLP: 16→64→16, relu 포함 → ~200 nodes
# - loss: n개 토큰의 chain
#
# "isabella" (8글자) 처리 시:
# n = 9 위치, 각 위치당 ~500 nodes → 총 ~4,500 nodes
# 재귀 깊이는 그래프 최대 깊이 (체인 길이) ≈ 수백
# → 1,000 한계에 걸릴 가능성 있음
#
# 더 깊은 네트워크(n_layer=4+) 사용 시 재귀 한계 초과 가능:
import sys
sys.setrecursionlimit(10000) # 안전하게 늘리기
부동소수점 누적 오차
# 스칼라 연산은 텐서 연산보다 누적 오차가 클 수 있음
# 예: 1000번의 덧셈에서 오차 누적
#
# sum([0.1] * 10) == 1.0 → False (≈ 0.9999999999999999)
# Python의 부동소수점 특성 — 이진수로 표현할 수 없는 소수들
#
# microgpt에서는 이 오차가 학습에 무시할 수 있는 수준:
# - 4,192개 파라미터 — 매우 작은 모델
# - 1,000 스텝 — 짧은 학습
# - Adam이 자동으로 보상: v_hat 분모가 적응적으로 조정
#
# 대규모 모델에서는 bfloat16 또는 float32 + gradient scaling 사용
| 문제 | 원인 | microgpt 방어책 | 프로덕션 방어책 |
|---|---|---|---|
| exp() 오버플로 | logit 값이 710 초과 | softmax max 차감 | 동일 + bfloat16 |
| log(0) 오류 | softmax 포화 후 타겟 확률 0 | 작은 초기값 + LR 감쇠 | log-softmax + label smoothing |
| 재귀 한계 초과 | build_topo 재귀 깊이 | 단순 구조(n_layer=1) | iterative DFS 또는 PyTorch |
| 기울기 소실 | 역전파 시 기울기 < 0.01 | 잔차 연결 | gradient clipping + warmup |
| 기울기 폭발 | 역전파 시 기울기 > 1e6 | 작은 lr + 감쇠 | clip_grad_norm_(max=1.0) |
순전파 수치 추적 — "ab" 이름의 첫 토큰 처리
학습 완료 후 "ab" 이름을 학습하는 한 스텝의 순전파를 개념적으로 추적합니다. 실제 수치는 가중치 초기화에 따라 다르지만, 각 단계에서 무슨 일이 일어나는지를 구체적으로 이해합니다.
Step 0 — BOS(26) 입력, pos=0, 타겟='a'(0)
# ── 임베딩 ──────────────────────────────────────────────
token_id = 26 # BOS
pos_id = 0
tok_emb = state_dict['wte'][26]
# → [0.031, -0.072, 0.085, -0.041, 0.097, -0.063, ...] (16개 Value)
# 각 값은 N(0, 0.08²)로 초기화된 후 학습으로 조정됨
pos_emb = state_dict['wpe'][0]
# → [0.055, 0.021, -0.088, 0.067, -0.034, 0.092, ...] (16개 Value)
# 위치 0: 이름의 시작 위치 특성 인코딩
x = [t + p for t, p in zip(tok_emb, pos_emb)]
# → [0.086, -0.051, -0.003, 0.026, ...] (BOS + 위치0 합산)
x = rmsnorm(x)
# RMS = sqrt(mean(x²)) ≈ sqrt(0.086²+0.051²+... / 16)
# scale = 1/RMS → 각 원소가 단위 RMS로 정규화됨
# → [-0.89, 0.52, 0.03, -0.27, ...] (크기 정규화, 방향 유지)
# ── Attention (pos=0: keys가 비어있어 자기 자신만 봄) ──
q = linear(x, state_dict['layer0.attn_wq']) # 16×16 행렬 곱 → 16차원
k = linear(x, state_dict['layer0.attn_wk']) # 16차원
v = linear(x, state_dict['layer0.attn_wv']) # 16차원
keys[0].append(k) # KV 캐시: 첫 번째 항목 추가
values[0].append(v)
# 각 헤드 (head_dim=4):
# 헤드 0: q[0:4] 와 keys[0][0][0:4] 내적 → attn_logit 1개
# → attn_logit / 2.0 (= /√4)
# → softmax([single_value]) = [1.0] ← 자기 자신 100% 집중
# → head_out = 1.0 × v[0:4] (value 그대로)
#
# pos=0에서는 어텐션이 자기 자신에 100% 집중 (다른 선택지 없음!)
x_attn = q # 개념적으로: pos=0에서 attention은 거의 항등 함수
# 출력 프로젝션 + 잔차 연결
x = linear(x_attn, state_dict['layer0.attn_wo'])
x = [a + b for a, b in zip(x, x_residual)] # 잔차 더하기
# ── MLP ────────────────────────────────────────────────
x_residual = x
x = rmsnorm(x)
x = linear(x, state_dict['layer0.mlp_fc1']) # 16 → 64
# 64개 중 약 절반이 ReLU로 0이 됨 (죽은 뉴런)
x = [xi.relu() for xi in x] # 음수 → 0
x = linear(x, state_dict['layer0.mlp_fc2']) # 64 → 16
x = [a + b for a, b in zip(x, x_residual)]
# ── 언임베딩 ────────────────────────────────────────────
logits = linear(x, state_dict['lm_head']) # 16 → 27
# 학습 후: 'a','e','i','m','l' 등의 logit이 높아야 함
# (이름은 모음이나 흔한 자음으로 시작하는 경우 많음)
#
# 예시 (학습 완료 후):
# logits ≈ {'a': 2.3, 'l': 2.1, 'k': 1.9, 'e': 1.8, ..., 'x': 0.2, 'z': 0.1}
# BOS 타겟이 아닌 이름 시작 글자들이 높은 점수
probs = softmax(logits)
# 'a' 확률: exp(2.3) / Σ exp(logits) ≈ 0.12 (12% 확률)
# 타겟이 'a'(0)이므로: loss = -log(0.12) ≈ 2.12
Step 1 — 'a'(0) 입력, pos=1, 타겟='b'(1)
# keys[0]에는 이제 [K_BOS] 1개 → pos=1에서는 2개를 봄
# 'a' + 위치1 임베딩 → x 계산 (이전과 동일 과정)
# ...
# Attention에서:
# 각 헤드: q_h[현재] × K_BOS / 2 AND q_h[현재] × K_a / 2
# attn_logits = [score_BOS, score_a] (2개)
# weights = softmax([score_BOS, score_a]) → 합이 1
#
# 예: weights ≈ [0.3, 0.7] ("a가 BOS보다 더 관련있다")
# head_out = 0.3 × V_BOS[0:4] + 0.7 × V_a[0:4]
#
# 이 어텐션이 "a 다음엔 무엇이 오는가"를 학습합니다.
# names.txt에서 'a' 다음에 자주 오는 글자들이 높은 확률로 학습됨
keys[0].append(k) # K_a 추가, 이제 [K_BOS, K_a]
values[0].append(v)
# 최종 logits:
# 학습 후 'b','d','l','n','r','s' 등이 높은 점수
# (영어 이름에서 'a' 다음에 자주 오는 글자들)
# 타겟='b'(1): loss = -log(P('b'))
차원 변환 요약표
| 단계 | 입력 형태 | 출력 형태 | 연산 | 파라미터 |
|---|---|---|---|---|
| 토큰 임베딩 | scalar (token_id) | [16] | table lookup | wte[27×16] |
| 위치 임베딩 | scalar (pos_id) | [16] | table lookup | wpe[16×16] |
| 합산 + RMSNorm | [16] + [16] | [16] | add + scale | 없음 |
| Q/K/V 프로젝션 | [16] | [16] × 3 | linear | 3 × [16×16] |
| 어텐션 (T토큰) | [16] + T×[16] | [16] | scaled dot-product | 없음(캐시 사용) |
| 출력 프로젝션 | [16] | [16] | linear | [16×16] |
| MLP fc1 | [16] | [64] | linear + ReLU | [64×16] |
| MLP fc2 | [64] | [16] | linear | [16×64] |
| lm_head | [16] | [27] | linear | [27×16] |
| softmax | [27] | [27] | exp / sum | 없음 |
| cross-entropy | [27] probs | scalar | -log(probs[target]) | 없음 |
AdamW & 최신 옵티마이저 변형
microgpt의 Adam 구현을 기준으로 프로덕션 옵티마이저들과의 차이를 비교합니다.
Adam vs AdamW — Weight Decay의 위치가 결정적
# ── Adam + L2 Regularization (잘못된 방식) ──
# L2: loss에 λ×‖W‖² 추가 → gradient에 λ×W 추가
g_l2 = p.grad + lambda_wd * p.data # L2 포함 gradient
m[i] = beta1 * m[i] + (1 - beta1) * g_l2
v[i] = beta2 * v[i] + (1 - beta2) * g_l2**2
# 문제: Adam이 g_l2의 크기를 정규화해버림
# v_hat에 의한 나눗셈이 weight decay 효과를 희석시킴
# → weight decay가 실제로는 잘 작동하지 않음!
# ── AdamW (올바른 방식, Loshchilov & Hutter 2017) ──
m[i] = beta1 * m[i] + (1 - beta1) * p.grad # 순수 gradient만
v[i] = beta2 * v[i] + (1 - beta2) * p.grad**2 # 순수 gradient만
m_hat = m[i] / (1 - beta1**t)
v_hat = v[i] / (1 - beta2**t)
p.data -= lr_t * m_hat / (v_hat**0.5 + eps_adam) # Adam 업데이트
p.data -= lr_t * lambda_wd * p.data # weight decay 별도 적용!
# → weight decay가 Adam 적응 학습률에 희석되지 않고 독립적으로 작용
# → GPT-3, LLaMA, nanoGPT가 사용하는 방식
weight_decay=0.1을 사용하며 임베딩/bias에는 적용하지 않습니다.
Lion 옵티마이저 (Evolved Sign Momentum, 2023)
# Google Brain의 프로그래밍으로 진화된 옵티마이저
# Adam보다 메모리 효율적: m 버퍼 1개만 필요 (v 버퍼 불필요)
#
# Lion 업데이트 규칙:
for i, p in enumerate(params):
# 업데이트 방향: m과 gradient의 선형 조합
update = sign(beta1 * m[i] + (1 - beta1) * p.grad)
p.data -= lr_t * (update + lambda_wd * p.data)
# 모멘텀 업데이트 (업데이트 후)
m[i] = beta2 * m[i] + (1 - beta2) * p.grad
# 핵심 차이: sign() 사용 → 업데이트 크기가 항상 ±lr로 고정
# Adam: lr × m_hat / √v_hat (크기 가변)
# Lion: lr × sign(...) (크기 고정)
#
# 장점: v 버퍼 불필요 → 메모리 33% 절약 (m+v → m만)
# 단점: 적절한 lr 조정 필요 (보통 Adam의 10배 작게)
학습률 스케줄러 완전 비교
| 스케줄러 | 수식 | 사용처 | 특징 |
|---|---|---|---|
| Constant | lr_t = lr | 단순 실험 | 후반 수렴 불안정 |
| Linear Decay (microgpt) |
lr_t = lr × (1 - t/T) | 짧은 학습 (1K 스텝) | 구현 단순, 후반 급격히 감소 |
| Step Decay | lr_t = lr × γ^⌊t/s⌋ | 전통적 CNN 학습 | 단계적 감소, 불연속 |
| Cosine Decay | lr_t = lr_min + ½(lr-lr_min)(1 + cos(πt/T)) | GPT-2/3, nanoGPT | 부드러운 감소, 최솟값 보장 |
| Warmup + Cosine (GPT-3 방식) |
선형 warmup → cosine decay | 대규모 LLM 학습 표준 | 초반 불안정 방지 + 부드러운 수렴 |
| 1-Cycle | lr 상승 → 감소 | FastAI 방식 | 빠른 수렴, 소규모 실험 |
# Cosine Decay with Linear Warmup (nanoGPT 방식)
warmup_steps = 100 # 전체 스텝의 10%
lr_min = 0.0001 # 최솟값 (lr의 10%)
def get_lr(t):
if t < warmup_steps:
return learning_rate * t / warmup_steps # 선형 증가
# Cosine decay
progress = (t - warmup_steps) / (num_steps - warmup_steps)
return lr_min + 0.5 * (learning_rate - lr_min) * (1 + math.cos(math.pi * progress))
# microgpt 선형 감쇠와 비교:
# step=0: linear=0.01000, cosine=0.0000 (warmup 시작)
# step=100: linear=0.00900, cosine=0.0100 (warmup 완료)
# step=500: linear=0.00500, cosine=0.0055 (cosine 중반)
# step=999: linear=0.00001, cosine=0.0001 (cosine 끝)
고급 샘플링 전략
microgpt의 temperature 샘플링 외에, 프로덕션 LLM에서 사용하는 다양한 샘플링 전략을 구현합니다.
Greedy Decoding — 항상 가장 확률 높은 토큰
# Temperature 없이 argmax 선택
# 장점: 결정적(deterministic), 항상 같은 출력
# 단점: 반복 루프에 빠질 수 있음, 다양성 없음
def greedy_decode(logits):
return max(range(len(logits)), key=lambda i: logits[i].data)
# microgpt 추론에서 사용 예:
# token_id = greedy_decode(logits) # temperature 없이
# 주의: temperature=0은 ZeroDivisionError → greedy_decode()를 대신 사용
Top-K 샘플링
# 확률 상위 K개 토큰만 남기고 나머지는 -∞ 처리 후 샘플링
# 장점: 낮은 확률의 이상한 토큰을 완전 차단
# 단점: K 선택이 어려움 (K=1은 greedy, K=vocab_size는 pure sampling)
def top_k_sample(logits, k=10, temperature=1.0):
# 1. 상위 K개 인덱스 찾기
sorted_indices = sorted(range(len(logits)),
key=lambda i: logits[i].data, reverse=True)
top_k_indices = sorted_indices[:k]
# 2. 상위 K개 logit만 추출
top_k_logits = [logits[i] / temperature for i in top_k_indices]
# 3. softmax → 샘플링
top_k_probs = softmax(top_k_logits)
local_idx = random.choices(range(k), weights=[p.data for p in top_k_probs])[0]
return top_k_indices[local_idx]
# k=5 예시: 상위 5개 ['a', 'e', 'l', 'k', 'm'] 만 고려
# 나머지 22개 글자는 아무리 작은 확률이어도 완전히 제외
Top-P (Nucleus) 샘플링
# 확률의 누적 합이 p를 넘을 때까지 상위 토큰을 포함
# 장점: 확률 분포가 넓을 때(불확실할 때)는 더 많은 토큰 포함
# 확률 분포가 좁을 때(확실할 때)는 적은 토큰만 포함
# Top-K보다 적응적 → 더 자연스러운 텍스트 생성
def top_p_sample(logits, p=0.9, temperature=1.0):
# 1. 내림차순 정렬
sorted_indices = sorted(range(len(logits)),
key=lambda i: logits[i].data, reverse=True)
sorted_logits = [logits[i] / temperature for i in sorted_indices]
sorted_probs = softmax(sorted_logits)
# 2. 누적 확률이 p를 초과하는 최소 집합 찾기
nucleus = []
cumulative = 0.0
for idx, prob in zip(sorted_indices, sorted_probs):
nucleus.append((idx, prob))
cumulative += prob.data
if cumulative >= p:
break
# 3. nucleus에서 샘플링
indices = [item[0] for item in nucleus]
weights = [item[1].data for item in nucleus]
return random.choices(indices, weights=weights)[0]
# p=0.9 예시:
# 확률이 고른 경우: [0.1, 0.09, 0.08, ...] → 누적 0.9 위해 10개+ 포함
# 확률이 집중된 경우: [0.85, 0.08, ...] → 누적 0.9 위해 2개만 포함
Repetition Penalty
# 이미 생성된 토큰의 logit을 낮춰 반복 방지
# GPT-2 공식 생성에서 사용
def apply_repetition_penalty(logits, generated, penalty=1.3):
for token_id in set(generated): # 중복 처리 방지
if logits[token_id].data > 0:
logits[token_id] = logits[token_id] / Value(penalty) # 양수 logit 낮추기
else:
logits[token_id] = logits[token_id] * Value(penalty) # 음수 logit 더 낮추기
return logits
# penalty=1.0: 효과 없음
# penalty=1.3: 이미 나온 글자의 확률 약 23% 감소
# penalty=2.0: 이미 나온 글자의 확률 50% 감소 (과도하면 이상한 이름)
| 전략 | 적합 상황 | 파라미터 | 특징 |
|---|---|---|---|
| Greedy | 테스트, 재현성 필요 | 없음 | 항상 같은 결과, 반복 위험 |
| Temperature (microgpt 기본) | 일반 이름 생성 | T=0.5~1.0 | 균형 잡힌 다양성 |
| Top-K | 특정 품질 기준 유지 | K=10~50 | 이상한 토큰 완전 차단 |
| Top-P (Nucleus) | LLM 프로덕션 표준 | p=0.9~0.95 | 적응적 후보 집합 |
| Temperature + Top-P | GPT-4 기본값 | T=1.0, p=0.9 | 두 방법의 조합 |
| Repetition Penalty | 반복 방지 필요 시 | penalty=1.2~1.5 | 이미 생성된 토큰 억제 |
PyTorch 마이그레이션 가이드 — microgpt → nanoGPT
microgpt의 각 구성요소를 PyTorch로 1:1 변환하는 방법을 코드와 함께 보여줍니다. 이 과정에서 수동 구현의 의미가 더욱 명확해집니다.
① Value 클래스 → torch.Tensor
# ── microgpt ──────────────────────────────────────────
a = Value(2.0)
b = Value(3.0)
c = a * b + a # 계산 그래프 자동 구성
c.backward() # 위상 정렬 + 연쇄 법칙
print(a.grad) # 4.0
print(b.grad) # 2.0
# ── PyTorch 동치 ──────────────────────────────────────
import torch
a = torch.tensor(2.0, requires_grad=True) # requires_grad=True ← 역전파 대상
b = torch.tensor(3.0, requires_grad=True)
c = a * b + a
c.backward() # PyTorch 내부에서 동일한 위상 정렬 + 연쇄 법칙
print(a.grad) # tensor(4.)
print(b.grad) # tensor(2.)
# 차이점:
# - Value: 스칼라 하나 = 객체 1개 (느림, 파이썬 오버헤드)
# - Tensor: 배열 전체 = 객체 1개 (빠름, C++/CUDA 연산)
② 헬퍼 함수 → torch.nn 모듈
import torch
import torch.nn as nn
import torch.nn.functional as F
# ── linear(x, w) → nn.Linear ──
# microgpt:
linear(x, state_dict['layer0.attn_wq']) # list 내포, O(n³) 스칼라 루프
# PyTorch:
attn_wq = nn.Linear(n_embd, n_embd, bias=False)
attn_wq(x_tensor) # BLAS/cuBLAS 행렬 곱 → GPU에서 수천 배 빠름
# ── softmax(logits) → F.softmax ──
# microgpt:
softmax(logits) # max 차감 + exp + sum + div, 스칼라
# PyTorch:
F.softmax(logits_tensor, dim=-1) # 내부에서 동일한 max 차감 적용, 텐서 연산
# ── rmsnorm(x) → nn.RMSNorm ──
# microgpt:
rmsnorm(x) # 학습 가능 γ 없음
# PyTorch (≥2.4):
rms_norm = nn.RMSNorm(n_embd, eps=1e-5) # 학습 가능 γ 있음 (기본 1로 초기화)
rms_norm(x_tensor)
# 또는 직접 구현:
def rmsnorm_torch(x, eps=1e-5):
ms = (x ** 2).mean(dim=-1, keepdim=True)
return x * (ms + eps) ** -0.5
③ gpt() 함수 → nn.Module
import torch
import torch.nn as nn
import torch.nn.functional as F
class MicroGPT(nn.Module):
def __init__(self, vocab_size=27, n_embd=16, n_head=4, n_layer=1, block_size=16):
super().__init__()
self.wte = nn.Embedding(vocab_size, n_embd) # microgpt: state_dict['wte']
self.wpe = nn.Embedding(block_size, n_embd) # microgpt: state_dict['wpe']
self.layers = nn.ModuleList([Block(n_embd, n_head) for _ in range(n_layer)])
self.lm_head = nn.Linear(n_embd, vocab_size, bias=False)
# Weight Tying: wte와 lm_head 공유 (nanoGPT 방식)
self.lm_head.weight = self.wte.weight # 파라미터 432개 절약!
def forward(self, idx): # idx: [B, T] 토큰 인덱스
B, T = idx.shape
pos = torch.arange(0, T, device=idx.device) # [T]
x = self.wte(idx) + self.wpe(pos) # [B, T, n_embd]
x = F.rms_norm(x, [n_embd]) # RMSNorm
for layer in self.layers:
x = layer(x) # [B, T, n_embd]
logits = self.lm_head(x) # [B, T, vocab_size]
return logits
# 학습 루프 (PyTorch 버전)
model = MicroGPT()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01,
betas=(0.85, 0.99), eps=1e-8)
for step in range(num_steps):
# 토큰 텐서: [1, T] → [T-1] input, [T-1] target
tokens = torch.tensor(token_sequence).unsqueeze(0) # [1, T]
logits = model(tokens[:, :-1]) # [1, T-1, 27]
loss = F.cross_entropy(logits.view(-1, 27),
tokens[:, 1:].view(-1)) # scalar
optimizer.zero_grad() # grad 초기화 (microgpt: p.grad = 0)
loss.backward() # 역전파
optimizer.step() # Adam 업데이트
예상 속도 향상
| 구현 방식 | 1000 스텝 소요 시간 | 속도 비율 | 특징 |
|---|---|---|---|
| microgpt (순수 파이썬) | ~5~10분 | 1× | 의존성 0, 교육 목적 |
| NumPy 벡터화 | ~10~30초 | ~20× | 스칼라 → 배열 연산 |
| PyTorch CPU | ~1~5초 | ~100× | 자동 최적화, BLAS 활용 |
| PyTorch GPU (RTX 3090) | ~0.1초 | ~1000× | 병렬 텐서 연산, CUDA |
loss.backward()가 마법처럼 느껴지지만,
microgpt를 먼저 구현하면 "아, 이게 위상 정렬 + 연쇄 법칙이구나"를 알게 됩니다.
디버깅 가이드 & 흔한 실수
microgpt.py를 직접 실행하거나 변형할 때 마주치는 오류들을 유형별로 정리합니다.
런타임 오류 — 즉시 크래시
| 오류 | 원인 | 해결 |
|---|---|---|
ValueError: math domain error |
math.log(0) 또는 음수 입력 |
softmax 결과 확인, eps=1e-8 추가, 또는 log-softmax 사용 |
ValueError: 'E' is not in list |
uchars.index('E') — 대문자 입력 |
입력 텍스트 소문자 변환: doc.lower() |
ZeroDivisionError |
temperature = 0 설정 |
temperature > 0 유지, 또는 greedy_decode() 사용 |
RecursionError: maximum recursion depth exceeded |
build_topo 재귀가 Python 기본 한계 1000 초과 |
sys.setrecursionlimit(10000) 또는 iterative DFS 구현 |
IndexError: list index out of range |
vocab_size=26으로 잘못 지정, BOS(26)가 범위 밖 |
vocab_size = len(uchars) + 1 = 27 확인 |
OverflowError: math range error |
math.exp()에 710 이상 입력 |
softmax max 차감 확인, 또는 lr 줄이기 (logit 폭발 가능성) |
학습 수렴 문제
- ❌ Loss가 3.30 (랜덤 베이스라인)에서 전혀 내려가지 않는 경우
-
loss.backward()가 실행되지 않거나,p.grad가 모두 0인지 확인- 파라미터 업데이트 후
p.grad = 0이 아닌p.grad = 0.0으로 해야 함 (실수형) params리스트에 일부 파라미터가 빠졌는지 확인- Adam 버퍼
m,v가 params와 길이가 같은지 확인
- ❌ Loss가 발산하는 경우 (NaN 또는 무한대)
-
- 학습률이 너무 큼 →
learning_rate = 0.001로 줄이기 - 초기화 std가 너무 큼 →
std = 0.02로 줄이기 - softmax 오버플로 → max 차감 확인
eps_adam = 1e-8이 너무 작은 경우 드물게 발생 →1e-6으로 늘리기
- 학습률이 너무 큼 →
- ❌ Loss가 2.7~2.8에서 멈추는 경우
-
- 정상 범위: 1,000 스텝으로는 2.37이 목표 (데이터와 모델 크기 한계)
num_steps = 5000으로 늘리면 더 낮아질 수 있음n_layer = 2, n_embd = 32로 모델 크기 증가 → 더 빠른 수렴
- ❌ 생성된 이름이 모두 동일한 경우
-
- temperature가 너무 낮음 →
temperature = 0.8~1.0으로 높이기 random.seed()가 반복 설정되어 있는지 확인 (추론 루프 안에 있으면 안 됨)
- temperature가 너무 낮음 →
- ❌ 생성된 이름이 모두 단 1~2글자인 경우
-
- 모델이 BOS를 너무 쉽게 생성 → 학습이 부족하거나 temperature가 너무 낮음
- 더 많은 학습 스텝 또는 높은 temperature 시도
조용한 버그 — 오류 없이 잘못된 결과
# 버그 1: grad 초기화를 업데이트 전에 하는 경우
for i, p in enumerate(params):
p.grad = 0 # ❌ 잘못됨: grad를 먼저 초기화하면 m, v 계산이 0 기반
m[i] = beta1 * m[i] + (1-beta1) * p.grad # ← 항상 0!
# ...
# 올바른 순서: 업데이트 후 초기화
for i, p in enumerate(params):
g = p.grad # ✓ 먼저 grad 읽기
m[i] = beta1 * m[i] + (1-beta1) * g
v[i] = beta2 * v[i] + (1-beta2) * g**2
# ... 업데이트 ...
p.grad = 0 # ✓ 마지막에 초기화
# 버그 2: keys/values를 초기화하지 않고 재사용
for step in range(num_steps):
# ❌ 잘못됨: 이전 이름의 K, V가 누적되어 있음
for pos_id in range(n):
logits = gpt(token_id, pos_id, keys, values)
# ✓ 올바름: 각 이름 처리 전 KV 캐시 초기화
for step in range(num_steps):
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
for pos_id in range(n):
logits = gpt(token_id, pos_id, keys, values)
# 버그 3: state_dict를 함수 내부에서 선언하는 경우
def gpt(token_id, pos_id, keys, values):
state_dict = { ... } # ❌ 매 호출마다 재초기화! 학습이 전혀 안 됨
# ✓ state_dict는 함수 밖 전역 스코프에 선언해야 함
디버깅 체크리스트
- 학습 시작 전
loss.data가log(27) ≈ 3.296근처인지 확인 (랜덤 베이스라인) - 10 스텝 후 loss가 감소하고 있는지 확인 (그래프나 print)
len(params)가 정확히 4,192인지 확인- 추론 전
keys, values가 새로 초기화되었는지 확인 - vocab_size가
len(uchars) + 1 = 27인지 확인 - 모든 입력 문자가 소문자인지 확인
성능 분석 & 시간 복잡도
microgpt의 각 단계별 계산 비용을 분석하고, 왜 느린지, 어디서 병목이 발생하는지를 이해합니다.
시간 복잡도 — 기호 정의
- B = batch size (microgpt: 1)
- T = 시퀀스 길이 (이름 길이, 최대 16)
- d = n_embd = 16 (임베딩 차원)
- H = n_head = 4 (어텐션 헤드 수)
- d_h = head_dim = d/H = 4 (헤드당 차원)
- V = vocab_size = 27
- 4d = MLP 확장 차원 = 64
| 연산 | 순전파 복잡도 | 실제 연산 수 (T=5, d=16) |
|---|---|---|
| 임베딩 조회 (wte + wpe) | O(d) | 32 덧셈 |
| RMSNorm | O(d) | ~64 연산 |
| Q, K, V 프로젝션 (각각) | O(d²) | 16×16 = 256 (×3) |
| 어텐션 점수 (T 토큰, H 헤드) | O(T × d) | 5 × 4 × 4 = 80 내적 |
| Softmax (T개) | O(T) | ~50 연산 |
| Value 가중합 | O(T × d) | 5 × 4 × 4 = 80 |
| 출력 프로젝션 | O(d²) | 16×16 = 256 |
| MLP (fc1 + relu + fc2) | O(d × 4d) | 16×64 + 64×16 = 2,048 |
| lm_head | O(V × d) | 27×16 = 432 |
| 총 순전파 | O(T×d² + T²×d) | ~4,000 스칼라 연산 (T=5 기준) |
# 전체 학습 1000 스텝 연산 추정:
#
# 이름당 평균 길이 ≈ 5.5글자 → 평균 T ≈ 7 (BOS 포함)
# 순전파당 스칼라 곱셈: ~6,000개
# 역전파: 순전파의 약 2배 → ~12,000개
# Value 객체당 Python 오버헤드: ~1μs
#
# 한 스텝: ~18,000 스칼라 연산 × 1μs = 18ms
# 1,000 스텝: ~18초 (실제: ~5~10분 — Python 객체 오버헤드가 큼)
#
# NumPy 벡터화 시:
# 18,000 스칼라 곱 → 1번의 16×16 행렬 곱 (BLAS: ~1μs)
# 예상 속도 향상: 18ms → 0.1ms = 180배
Python 구현 병목 분석
| 병목 | microgpt 속도 | NumPy 등가 | 속도 차이 |
|---|---|---|---|
| 행렬-벡터 곱 (linear) | O(n²) 중첩 for 루프 스칼라 Value 곱셈 |
np.dot(W, x)BLAS 최적화 |
100~1000× |
| softmax 분모 합산 | sum(exps) Python 리스트__radd__ 체인 |
np.sum(exps)벡터 연산 |
50~200× |
| 역전파 위상 정렬 | DFS 재귀 Python 함수 호출 오버헤드 |
PyTorch autograd C++ 연산 그래프 |
1000×+ |
| Value 객체 생성 | 한 스텝당 ~수만 개 Python 힙 할당 |
텐서 뷰 (zero-copy) | 100×+ |
어텐션의 O(T²) 복잡도 — GPT의 근본 한계
# Attention 행렬: T × T (모든 토큰 쌍의 점수)
# T=16 → 16×16 = 256 점수
# T=512 → 512×512 = 262,144 점수 (1,024배!)
# T=128K (Claude 3.5) → 128K×128K ≈ 16.4억 점수
#
# 이 O(T²) 문제를 해결하는 기술들:
#
# 1. Flash Attention (Dao et al., 2022):
# 동일한 O(T²) 연산이지만 메모리 접근 패턴 최적화
# GPU 메모리 O(T²) → O(T) (행렬 저장 없이 on-the-fly 계산)
# nanoGPT: F.scaled_dot_product_attention() 으로 자동 적용
#
# 2. Sliding Window Attention (Mistral):
# 각 토큰이 이전 W개만 봄 → O(T × W)
# W=4096이면 긴 시퀀스에서도 효율적
#
# 3. GQA (Grouped Query Attention, LLaMA-2/3):
# 여러 Q 헤드가 K, V를 공유 → KV 캐시 크기 감소
# n_head=32, n_kv_head=8 → K, V 메모리 4배 절약
KV 캐시 메모리 사용량
# microgpt KV 캐시 분석:
# 레이어당: T개 키 + T개 값, 각각 n_embd=16 차원
# T=16 (최대), n_layer=1:
# 메모리 = 2 × 16 × 16 = 512 Value 객체
# × ~150 bytes/객체 ≈ 76.8 KB (매우 작음)
#
# GPT-4 KV 캐시 비교 (n_embd=12288, n_layer=96, T=128K):
# 2 × 128K × 12288 × 96 × 4 bytes (float32)
# ≈ 2 × 128K × 12288 × 96 × 4 ≈ 300 GB (!)
# 실제로는 fp16 + GQA 로 대폭 줄임
microgpt (5~10분) → NumPy 벡터화 (~30초) → PyTorch CPU (~5초) → PyTorch GPU (0.1초) → Flash Attention + 배치 처리 (0.01초). 각 단계마다 10~100배 개선이 가능하며, 원리는 모두 동일합니다.
참고 자료
원본 소스
- Andrej Karpathy — microgpt 블로그 포스트 (2026.02.12) — 저자 직접 작성 공식 가이드. 데이터셋~추론 전체 해설
- microgpt.py 원본 소스 코드 (GitHub Gist) — 약 243줄 순수 파이썬. 토크나이저·Autograd·GPT·Adam·추론 전체
- @karpathy GitHub — 저자의 모든 오픈소스 교육용 AI 프로젝트
- @karpathy X(Twitter) — 최신 연구·강의 업데이트
관련 Karpathy 프로젝트
| 프로젝트 | 설명 | 관계 |
|---|---|---|
| micrograd | 스칼라 autograd 엔진 — Value 클래스, 위상 정렬 역전파 (~150줄) | microgpt Value 클래스의 직접적 원형 |
| makemore | 문자 수준 언어 모델 — Bigram → MLP → Transformer 단계별 강의 | names.txt 데이터셋 출처, 토크나이저 구조 계승 |
| nanoGPT | 실용적 GPT 훈련 — PyTorch, Flash Attention, DDP, GPT-2 재현, Shakespeare 예제 | microgpt의 "Production 확장" — 같은 원리, 실용적 규모 |
| llm.c | C/CUDA로 LLM 훈련 — PyTorch조차 없이 구현 | 의존성 제거 철학의 극한 — microgpt 방향의 시스템 레벨 확장 |
학습 영상 (Karpathy YouTube)
- 안드레 카파시가 들려주는 GPT 이야기 | microgpt.py — microgpt.py 전체 코드를 직접 설명하는 한국어 해설 영상
- Let's build GPT: from scratch, in code, spelled out — nanoGPT 라이브 코딩 (2시간). 학습 루프·Adam·추론을 실시간으로 구현
- The spelled-out intro to neural networks and backpropagation: building micrograd — micrograd + 역전파 강의 (2시간25분)
- The spelled-out intro to language modeling: building makemore — names.txt + 문자 수준 LM
- Let's build a GPT Tokenizer — BPE 토크나이저 (2시간). microgpt 문자 수준 토크나이저와 비교
- Neural Networks: Zero to Hero (재생목록) — Karpathy 전체 강의 시리즈 (micrograd → makemore → GPT)
핵심 논문
- Adam: A Method for Stochastic Optimization (Kingma & Ba, 2014) — microgpt가 구현한 Adam 옵티마이저 원논문. β1=0.9, β2=0.999, ε=1e-8 기본값 출처
- Attention Is All You Need (Vaswani et al., 2017) — Transformer 원논문. Q·K·V·Scaled Dot-Product·Multi-head·Cross-Entropy 수식 출처
- GPT-2: Language Models are Unsupervised Multitask Learners (OpenAI, 2019) — microgpt가 "follow GPT-2"라고 명시한 기준 아키텍처
- GPT-3: Language Models are Few-Shot Learners (Brown et al., 2020) — nanoGPT의 Cosine LR with warmup 스케줄 출처
- Root Mean Square Layer Normalization (Zhang & Sennrich, 2019) — microgpt의
rmsnorm수식 근거 - FlashAttention: Fast and Memory-Efficient Exact Attention (Dao et al., 2022) — nanoGPT가 채택한 IO-aware 어텐션 알고리즘
- Press & Wolf (2016) — Using the Output Embedding to Improve Language Models — Weight Tying 원논문
- Identity Mappings in Deep Residual Networks (He et al., 2016) — Pre-LN vs Post-LN 잔차 연결 연구