Skip to main content
Views 31

GPT와 트랜스포머를 직접 구현하며 이해하기 (안드레이 카파시 Let's build GPT)

Summary

이 노트는 아래 출처를 AI로 재구성한 것입니다. 원문 방문을 권장합니다.

원문 출처: https://www.youtube.com/watch?v=kCc8FmEb1nY&t=1405s

핵심 요약

ChatGPT의 핵심인 트랜스포머 언어 모델을, 아주 작은 예제(캐릭터 단위 Shakespeare)로부터 파이썬·PyTorch 코드로 직접 구현해 나가는 과정이다.

텍스트를 정수 시퀀스로 바꾸는 토크나이저, 배치·블록 단위 데이터 구성, 단순 바이그램 모델에서 시작해, 트랜스포머의 핵심인 자기 주의(attention)를 위한 행렬 연산 트릭까지 차근차근 쌓아 올린다.

언어 모델과 GPT 개념 잡기

언어 모델은 "이전까지 나온 단어(또는 토큰)를 보고 다음에 뭐가 나올지 확률적으로 맞히는 모델"이다.

ChatGPT 같은 시스템도 결국은, 사용자가 친 프롬프트를 앞부분으로 두고 그 뒤를 계속 이어 쓰는 "자동 완성기"라고 볼 수 있다. 프롬프트가 길고, 모델이 똑똑해서 마치 대화를 하는 것처럼 느껴질 뿐이다.

이 모델은 완전히 결정적인 것이 아니라 확률적이기 때문에, 같은 프롬프트를 주더라도 실행할 때마다 조금씩 다른 답을 내놓는다. 이는 모델이 각 다음 토큰을 확률 분포에서 샘플링하기 때문이다.

GPT는 "Generative Pre-trained Transformer"의 줄임말로, 핵심은 트랜스포머(Transformer)라는 신경망 구조이다. 트랜스포머는 원래 기계 번역 논문에서 제안되었지만, 이후 거의 모든 자연어 처리·생성 모델의 표준 구조로 자리 잡았다.

왜 직접 구현해서 배우는가

실제 ChatGPT는 인터넷 대규모 텍스트에 대한 사전 학습, 여러 단계의 파인튜닝, 인간 피드백, 안전 장치 등 매우 복잡한 시스템이다.

하지만 그 핵심 아이디어는 "트랜스포머 기반 언어 모델을 학습해 다음 토큰을 예측한다"로 상당히 단순화할 수 있다.

따라서 교육용으로는, 작은 데이터셋(예: tiny Shakespeare), 간단한 캐릭터 단위 토크나이저, 축소된 트랜스포머 구조만으로도 충분히 원리를 이해할 수 있다.

나중에는 이 코드를 그대로 복사해 다른 텍스트(내 문서, 코드, 소설 등)에 대해 학습시키는 식으로 응용할 수 있다.

tiny Shakespeare: 작고 훌륭한 언어 모델 데이터셋

실습에서는 모든 Shakespeare 작품을 이어 붙인 약 1MB 크기의 텍스트 파일을 사용한다.

이 데이터는 하나의 긴 문자열로 읽어 들이고, 길이가 약 백만 글자 정도인 캐릭터 시퀀스가 된다. 여기서 등장하는 모든 문자 종류를 모으면, 공백, 줄바꿈, 문장부호, 대·소문자 알파벳 등을 포함해 65개의 고유 문자("어휘")가 나온다.

우리가 할 일은 "이 문자 시퀀스를 보고 다음 문자를 예측하는 모델을 학습시키는 것"이다. 잘 학습된 모델은 Shakespeare를 흉내 내는 듯한 영어 문장을 캐릭터 단위로 생성할 수 있게 된다. 물론 진짜 Shakespeare는 아니고, 통계적 패턴을 따른 모조품이다.

토크나이저: 텍스트를 숫자로 바꾸기

신경망은 문자열 그 자체를 다룰 수 없고, 숫자(정수 또는 실수)를 입력으로 받는다. 그래서 먼저 텍스트를 정수 시퀀스로 바꾸는 과정이 필요하다. 이것을 "토크나이징(tokenization)"이라고 부른다.

여기서는 가장 단순한 방식인 "문자 단위 토크나이저"를 사용한다.

  1. 데이터 전체에서 고유 문자의 집합을 뽑아 정렬하고, 각 문자에 0, 1, 2, … 같은 정수 ID를 부여한다.

  2. char -> int 매핑(인코더)과 int -> char 매핑(디코더)을 만든다.

  3. 임의의 문자열을 받았을 때, 각 문자를 대응하는 정수로 바꾸면 숫자 리스트가 되고, 반대로 숫자 리스트를 다시 문자로 바꿔 합치면 원래 문자열로 복원된다.

실무의 GPT 모델들은 보통 문자 단위가 아니라 "서브워드 토크나이저"(SentencePiece, BPE 등)를 써서 약 5만 개 정도의 토큰을 사용한다. 이 경우 전체 문자열을 0~4만대 수준의 토큰 ID 시퀀스로 표현한다.

서브워드는 문장이 짧은 토큰 수로 표현되어 효율적이지만, 구현과 개념이 복잡해지므로, 여기서는 교육 목적으로 단순한 문자 단위를 선택한다.

데이터 분할과 "블록" 개념

텍스트 전체를 하나의 초거대 시퀀스로 보고 한 번에 모델에 넣는 것은 연산량과 메모리에서 비현실적이다.

대신 일정 길이의 조각으로 잘라 쓰는데, 이 조각의 최대 길이를 "블록 크기(block size)" 또는 "컨텍스트 길이(context length)"라고 부른다. 예시에서는 블록 크기를 8로 두고, 길이 9의 시퀀스를 예로 든다.

길이 9 시퀀스를 보면 내부에 다음과 같은 여러 개의 학습 예제가 숨어 있다.

  • 첫 글자를 보고 두 번째 글자를 예측

  • 처음 두 글자를 보고 세 번째 글자를 예측

  • 처음 여덟 글자를 보고 아홉 번째 글자를 예측

즉, 길이 block_size + 1인 조각 하나는, 내부에 block_size개의 (입력 시퀀스, 타깃 문자) 예제를 패킹하고 있는 셈이다.

실제 학습에서는, 텍스트 전체 중에서 임의 위치를 골라 이런 블록을 계속 잘라내며, 각 위치마다 "지금까지의 문맥 → 다음 문자"를 동시에 학습시키게 된다.

또한 텍스트의 앞 90%는 학습(train)용, 뒤 10%는 검증(validation)용으로 나누어, 모델이 단순 암기 대신 일반적인 패턴을 학습했는지 확인한다.

배치(batch): 여러 시퀀스를 한 번에 처리하기

GPU는 병렬 연산에 특화되어 있기 때문에, 매 스텝마다 시퀀스 하나만 처리하면 GPU를 제대로 활용하지 못한다.

그래서 한 번에 여러 개의 블록을 모아서 "배치(batch)"로 묶는다. 배치 크기를 4라고 하면, 각 스텝에 4개의 서로 다른 텍스트 조각이 동시에 모델을 통과한다.

이때 입력 텐서는 보통 (B, T) 또는 (B, T, C) 형태를 갖는다.

  • B: 배치 크기 (동시에 처리하는 시퀀스 수)

  • T: 시퀀스 길이(블록 크기)

  • C: 채널 또는 임베딩 차원 수

각 배치의 각 시점마다 "현재까지의 모든 과거 토큰 → 다음 토큰"을 예측하는 많은 예제가 한 번에 들어가므로, 전체 학습이 훨씬 효율적이고 안정적이 된다.

가장 단순한 모델: 바이그램 언어 모델

트랜스포머로 곧바로 가기 전에, 가장 단순한 언어 모델인 "바이그램(bigram) 모델"부터 구축한다.

바이그램 모델의 아이디어는 "다음 문자를 예측할 때, 오직 바로 이전 문자만 보고 판단한다"이다. 문맥의 나머지는 완전히 무시한다.

PyTorch로 구현할 때는 다음과 같이 한다.

  1. nn.Embedding(vocab_size, vocab_size)를 만든다.

    • 행 인덱스는 현재 문자 ID,

    • 그 행의 벡터 값은 "다음 문자에 대한 점수(logits)" 역할을 한다.

  2. 입력 (B, T)를 임베딩 테이블에 통과시키면 (B, T, vocab_size) 형태의 로짓 텐서를 얻는다.

  3. 각 위치의 로짓과 실제 다음 문자 타깃을 F.cross_entropy로 비교해 손실(loss)을 계산한다.

  4. 이 손실을 역전파(backpropagation)해 임베딩 테이블을 학습한다.

이 모델은 문맥이 1글자뿐이라 매우 단순하지만, 빈도 높은 문장 패턴을 어느 정도 잡아내므로 완전 랜덤보다는 훨씬 나은 텍스트를 생성할 수 있다.

손실 함수와 학습 루프 이해하기

언어 모델의 품질을 측정할 때는, "정답 문자를 얼마나 잘 맞추는가"를 보고 싶다.

이를 수학적으로 표현한 것이 "교차 엔트로피(cross-entropy)", 또는 "음의 로그 우도(Negative Log-Likelihood)"다.

  • 로짓 → softmax → 각 문자에 대한 확률 분포

  • 실제 정답 문자가 가리키는 확률의 로그를 취해 음수로 바꾼 값

  • 이를 전체 예제에 대해 평균내면 교차 엔트로피 손실

만약 완전히 랜덤하게 65개 문자 중 하나를 균등 분포로 고른다면, 이론적으로 손실은 -log(1/65)4.17 정도가 된다. 초기 모델 손실이 이보다 큰지, 그리고 학습을 거치며 점점 줄어드는지를 보면 학습이 잘 되는지 감을 잡을 수 있다.

학습 루프는 다음 흐름을 반복한다.

  1. 배치 데이터를 샘플링한다.

  2. 모델을 통과시켜 로짓과 손실을 얻는다.

  3. 이전 스텝의 그래디언트를 0으로 초기화한다.

  4. loss.backward()로 그래디언트를 계산한다.

  5. 옵티마이저(Adam 등)로 파라미터를 한 스텝 업데이트한다.

주기적으로 train/validation 손실을 몇 배치씩 평균 내어 보고, 과적합 여부와 수렴 상황을 점검한다.

생성(generation) 로직: 한 글자씩 이어 쓰기

학습된 언어 모델로 텍스트를 생성할 때는 "현재까지의 시퀀스를 넣고, 다음 문자에 대한 분포를 얻어 샘플링 → 그 문자를 시퀀스 뒤에 붙임"을 반복한다.

구체적으로는 다음과 같다.

  1. 시작 컨텍스트(예: 줄바꿈 문자 하나)를 나타내는 (B, T) 텐서를 만든다.

  2. 모델에 넣어 (B, T, vocab_size) 로짓을 받는다.

  3. 마지막 시점 T-1 위치의 로짓만 꺼내 softmax로 확률 분포를 만든다.

  4. torch.multinomial 등으로 이 분포에서 토큰 하나를 샘플링한다.

  5. 샘플링한 정수를 기존 시퀀스 뒤에 붙여 새로운 (B, T+1) 시퀀스로 만든다.

  6. 위 과정을 원하는 길이만큼 반복한다.

처음에는 모델이 랜덤 상태라 이상한 문장만 나오지만, 학습이 진행될수록 서서히 "Shakespeare 흉내"에 가까운 문장이 나오는 것을 확인할 수 있다.

행렬 곱과 트릴(tril)을 이용한 "과거만 보는" 평균 연산

트랜스포머의 자기 주의(self-attention)에서는 "각 위치의 토큰이, 자기보다 앞선 토큰들만을 보고 정보를 모아야 한다"는 제약이 있다. 미래 토큰 정보는 볼 수 없다. 이를 구현할 때 핵심이 되는 행렬 연산 트릭을 작은 예제로 먼저 이해해 본다.

가상의 텐서 x(B, T, C) 모양으로 있고, 각 시점마다 C차원 특징 벡터가 있다고 하자. 이제 "각 시점 t에서, 0~t까지의 모든 벡터를 평균낸 결과"를 만들고 싶다면, 가장 단순한 방법은 for 루프로 각 t를 돌며 평균을 구하는 것이다. 하지만 이 방식은 느리다.

대신, 아래와 같은 아이디어를 사용한다.

  1. T x T 크기의 행렬을 만들고, 그 하삼각(lower triangular) 부분을 1, 나머지를 0으로 채운다.

    • 즉, (t, τ) 원소가 1이면 τ ≤ t, 0이면 τ > t.

  2. 각 행의 합이 1이 되도록 정규화한다.

    • 예: 첫 번째 행은 [1, 0, 0,], 두 번째 행은 [1/2, 1/2, 0,] 등.

  3. 이 행렬을 x(T, C) 부분에 행렬 곱으로 곱하면, 각 시점별 "과거까지의 평균"이 한 번에 계산된다.

  4. 배치 차원이 있을 경우 (B, T, T) @ (B, T, C) 꼴의 batched matmul로 수행된다.

이 구조는 이후 self-attention에서 "각 위치가 다른 위치들을 얼마나 참고하는지"를 나타내는 가중치 행렬로 일반화된다. 현재 예제에서는 단순 평균이지만, 실제 트랜스포머에서는 데이터에 따라 달라지는 가중치를 학습해, 어떤 과거 토큰은 더 많이, 어떤 토큰은 덜 참고하도록 만든다.

마스킹과 softmax: 자기 주의의 전단계

앞에서 만든 T x T 하삼각 행렬은 "현재 위치 t가 볼 수 있는 과거 위치들"을 정의하는 마스크 역할을 한다.

트랜스포머에서는 토큰 간 "친밀도" 또는 "유사도"를 나타내는 T x T 점수 행렬을 먼저 계산한 뒤, 여기에서 미래 위치(τ > t)에 해당하는 원소를 -inf로 바꾼 뒤, 행별로 softmax를 취해 확률 분포로 만든다.

  • softmax를 취하면 각 행(각 위치에서 다른 위치로의 가중치)의 합이 1이 된다.

  • -inf였던 위치는 지수(exp)를 취하면 0이 되고, 결과적으로 가중치 0이 된다.

  • 이렇게 얻은 가중치 행렬을 이용해 (가중치 행렬) @ (값 벡터들)을 하면, "각 위치가 과거 토큰들에서 정보를 가져와 가중합한 결과"를 얻는다.

이 방식이 바로 자기 주의(self-attention)의 핵심 아이디어이며, 여기서 배운 하삼각 마스크 + softmax + 행렬 곱 트릭이 그대로 사용된다.

모델 구조 리팩토링과 임베딩 확장

처음에는 nn.Embedding(vocab_size, vocab_size) 하나만으로 바이그램 모델을 구현했지만, 트랜스포머로 가기 위해 구조를 조금 일반화해야 한다.

우선 "토큰 임베딩 차원 수 n_embed"를 도입해, 토큰을 n_embed차원 벡터로 바꾸는 임베딩 테이블과, 이를 다시 어휘 크기만큼의 로짓으로 변환하는 출력 선형층(nn.Linear(n_embed, vocab_size))으로 분리한다.

이렇게 하면, 중간 표현(임베딩)을 더 풍부하게 만들고, 그 위에 self-attention, 피드포워드 네트워크 등을 쌓기 쉬워진다.

또한 토큰의 "정체성" 뿐 아니라 "위치 정보"도 모델에 알려줘야 한다. 트랜스포머는 RNN처럼 순서 정보를 자연스럽게 갖고 있지 않기 때문에, 별도의 위치 임베딩을 더해 준다.

  • position_embedding_table = nn.Embedding(block_size, n_embed)를 만들고,

  • 시퀀스 길이 T에 대해 0~T-1까지의 위치 ID를 넣어 (T, n_embed)를 얻는다.

  • 토큰 임베딩 (B, T, n_embed)와 위치 임베딩 (T, n_embed)를 더해 (B, T, n_embed)을 만든다.

이제 각 위치의 표현 벡터는 "어떤 문자인지 + 그 문자가 문맥 중 어디에 있는지"를 함께 담게 된다. 이 위에 self-attention 블록을 추가하면, 토큰들끼리 서로를 "보고" 문맥을 반영하는 진짜 트랜스포머 구조가 완성된다.

인사이트

이 흐름 전체를 관통하는 핵심은 "복잡해 보이는 거대한 GPT도, 사실은 '다음 토큰 맞추기'를 잘하기 위한 반복 훈련의 결과"라는 점이다.

직접 tiny Shakespeare에 대해 문자 단위 모델을 짜 보고, 단순 바이그램에서 시작해, 배치·블록·임베딩·마스킹·행렬 곱 트릭까지 한 단계씩 이해해 나가면, 논문에 나온 거대한 그림도 훨씬 직관적으로 보인다.

실용적으로는 다음을 추천한다.

  • 먼저 문자 단위 바이그램 모델을 완전히 이해하고 구현해 본다.

  • 그다음, 현재 설명된 tril+softmax 트릭을 이용해 간단한 self-attention 층을 붙여 본다.

  • 마지막으로, 이 구조를 여러 층 쌓고, 피드포워드 네트워크와 잔차 연결, 레이어 정규화까지 추가하면, 논문에서 보는 "작은 GPT" 수준의 모델에 가까워진다.

규모는 작더라도 "직접 만든 GPT"를 학습·샘플링해 보는 경험은, 대형 모델을 단순히 API로 호출하는 것과는 전혀 다른 수준의 이해를 제공해 줄 것이다.

출처 및 참고 :

GPT와 트랜스포머를 직접 구현하며 이해하기 (안드레이 카파시 Let's build GPT)

이 노트는 요약·비평·학습 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.