Recent Posts
Recent Comments
반응형
«   2026/02   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
Archives
Today
Total
관리 메뉴

오늘도 공부

Microgpt 코드 분석 #2 본문

AI

Microgpt 코드 분석 #2

행복한 수지아빠 2026. 2. 20. 10:10
반응형

 

 

Microgpt 분석 #1

microgptThis is a brief guide to my new art project microgpt, a single file of 200 lines of pure Python with no dependencies that trains and inferences a GPT. This file contains the full algorithmic content of what is needed: dataset of documents, tokenize

javaexpert.tistory.com

 

 

microgpt

This is a brief guide to my new art project microgpt, a single file of 200 lines of pure Python with no dependencies that trains and inferences a GPT. This file contains the full algorithmic content of what is needed: dataset of documents, tokenizer, autog

karpathy.github.io

 


Step 0) 파일 머리말 + import + 랜덤 시드

"""
The most atomic way to train and run inference for a GPT in pure, dependency-free Python.
This file is the complete algorithm.
Everything else is just efficiency.

@karpathy
"""

import os       # os.path.exists
import math     # math.log, math.exp
import random   # random.seed, random.choices, random.gauss, random.shuffle
random.seed(42) # Let there be order among chaos

자세한 설명

  • 맨 위의 """ ... """는 **설명 문장(주석)**이야. 실행에는 영향이 없고 “이 파일은 GPT 학습/생성의 가장 최소 단위 구현이다”라고 선언하는 부분.
  • import os: 파일이 존재하는지 확인할 때 os.path.exists를 쓰기 위해 가져와.
  • import math: log, exp 같은 수학 함수를 쓰기 위해 가져와.
  • import random: 랜덤 초기화/샘플링/셔플/정규분포 난수 등을 쓰기 위해 가져와.
  • random.seed(42): **랜덤을 ‘고정’**하는 주문이야.
    • 같은 seed면 매번 실행할 때 처음 가중치(모델의 뇌)도 같고, 결과도 비슷하게 나와서 “공부/디버깅”에 좋다.
    • seed가 없으면 실행할 때마다 랜덤이 달라져서 결과가 계속 바뀌어 헷갈릴 수 있어.

✅ 핵심 포인트

  • 외부 라이브러리 없음(numpy/torch 없이 순수 파이썬).
  • random.seed(42) 덕분에 재현 가능한 실험이 된다.

Step 1) Dataset 만들기: input.txt 다운로드 + docs 로드 + 셔플

# Let there be a Dataset `docs`: list[str] of documents (e.g. a list of names)
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)}")

자세한 설명

  • 이 코드의 목표는 docs라는 리스트를 만드는 거야.
    • docs: list[str] 형태 → 문서(문장/이름)들의 목록
    • 여기서는 “이름 한 줄”이 “문서 하나”가 돼.

1) 파일이 없으면 다운로드

  • if not os.path.exists('input.txt'):
    input.txt가 없으면,
  • urllib.request.urlretrieve(names_url, 'input.txt')
    인터넷에서 이름 목록 파일을 내려받아 input.txt로 저장해.

2) 파일 읽어서 docs 만들기

  • open('input.txt')로 파일을 줄 단위로 읽고,
  • line.strip()는 줄 끝의 \n 같은 공백을 제거해.
  • if line.strip()는 “빈 줄은 제외”하겠다는 뜻.

3) 섞기(셔플)

  • random.shuffle(docs)는 학습 순서를 섞어.
    • 공부할 때 문제집을 항상 같은 순서로만 풀면 편향될 수 있잖아? 그걸 막는 느낌.

4) 개수 출력

  • num docs: ...는 문서(이름) 개수를 보여줘.

✅ 핵심 포인트

  • docs는 학습 데이터 전체(이름 리스트).
  • shuffle은 학습을 골고루 하게 해준다.

Step 2) Tokenizer 만들기: 문자 집합 + BOS + vocab_size

# Let there be a Tokenizer to translate strings to sequences of integers ("tokens") and back
uchars = sorted(set(''.join(docs))) # unique characters in the dataset become token ids 0..n-1
BOS = len(uchars) # token id for a special Beginning of Sequence (BOS) token
vocab_size = len(uchars) + 1 # total number of unique tokens, +1 is for BOS
print(f"vocab size: {vocab_size}")

자세한 설명

컴퓨터는 글자보다 숫자를 다루기 쉬워서, 글자들을 번호로 바꾸는 단계야.

1) 데이터에 등장하는 모든 글자 모으기

  • ''.join(docs) : docs(이름 리스트)를 전부 이어붙여서 긴 문자열로 만듦
  • set(...) : 중복 제거 → “유니크 문자들만”
  • sorted(...) : 정렬 → 항상 같은 순서로 토큰 id가 정해지게 함

결과: uchars는 예를 들어 ['a','b','c',...,'z'] 같은 리스트가 된다.

2) BOS 토큰

  • BOS = len(uchars)
    uchars의 마지막 인덱스 다음 번호를 특수 토큰으로 쓰는 거야.
  • 여기서는 BOS가 “Beginning of Sequence”라고 써 있지만 실제로는
    • 시작 표시로도 쓰고
    • 끝 표시로도 쓰는(종료 신호) 역할을 같이 한다.

3) vocab_size

  • vocab_size = len(uchars) + 1
    글자 종류 수 + BOS 1개

✅ 핵심 포인트

  • uchars: 데이터에 나온 글자 사전(vocabulary).
  • BOS: 시작/끝을 알려주는 특수 토큰(종료 신호).
  • vocab_size: 모델이 선택할 수 있는 다음 글자 후보 개수.

Step 3) Autograd(자동미분) 핵심: Value 클래스

# Let there be Autograd to recursively apply the chain rule through a computation graph
class Value:
    __slots__ = ('data', 'grad', '_children', '_local_grads') # Python optimization for memory usage

    def __init__(self, data, children=(), local_grads=()):
        self.data = data                # scalar value of this node calculated during forward pass
        self.grad = 0                   # derivative of the loss w.r.t. this node, calculated in backward pass
        self._children = children       # children of this node in the computation graph
        self._local_grads = local_grads # local derivative of this node w.r.t. its children

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data + other.data, (self, other), (1, 1))

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data * other.data, (self, other), (other.data, self.data))

    def __pow__(self, other): return Value(self.data**other, (self,), (other * self.data**(other-1),))
    def log(self): return Value(math.log(self.data), (self,), (1/self.data,))
    def exp(self): return Value(math.exp(self.data), (self,), (math.exp(self.data),))
    def relu(self): return Value(max(0, self.data), (self,), (float(self.data > 0),))
    def __neg__(self): return self * -1
    def __radd__(self, other): return self + other
    def __sub__(self, other): return self + (-other)
    def __rsub__(self, other): return other + (-self)
    def __rmul__(self, other): return self * other
    def __truediv__(self, other): return self * other**-1
    def __rtruediv__(self, other): return other * self**-1

    def backward(self):
        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)
        self.grad = 1
        for v in reversed(topo):
            for child, local_grad in zip(v._children, v._local_grads):
                child.grad += local_grad * v.grad

자세한 설명

이 코드는 “딥러닝 프레임워크(예: PyTorch)가 해주는 핵심”을 직접 구현한 부분이야.
한 문장으로: 실수(손실)를 줄이기 위해, 어떤 숫자(가중치)를 얼마나 바꿔야 하는지 자동으로 계산하는 장치.

1) Value가 들고 있는 것들

  • data: 실제 값(숫자 1개)
  • grad: 손실(loss)을 기준으로 한 기울기(미분값)
    • “이 숫자를 조금 올리면 손실이 얼마나 변해?” 같은 정보
  • _children: 이 값이 어떤 값들(Value)로부터 계산됐는지(연결)
  • _local_grads: “내가 자식에게 얼마나 영향을 받는지”의 로컬 미분값
    • 예: z = x + y면 dz/dx = 1, dz/dy = 1
    • 예: z = x * y면 dz/dx = y, dz/dy = x

2) 연산자 오버로딩(+, *)

  • __add__, __mul__는 Value + Value 같은 계산을 하면
    • 결과도 Value로 만들고
    • 결과가 어디서 왔는지(children)
    • 로컬 미분값(local_grads)
      을 저장해둔다.
  • 그래서 계산을 많이 하면 **계산 그래프(연결 구조)**가 자동으로 쌓인다.

3) exp, log, relu 등

  • log(), exp()도 마찬가지로
    • 값 계산 + 로컬 미분값 저장
  • relu()는 max(0,x)라서
    • x가 0보다 크면 기울기 1
    • 아니면 기울기 0

4) backward()가 하는 일 (진짜 핵심)

  1. 그래프를 “뒤에서부터 계산 가능한 순서”로 정렬(topological order)한다.
  2. 손실(loss)의 grad는 1로 둔다. (∂loss/∂loss = 1)
  3. 뒤에서 앞으로(역순) 이동하면서
    • child.grad += local_grad * v.grad
    • 이게 체인룰(미분의 곱셈)이다.

초등학생 비유로는:

  • 문제를 틀렸을 때 “어느 단계가 얼마나 틀렸는지”를 거꾸로 추적해서
  • “여기 조금 고치면 점수가 좋아져!”를 알려주는 것.

✅ 핵심 포인트

  • Value는 **값(data)**과 **기울기(grad)**를 같이 들고 다닌다.
  • 연산할 때마다 “어떻게 계산됐는지”가 그래프로 쌓인다.
  • backward()는 그래프를 거꾸로 돌면서 체인룰로 grad를 전파한다.

Step 4) 모델 파라미터(가중치) 초기화: state_dict + params

# Initialize the parameters, to store the knowledge of the model
n_layer = 1     # depth of the transformer neural network (number of layers)
n_embd = 16     # width of the network (embedding dimension)
block_size = 16 # maximum context length of the attention window (note: the longest name is 15 characters)
n_head = 4      # number of attention heads
head_dim = n_embd // n_head # derived dimension of each head
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), 'wpe': matrix(block_size, n_embd), 'lm_head': matrix(vocab_size, n_embd)}
for i in range(n_layer):
    state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd)
    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)
    state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd)
params = [p for mat in state_dict.values() for row in mat for p in row] # flatten params into a single list[Value]
print(f"num params: {len(params)}")

자세한 설명

여긴 “모델의 뇌(기억)”를 만드는 부분이야.
딥러닝 모델은 **숫자 행렬(표)**을 엄청 많이 들고 있고, 학습하면서 그 숫자들이 바뀌어 지식이 된다.

1) 하이퍼파라미터(모델 크기 설정)

  • n_layer = 1: 트랜스포머 블록 1개만 쓴다(아주 작은 GPT).
  • n_embd = 16: 글자 하나를 16개 숫자로 표현한다(임베딩 차원).
  • block_size = 16: 최대 16글자까지(문맥 길이).
  • n_head = 4: 어텐션 헤드 4개.
  • head_dim = 16 // 4 = 4: 한 헤드는 4차원씩 담당.

2) matrix 함수(가중치 만들기)

  • matrix(nout, nin)은 nout x nin 크기의 행렬을 만든다.
  • 각 원소는 Value(random.gauss(0,std)):
    • 평균 0인 정규분포에서 랜덤으로 뽑아서 시작
    • “처음엔 아무것도 모르는 뇌”를 랜덤으로 세팅하는 느낌

3) state_dict 안에 뭐가 들어가나(중요)

  • wte: (vocab_size x n_embd)
    토큰(글자 id) → 임베딩 벡터(길이 16)
  • wpe: (block_size x n_embd)
    위치(pos id) → 임베딩 벡터(길이 16)
  • lm_head: (vocab_size x n_embd)
    마지막 hidden(길이 16) → 다음 토큰 점수표(길이 vocab_size)

레이어마다:

  • attn_wq, attn_wk, attn_wv, attn_wo: 전부 (n_embd x n_embd) = (16 x 16)
    • q/k/v 만들고, 어텐션 출력도 다시 16차원으로 투영
  • mlp_fc1: (4*n_embd x n_embd) = (64 x 16)
  • mlp_fc2: (n_embd x 4*n_embd) = (16 x 64)

4) params로 납작하게 만들기

  • optimizer(Adam)가 업데이트하려면 “모든 파라미터를 한 줄로” 돌기 편해.
  • 그래서 params는 state_dict 안의 모든 Value를 전부 꺼내서 1차원 리스트로 만든 것.

✅ 핵심 포인트

  • state_dict는 모델이 학습하는 모든 가중치(지식 저장소).
  • 크기(차원)가 아주 작게 설정되어 있어서 원리를 보기 좋게 만든 micro GPT.
  • params는 optimizer가 업데이트할 전체 파라미터 리스트.

Step 5) 모델 아키텍처: linear / softmax / rmsnorm / gpt

# Define the model architecture: a function mapping tokens and parameters to logits over what comes next
# Follow GPT-2, blessed among the GPTs, with minor differences: layernorm -> rmsnorm, no biases, GeLU -> ReLU
def linear(x, w):
    return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]

def softmax(logits):
    max_val = max(val.data for val in logits)
    exps = [(val - max_val).exp() for val in logits]
    total = sum(exps)
    return [e / total for e in exps]

def rmsnorm(x):
    ms = sum(xi * xi for xi in x) / len(x)
    scale = (ms + 1e-5) ** -0.5
    return [xi * scale for xi in x]

def gpt(token_id, pos_id, keys, values):
    tok_emb = state_dict['wte'][token_id] # token embedding
    pos_emb = state_dict['wpe'][pos_id] # position embedding
    x = [t + p for t, p in zip(tok_emb, pos_emb)] # joint token and position embedding
    x = rmsnorm(x) # note: not redundant due to backward pass via the residual connection

    for li in range(n_layer):
        # 1) Multi-head Attention block
        x_residual = x
        x = rmsnorm(x)
        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'])
        keys[li].append(k)
        values[li].append(v)
        x_attn = []
        for h in range(n_head):
            hs = h * head_dim
            q_h = q[hs:hs+head_dim]
            k_h = [ki[hs:hs+head_dim] for ki in keys[li]]
            v_h = [vi[hs:hs+head_dim] for vi in values[li]]
            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)
            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)]
        # 2) MLP block
        x_residual = x
        x = rmsnorm(x)
        x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
        x = [xi.relu() for xi in x]
        x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
        x = [a + b for a, b in zip(x, x_residual)]

    logits = linear(x, state_dict['lm_head'])
    return logits

자세한 설명

A) linear(x, w)

  • 입력 x는 벡터(길이 nin)
  • w는 행렬(크기 nout x nin)
  • 출력은 벡터(길이 nout)

코드에서:

  • for wo in w는 행렬의 “각 행(row)”를 돈다.
  • sum(wi * xi ...)는 내적(dot product): 한 행과 x를 곱해 점수 1개를 만든다.
  • 결과적으로 “선형변환(Linear layer)”이 된다.

B) softmax(logits)

소프트맥스는 “점수표(logits)”를 “확률표(합이 1)”로 바꿔.

  • max_val = max(val.data ...): 가장 큰 값을 구함
  • (val - max_val)을 하는 이유:
    • exp는 큰 숫자에서 폭발하기 쉬움(오버플로우)
    • 그래서 최대값을 빼서 안정적으로 만든다.
  • exps = ... exp(): 각 점수를 exp로 바꿔서 양수로 만들고
  • total = sum(exps): 전체 합
  • e/total: 확률로 정규화

중요: 여기서 exp/log/나눗셈이 다 Value 연산이라서 계산 그래프가 연결된다.
즉 softmax도 학습에 포함돼.


C) rmsnorm(x)

RMSNorm은 “벡터 크기(스케일)를 적당히 맞춰서 학습을 안정적으로 만드는 정규화”야.

  • ms = sum(xi * xi) / len(x) : 평균 제곱(mean square)
  • scale = (ms + 1e-5) ** -0.5 :
    • 1/sqrt(ms + eps)랑 같은 의미
    • eps(1e-5)는 0으로 나누는 걸 방지
  • [xi * scale for xi in x]: 벡터를 스케일 조정해서 크기를 일정하게 만든다.

D) gpt(...) : “한 위치(pos)에서 다음 토큰 점수표(logits) 만들기”

1) 입력은 무엇?

  • token_id: 현재 글자(토큰) id
  • pos_id: 지금이 몇 번째 위치인지(0,1,2,...)
  • keys, values: 어텐션용 “기억장부”
    • 지금까지 계산한 k/v를 쌓아두는 캐시

2) 임베딩 합치기

  • tok_emb = wte[token_id] → 길이 16
  • pos_emb = wpe[pos_id] → 길이 16
  • x = tok_emb + pos_emb → 길이 16

3) (레이어마다) 어텐션 블록

  • x_residual = x: 잔차 연결(residual connection)을 위해 저장
  • x = rmsnorm(x): 정규화
  • q,k,v = linear(x, Wq/Wk/Wv):
    • 각각 길이 16 벡터가 나옴
  • keys[li].append(k), values[li].append(v):
    • 지금 위치의 k/v를 “기억장부에 추가”
    • 그래서 다음 위치 계산할 때 과거를 볼 수 있다.

멀티헤드 어텐션 내부

  • 헤드 4개로 나누기:
    • 각 헤드는 4차원(head_dim=4)
  • q_h, k_h, v_h는 해당 헤드 부분만 잘라온 것
  • attn_logits:
    • 과거의 각 시점 t에 대해
    • dot(q_h, k_h[t]) / sqrt(head_dim)
    • “현재 질문(q)이 과거의 어떤 키(k)와 비슷한가?”
  • attn_weights = softmax(attn_logits):
    • 중요도(가중치) 확률
  • head_out:
    • 그 가중치로 과거의 v를 섞어서 새로운 벡터를 만든다.
  • 모든 헤드 결과를 이어붙여 x_attn(길이 16) 완성
  • x = linear(x_attn, Wo)로 다시 16차원으로 정리
  • x = x + x_residual로 잔차 연결

👉 “과거에서 필요한 정보만 골라 섞어오는 손전등”이라고 생각하면 돼.

4) MLP 블록

  • 다시 residual 저장 → norm → fc1 → relu → fc2 → residual 더하기
  • fc1은 16 → 64로 늘리고
  • fc2는 64 → 16으로 줄여
  • 목적: 어텐션 결과를 한 번 더 “깊게 계산”하는 작은 뇌

5) lm_head로 logits 만들기

  • logits = linear(x, lm_head) → 길이 vocab_size
  • 이 logits는 “다음 글자가 될 후보들”에 대한 점수표야.

✅ 핵심 포인트

  • linear: 행렬×벡터 = 신경망의 기본 연산.
  • softmax: 점수 → 확률(학습 가능한 계산 그래프 포함).
  • rmsnorm: 학습 안정화(크기 맞추기).
  • gpt: (임베딩) → (어텐션+MLP) → (다음 토큰 logits).
  • keys/values 캐시 덕분에 미래를 안 보고 과거만 보게 된다(순서대로 append하니까).

Step 6) Adam 옵티마이저 준비: 하이퍼파라미터 + m, v 버퍼

# Let there be Adam, the blessed optimizer and its buffers
learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
m = [0.0] * len(params) # first moment buffer
v = [0.0] * len(params) # second moment buffer

자세한 설명

Adam은 “경사하강법(gradient descent)”의 업그레이드 버전이야.

  • learning_rate: 한 번에 얼마나 움직일지(수정량 크기)
  • beta1: m의 이동평균 비율(기울기 평균을 얼마나 오래 기억할지)
  • beta2: v의 이동평균 비율(기울기 제곱 평균을 얼마나 오래 기억할지)
  • eps_adam: 0으로 나눔 방지 + 안정성

버퍼:

  • m[i]: i번째 파라미터의 기울기 평균(방향 기억)
  • v[i]: i번째 파라미터의 기울기 제곱 평균(크기 기억)

더 자세한 비유로

  • m은 “최근에 어떤 방향으로 틀렸는지” 기록하는 노트
  • v는 “얼마나 크게 틀렸는지” 기록하는 노트

✅ 핵심 포인트

  • Adam은 **방향(m)**과 **크기(v)**를 기억해서 더 안정적으로 업데이트한다.
  • m, v는 파라미터 개수만큼 각각 하나씩 가진다.

Step 7) 학습 루프: 토큰화 → forward → loss → backward → Adam 업데이트

# Repeat in sequence
num_steps = 1000 # number of training steps
for step in range(num_steps):

    # Take single document, tokenize it, surround it with BOS special token on both sides
    doc = docs[step % len(docs)]
    tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
    n = min(block_size, len(tokens) - 1)

    # Forward the token sequence through the model, building up the computation graph all the way to the 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)
        loss_t = -probs[target_id].log()
        losses.append(loss_t)
    loss = (1 / n) * sum(losses) # final average loss over the document sequence. May yours be low.

    # Backward the loss, calculating the gradients with respect to all model parameters
    loss.backward()

    # Adam optimizer update: update the model parameters based on the corresponding gradients
    lr_t = learning_rate * (1 - step / num_steps) # linear learning rate decay
    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

    print(f"step {step+1:4d} / {num_steps:4d} | loss {loss.data:.4f}", end='\r')

자세한 설명

A) 매 스텝에서 학습할 문서(이름) 하나 고르기

  • doc = docs[step % len(docs)]
    • step이 커져도 docs 범위를 넘지 않도록 반복해서 뽑는다.

B) 토큰화 + BOS로 감싸기

  • tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
    • 예: doc="emma"라면 [BOS, e, m, m, a, BOS](각각은 숫자 id)
  • 여기서 uchars.index(ch)는 ch가 uchars에서 몇 번째인지 찾는 것.
    • 느리지만(선형 탐색) “가장 단순한 구현”을 보여주려는 코드라 이렇게 했다.

C) n (이번 문서에서 학습할 길이)

  • len(tokens) - 1인 이유:
    • 우리는 pos_id 위치의 입력으로 pos_id+1 위치의 정답을 맞히니까
    • 마지막 토큰은 “다음 정답”이 없다.
  • min(block_size, ...):
    • 문서가 길면 최대 16글자까지만 보게 자른다.

D) forward: 손실(loss) 만들기

  • keys, values를 레이어별로 빈 리스트로 시작
    • 순서대로 pos를 증가시키며 append하니까 “과거만 저장”된다.
  • 각 위치 pos_id에서:
    • token_id = 현재 입력
    • target_id = 다음 정답
    • logits = gpt(...)
    • probs = softmax(logits)
    • loss_t = -probs[target_id].log()

loss_t가 의미하는 것

  • 정답 확률이 1에 가까우면 -log(1) ≈ 0 (벌점 거의 없음)
  • 정답 확률이 0.01이면 -log(0.01)은 큼 (벌점 큼)
    정답을 높게 주도록 학습시키는 공식.
  • loss = (1/n) * sum(losses)로 평균 손실

E) backward: 기울기 계산

  • loss.backward() 호출 한 번으로
    • loss를 만드는 데 관여했던 모든 Value들의 grad가 채워진다.
    • 여기엔 모델의 모든 파라미터도 포함된다.

F) Adam 업데이트

  • lr_t = learning_rate * (1 - step/num_steps):
    • 학습 초반엔 크게 움직이고, 후반엔 조금씩 움직이게 만드는 “선형 감소”.
  • m, v 업데이트:
    • m은 grad 평균, v는 grad^2 평균
  • m_hat, v_hat:
    • 초반에는 m, v가 0에서 시작해서 편향(bias)이 생기니까 보정해줌.
  • p.data -= ...:
    • 파라미터 실제 값을 업데이트(학습)
  • p.grad = 0:
    • 다음 스텝을 위해 기울기 초기화(안 하면 누적돼서 망함)

G) 출력

  • end='\r'는 같은 줄에서 계속 덮어써서 진행 상황처럼 보이게 해준다.

✅ 핵심 포인트

  • 학습은 “다음 글자 맞히기” 게임이다.
  • loss_t = -log(p(정답)) → 정답 확률을 높이도록 강제.
  • loss.backward()가 모든 파라미터의 grad를 채운다.
  • Adam이 p.data를 업데이트하고 p.grad는 0으로 리셋한다.

Step 8) Inference(생성): temperature로 샘플링해서 새 이름 20개 생성

# Inference: may the model babble back to us
temperature = 0.5 # in (0, 1], control the "creativity" of generated text, low to high
print("\n--- inference (new, hallucinated names) ---")
for sample_idx in range(20):
    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    token_id = BOS
    sample = []
    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)}")

자세한 설명

학습이 끝났으니 이제 모델이 “새 이름”을 만들어내는 단계야.

A) temperature(창의력 조절)

  • logits를 temperature로 나누고 softmax를 한다.
  • temperature가 낮으면:
    • logits 차이가 더 커짐 → softmax가 “한쪽으로 쏠림”
    • 결과: 무난하고 비슷한 이름이 더 많이 나옴
  • temperature가 높으면(1에 가까울수록):
    • 확률이 더 고르게 됨
    • 결과: 더 다양한 이름이 나오지만 이상한 것도 늘 수 있음

B) 생성 과정(한 글자씩)

  • token_id = BOS에서 시작
  • 매 위치(pos_id)마다:
    1. logits = gpt(...)
    2. 확률로 바꾼 뒤
    3. random.choices로 확률에 따라 한 토큰 뽑기
  • 뽑힌 토큰이 BOS면 “끝!” (이름 생성 종료)
  • 아니면 uchars[token_id]로 문자로 바꿔서 sample에 추가

C) 캐시(keys/values)를 왜 다시 빈 걸로 시작하나?

  • 이름 하나를 만들 때는 그 이름 내부의 문맥만 쓰면 되니까
  • 새 샘플 시작마다 캐시를 초기화해야 “이전 샘플의 기억”이 섞이지 않아.

✅ 핵심 포인트

  • 생성은 BOS에서 시작해서 한 글자씩 확률로 뽑는 과정.
  • temperature는 다양성(창의력) 조절 다이얼.
  • 샘플마다 keys/values 캐시를 초기화한다.

 

반응형