콘월 이층집

tokenizer

프로젝트 microGPT - Dataset, Tokenizer, Autograd, Parameters

반응형

Dataset

대규모 언어 모델의 연료는 결국 텍스트 데이터의 흐름(stream)이다.
이 흐름은 하나의 거대한 문서일 수도 있고, 여러 개의 문서 집합으로 나뉠 수도 있다.

실제 상용 모델에서는 각 문서가 인터넷 웹페이지, 책, 코드 파일과 같은 거대한 텍스트 단위에 해당한다.

 

microGPT는 훨씬 단순한 데이터를 예시로 사용한다. (32,033개의 이름이 한 줄에 하나씩 적혀있다.)

아래 코드를 실행하면 GitHub에서 input.txt를 내려받고, 이를 문서 목록(docs)으로 구성한다.

# Let there be an input dataset `docs`: list[str] of documents (e.g. a dataset of names)
if not os.path.exists('input.txt'):
    import urllib.request
    names_url = 'https://raw.githubusercontent.com/karpathy/makemore/refs/heads/master/names.txt'
    urllib.request.urlretrieve(names_url, 'input.txt')
docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()] # list[str] of documents
random.shuffle(docs)
print(f"num docs: {len(docs)}")

 

여기서 docs는 단순한 list[str]이다. 각 문자열이 하나의 문서이다.

 

모델의 학습하고자 하는 목표는 입력 데이터에서 통계적 패턴을 학습하고,

그 패턴을 공유하는 새로운 데이터를 생성하는 것이다.

이름 데이터셋의 경우라면, 이를 학습한 모델은 그럴듯한 새로운 이름을 생성하는 것이 목표일 것이다.

 

2026년 현재 기준으로 보면 이러한 데이터를 학습하는 작업과 목표는 다소 단순하다고 생각할 수 있다.

이해를 돕기 위한 예로, 우리는 ChatGPT와 “대화를 한다”고 생각한다.
질문을 던지고, 이해한 답변을 듣고, 상호작용한다고 느낀다.

그러나 모델의 관점에서 보면, 이 모든 것은 하나의 긴 문서일 뿐이다.

사용자가 텍스트를 시작하면, 모델은 그 뒤를 통계적으로 이어 쓰고 있을 뿐이다.

즉, 대화가 아니라 문서 완성(document completion)이다.

 

ChatGPT는 생각하지 않는다.
이해하지도 않는다.
그저 다음에 올 토큰을 예측할 뿐이다.

 

Tokenizer

모델은 내부적으로 문자가 아니라 숫자를 다루기 때문에,

정수 토큰 ID 시퀀스로 변환하고 다시 텍스트로 복원하는 방법이 필요하다.

이를 토크나이저(Tokenizer)라고 한다.

 

GPT-4에서 사용하는 tiktoken과 같은 상용 토크나이저는 효율성을 위해 문자 덩어리(서브워드) 단위로 동작한다.
microGPT는 데이터셋의 각 고유 문자에 하나의 정수를 할당하는 가장 단순한 방식을 택한다.

아래 코드처럼 전체에서 등장하는 고유 문자(소문자 a–z)를 수집한 뒤 정렬하고, 각 문자에 인덱스를 ID로 부여한다.

# Let there be a Tokenizer to translate strings to discrete symbols and back
uchars = sorted(set(''.join(docs))) # unique characters in the dataset become token ids 0..n-1
BOS = len(uchars) # token id for the 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}")

 

여기서 중요한 점은 정수 값 자체에는 아무 의미가 없다는 것이다.
각 토큰은 단지 서로 다른 기호일 뿐이며 0, 1, 2 대신 🍎, 🚀, 🧠를 써도 본질은 변하지 않는다.

그러므로 BOS (Beginning of Sequence) 토큰이라고 하는 특별한 토큰을 추가한다.
이 토큰은 문서의 시작과 끝을 구분하는 역할을 하며, 학습 시 각 문서는 아래와 같이 감싸진다. (emma)

[BOS, e, m, m, a, BOS]

 

모델은 첫 번째 BOS가 “새 이름의 시작”임을, 두 번째 BOS가 “이름의 종료”임을 학습하게 된다.

결과적으로 최종 어휘 크기는 27개가 된다.

  • 소문자 a–z: 26개
  • BOS 토큰: 1개

총 27개의 토큰으로 구성된 작은 어휘(vocavulary)다.

Autograd

모델을 학습시키기 위해서는 반드시 기울기(gradient)가 필요하다.

학습 관점에서 알고 싶은 것은 손실(loss) 변화율이기 때문이다.

어느 파라미터를 아주 조금 변경했을 때,
최종 손실은 얼마나 변하는가?

 

이 값을 계산하는 것이 바로 미분이다. 

PyTorch 같은 프레임워크에서는 backward() 함수 하나로 미분 과정을 처리해준다.
microGPT의 Value 클래스는 이 과정을 직접 구현한 것이다.

class Value:
    __slots__ = ('data', 'grad', '_children', '_local_grads')

    def __init__(self, data, children=(), local_grads=()):
        self.data = data                   # 이 노드의 실제 값
        self.grad = 0                      # loss에 대한 미분값
        self._children = children          # 계산 그래프 상의 입력 노드들
        self._local_grads = local_grads    # 로컬 미분값

 

Value 객체는 하나의 스칼라 숫자를 감싸고, 이 숫자가 어떻게 계산되었는지를 기억한다.

덧셈, 곱셈, 로그, exp, ReLU와 같은 모든 연산 역시 새로운 Value 객체를 만들어낸다.

이렇게 Value 객체들이 서로를 참조하도록 연결되면 자연스럽게 하나의 계산 그래프(computation graph)가 형성된다.

 

이제 이 그래프를 거슬러 올라가야 한다. 최종 출력이 각 입력에 얼마나 의존하는지를 계산하기 위해서다.

이 의존 관계를 따라 역방향으로 전파하는 법칙이 바로 체인 룰(chain rule)이다.

그래프의 끝(손실)에서 시작해 각 노드의 기울기를 차례로 누적하는 것, 이것이 backward()가 하는 일이다.

 

간단한 예를 들어 해당 과정을 설명해보고자 한다.

L = a * b + a
# a = 2, b = 3이라면 :
#     dL/da = b + 1 = 3 + 1 = 4
#     dL/db = a = 2

 

 

만약 a를 0.001만큼 증가시키면, L은 약 0.004 증가한다.

기울기는 각 입력이 최종 출력에 얼마나 민감한지를 나타내는 값이다.

그리고 바로 이 값이 모델을 학습시키는 방향을 결정한다.

(단, 실제 학습에서는 손실을 줄이기 위해 기울기의 반대 방향으로 파라미터를 업데이트한다.)

Parameters

파라미터는 모델의 “지식”이다.

이들은 수많은 부동소수점 숫자들의 집합이며, 모두 Value 객체로 감싸져 있다.

처음에는 무작위(random) 값으로 시작하지만, 학습이 진행되면서 점점 최적화된다.

각 파라미터가 정확히 어떤 역할을 하는지는 이후 포스팅에서 모델의 아키텍처를 설명할 예정이다.
지금 단계에서는 우선 이 파라미터들을 초기화해야 한다는 것만 알아두자.

n_embd = 16                   # 임베딩 차원
n_head = 4                    # 어텐션 헤드 수
n_layer = 1                   # 레이어 수
block_size = 16               # 최대 시퀀스 길이
head_dim = n_embd // n_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]
print(f"num params: {len(params)}")
 

각 파라미터는 평균 0, 표준편차 std를 가지는 가우시안 분포에서 샘플링된다. 즉, 작은 랜덤 값으로 초기화된다. (matrix)
처음부터 큰 값으로 시작하면 학습이 불안정해질 수 있기 때문에 학습에서 일반적으로 쓰는 방식이다.

optimizer가 모든 파라미터를 순회할 수 있도록 모든 가중치를 하나의 리스트로 평탄화(flatten)해야한다.

microgpt train & inference (m1)

반응형

+ 최근 글