Skip to content

Microgpt

200줄 순수 파이썬으로 구현한 GPT 학습 및 추론

About

  • karpathy가 공개한 아트 프로젝트. 200줄 단일 파일로 외부 의존성없이 GPT 전체 알고리듬을 구현
  • 프로덕션 LLM과의 차이는 규모와 효율성일 뿐 핵심은 동일, 이 코드를 이해하면 GPT의 알고리듬적 본질을 이해한 것임
  • 데이터셋, 토크나이저, autograd 엔진, GPT-2 유사 Transformer 아키텍처, Adam 옵티마이저, 학습·추론 루프까지 포함
  • micrograd, makemore, nanogpt 등 기존 프로젝트들과 10년간의 LLM 단순화 작업의 결정체로, 더 이상 단순화할 수 없는 최소 형태로 GPT의 본질을 담음
  • 32,000개의 이름 데이터를 학습해 그럴듯한 새로운 이름을 생성하며, 모든 계산을 스칼라 단위 autograd로 직접 수행
  • 학습 과정은 손실 계산 → 역전파 → Adam 업데이트로 구성되며, 약 1분 내 실행 가능

microgpt 개요

  • microgpt는 200줄짜리 Python 스크립트로, GPT 모델의 학습과 추론 과정을 완전하게 구현
    • 외부 라이브러리 없이 데이터셋, 토크나이저, autograd, 모델, 옵티마이저, 학습 루프를 모두 포함
  • 기존의 micrograd, makemore, nanogpt 등의 프로젝트를 통합해 단일 파일로 정리
  • “더 이상 단순화할 수 없음” 수준으로 알고리듬적 핵심만 남긴 구현
  • 전체 코드는 GitHub Gist, 웹페이지, Google Colab에서 제공

데이터셋 구성

  • 대규모 언어 모델의 연료는 텍스트 데이터 스트림이며, 프로덕션에서는 인터넷 웹페이지를 사용하지만 microgpt에서는 32,000개의 이름을 한 줄씩 담은 단순한 예제 사용
  • 각 이름이 하나의 "문서"로 취급되며, 모델은 데이터 내의 통계적 패턴을 학습해 유사한 새 문서를 생성하는 것이 목표
  • 학습 완료 후 모델은 "kamon", "karai", "vialan" 같은 그럴듯한 새 이름을 "환각(hallucinate)"
  • ChatGPT 관점에서 사용자와의 대화도 "특이하게 생긴 문서"일 뿐이며, 프롬프트로 문서를 초기화하면 모델의 응답은 통계적 문서 완성에 해당

토크나이저

  • 신경망은 문자가 아닌 숫자로 작동하므로 텍스트를 정수 토큰 ID 시퀀스로 변환하고 다시 복원하는 방법 필요
  • tiktoken(GPT-4 사용) 같은 프로덕션 토크나이저는 효율을 위해 문자 청크 단위로 작동하지만, 가장 단순한 토크나이저는 데이터셋의 각 고유 문자에 하나의 정수를 할당
  • 소문자 a-z를 정렬해 각 문자에 인덱스로 ID 부여하며, 정수 값 자체는 의미가 없고 각 토큰은 별개의 이산 심볼
  • BOS(Beginning of Sequence) 특수 토큰을 추가해 "새 문서가 시작/종료됨"을 알리며, "emma"는 [BOS, e, m, m, a, BOS]로 래핑
  • 최종 어휘 크기는 27개(소문자 26개 + BOS 1개)

자동 미분(Autograd)

  • 신경망 학습에는 그래디언트가 필요: 각 파라미터에 대해 "이 값을 살짝 올리면 손실이 올라가는가, 내려가는가, 얼마나?"를 알아야 함
  • 연산 그래프는 많은 입력(모델 파라미터와 입력 토큰)을 가지지만 단일 스칼라 출력인 손실(loss) 로 수렴
  • 역전파(Backpropagation) 는 출력에서 시작해 그래프를 역방향으로 따라가며 미적분의 체인 룰에 의존해 모든 입력에 대한 손실의 그래디언트 계산
  • Value 클래스로 구현: 각 Value는 단일 스칼라(.data)를 감싸고 어떻게 계산되었는지 추적
    • 덧셈, 곱셈 등의 연산 시 새 Value가 입력(_children)과 해당 연산의 국소 도함수(_local_grads) 를 기억
    • 예: __mul__은 ∂(a·b)/∂a=b, ∂(a·b)/∂b=a를 기록
  • 지원되는 연산 블록: 덧셈, 곱셈, 거듭제곱, log, exp, ReLU
  • backward() 메서드가 역위상 순서로 그래프를 순회하며 각 단계에서 체인 룰 적용
    • 손실 노드에서 self.grad = 1로 시작(∂L/∂L=1)
    • 국소 그래디언트를 경로 따라 곱해가며 파라미터까지 전파
  • +=로 누적(할당이 아님): 그래프가 분기될 때 각 분기에서 독립적으로 그래디언트가 흘러와 합산되어야 함(다변수 체인 룰의 결과)
  • PyTorch의 .backward()와 알고리듬적으로 동일하나, 텐서 대신 스칼라 단위로 작동해 훨씬 단순하지만 효율성은 낮음

파라미터 초기화

  • 파라미터는 모델의 지식으로, 무작위로 시작해 학습 중 반복적으로 최적화되는 부동소수점 숫자의 대규모 집합
  • 가우시안 분포에서 작은 무작위 값으로 초기화
  • state_dict로 명명된 행렬들로 구성: 임베딩 테이블, 어텐션 가중치, MLP 가중치, 최종 출력 프로젝션
  • 하이퍼파라미터 설정:
    • n_embd = 16: 임베딩 차원
    • n_head = 4: 어텐션 헤드 수
    • n_layer = 1: 레이어 수
    • block_size = 16: 최대 시퀀스 길이
  • 소형 모델 기준 4,192개 파라미터(GPT-2는 16억 개, 현대 LLM은 수천억 개)

아키텍처

  • 모델 아키텍처는 무상태 함수: 토큰, 위치, 파라미터, 이전 위치의 캐시된 키/값을 받아 다음 토큰에 대한 로짓(점수) 반환
  • GPT-2를 따르되 약간 단순화: RMSNorm(LayerNorm 대신), 바이어스 없음, ReLU(GeLU 대신)
  • 헬퍼 함수
    • linear: 행렬-벡터 곱셈으로 가중치 행렬의 각 행에 대해 하나의 내적 계산, 신경망의 기본 구성 요소인 학습된 선형 변환
    • softmax: 원시 점수(로짓)를 확률 분포로 변환, 모든 값이 [0,1] 범위에 들어가고 합이 1이 됨, 수치적 안정성을 위해 최대값 먼저 빼기
    • rmsnorm: 벡터를 단위 제곱평균제곱근을 갖도록 재조정해 활성화가 네트워크를 통과하며 커지거나 줄어드는 것 방지, 학습 안정화
  • 모델 구조
    • 임베딩: 토큰 ID와 위치 ID가 각각의 임베딩 테이블(wte, wpe)에서 행을 참조, 두 벡터를 더해 토큰이 무엇인지와 시퀀스에서 어디에 있는지 동시 인코딩
      • 현대 LLM은 위치 임베딩을 건너뛰고 RoPE 같은 상대 기반 위치 지정 기법 사용
    • 어텐션 블록: 현재 토큰을 Q(쿼리), K(키), V(값) 세 벡터로 프로젝션
      • 쿼리: "내가 찾는 것은?", 키: "내가 담고 있는 것은?", 값: "선택되면 제공하는 것은?"
      • 예: "emma"에서 두 번째 "m"이 다음을 예측할 때 "최근 어떤 모음이 있었나?" 같은 쿼리 학습 가능, 앞의 "e"가 이 쿼리와 잘 맞아 높은 어텐션 가중치 획득
      • 키와 값은 KV 캐시에 추가되어 이전 위치 참조 가능
      • 각 어텐션 헤드가 쿼리와 모든 캐시된 키 사이의 내적(√d_head로 스케일)을 계산, softmax로 어텐션 가중치 얻고 캐시된 값의 가중 합 계산
      • 모든 헤드 출력을 연결해 attn_wo로 프로젝션
      • 어텐션 블록은 위치 t의 토큰이 과거 0..t-1의 토큰을 "볼" 수 있는 유일한 곳, 어텐션은 토큰 통신 메커니즘
    • MLP 블록: 2층 피드포워드 네트워크: 임베딩 차원의 4배로 확장 → ReLU 적용 → 다시 축소
      • 위치별 "사고"의 대부분이 이루어지는 곳
      • 어텐션과 달리 시간 t에 완전히 로컬한 계산
      • Transformer는 통신(어텐션)과 계산(MLP)을 교차 배치
    • 잔차 연결: 어텐션과 MLP 블록 모두 출력을 입력에 다시 더함
      • 그래디언트가 네트워크를 직접 통과하게 해 깊은 모델의 학습 가능하게 함
    • 출력: 최종 은닉 상태를 lm_head로 어휘 크기에 프로젝션해 토큰당 하나의 로짓 생성(여기서는 27개 숫자), 높은 로짓 = 해당 토큰이 다음에 올 가능성 높음
    • KV 캐시 특이점: 학습 중에도 KV 캐시 사용은 드문 경우이나, microgpt가 한 번에 하나의 토큰만 처리하므로 명시적으로 구축, 캐시된 키와 값이 연산 그래프의 라이브 Value 노드로 역전파 대상

학습 루프

  • 학습 루프는 반복적으로: (1) 문서 선택 → (2) 토큰에 대해 모델 순방향 실행 → (3) 손실 계산 → (4) 역전파로 그래디언트 획득 → (5) 파라미터 업데이트
  • 토큰화
    • 각 학습 스텝에서 하나의 문서를 선택해 양쪽에 BOS 래핑: "emma" → [BOS, e, m, m, a, BOS]
    • 모델의 목표는 이전 토큰들이 주어졌을 때 각 다음 토큰을 예측
  • 순방향 패스와 손실
    • 토큰을 한 번에 하나씩 모델에 공급하며 KV 캐시 구축
    • 각 위치에서 모델이 27개 로짓 출력, softmax로 확률 변환
    • 각 위치의 손실은 올바른 다음 토큰의 음의 로그 확률: −log p(target), 이를 교차 엔트로피 손실이라 함
    • 손실은 모델이 실제로 오는 것에 얼마나 놀랐는지 측정: 확률 1.0 할당 시 손실 0, 확률 0 근처 시 손실 +∞
    • 문서 전체의 위치별 손실을 평균해 단일 스칼라 손실 획득
  • 역방향 패스
    • loss.backward() 한 번 호출로 전체 연산 그래프에 대해 역전파 실행
    • 이후 각 파라미터의 .grad가 손실을 줄이기 위해 어떻게 변경해야 하는지 알려줌
  • Adam 옵티마이저
    • 단순 경사 하강(p.data -= lr * p.grad) 대신 Adam 사용
    • 파라미터당 두 개의 이동 평균 유지:
      • m: 최근 그래디언트의 평균(모멘텀)
      • v: 최근 그래디언트 제곱의 평균(파라미터별 학습률 적응)
    • m_hat, v_hat은 0으로 초기화된 m, v의 바이어스 보정
    • 학습률은 학습 중 선형 감소
    • 업데이트 후 .grad = 0으로 초기화
  • 학습 결과
    • 1,000 스텝 동안 손실이 약 3.3(27개 토큰 중 무작위 추측: −log(1/27)≈3.3)에서 약 2.37로 감소
    • 낮을수록 좋고 최저는 0(완벽한 예측)이므로 개선 여지 있으나 모델이 이름의 통계적 패턴을 학습 중임이 명확

추론

  • 학습 완료 후 모델에서 새 이름 샘플링 가능, 파라미터 고정 후 순방향 패스를 루프로 실행, 각 생성 토큰을 다음 입력으로 피드백
  • 샘플링 과정
    • 각 샘플을 BOS 토큰으로 시작("새 이름 시작")
    • 모델이 27개 로짓 생성 → 확률로 변환 → 해당 확률에 따라 무작위로 하나의 토큰 샘플링
    • 해당 토큰을 다음 입력으로 피드백, 모델이 다시 BOS 생성("완료") 또는 최대 시퀀스 길이 도달까지 반복
  • 온도(Temperature)
    • softmax 전에 로짓을 온도로 나눔
    • 온도 1.0: 모델이 학습한 분포에서 직접 샘플링
    • 낮은 온도(예: 0.5): 분포를 날카롭게 해 모델이 더 보수적으로 상위 선택을 할 가능성 높임
    • 온도 0 근처: 항상 가장 확률 높은 단일 토큰 선택(탐욕적 디코딩)
    • 높은 온도: 분포를 평평하게 해 더 다양하지만 덜 일관된 출력

실행 방법

  • Python만 필요(pip install 없음, 의존성 없음): python train.py
  • MacBook에서 약 1분 소요
  • 각 스텝에서 손실 출력: ~3.3(무작위)에서 ~2.37로 감소
  • 학습 완료 후 환각된 새 이름 생성: "kamon", "ann", "karai" 등
  • Google Colab 노트북에서도 실행 가능, Gemini에게 질문 가능
  • 다른 데이터셋 시도, num_steps 증가로 더 오래 학습, 모델 크기 증가로 더 나은 결과 가능

코드 진행 단계

파일

추가 내용

train0.py

바이그램 카운트 테이블 — 신경망 없음, 그래디언트 없음

train1.py

MLP + 수동 그래디언트(수치적 & 해석적) + SGD

train2.py

Autograd(Value 클래스) — 수동 그래디언트 대체

train3.py

위치 임베딩 + 단일 헤드 어텐션 + rmsnorm + 잔차

train4.py

멀티헤드 어텐션 + 레이어 루프 — 전체 GPT 아키텍처

train5.py

Adam 옵티마이저 — 이것이 train.py

  • build_microgpt.py Gist의 Revisions에서 모든 버전과 각 스텝 간 diff 확인 가능

프로덕션 LLM과의 차이

  • microgpt는 GPT 학습 및 실행의 완전한 알고리듬적 본질 포함, ChatGPT 같은 프로덕션 LLM과의 차이는 핵심 알고리듬을 바꾸지 않으며 규모에서 작동하게 하는 요소들
  • 데이터
    • 32K 짧은 이름 대신 수조 개의 인터넷 텍스트 토큰(웹페이지, 책, 코드 등)으로 학습
    • 데이터 중복 제거, 품질 필터링, 도메인 간 신중한 혼합
  • 토크나이저
    • 단일 문자 대신 BPE(Byte Pair Encoding) 같은 서브워드 토크나이저 사용
    • 자주 함께 나타나는 문자 시퀀스를 단일 토큰으로 병합, "the" 같은 일반 단어는 단일 토큰, 희귀 단어는 조각으로 분리
    • ~100K 토큰 어휘, 위치당 더 많은 콘텐츠를 보므로 훨씬 효율적
  • Autograd
    • 순수 Python의 스칼라 Value 객체 대신 텐서(대규모 다차원 숫자 배열) 사용, 초당 수십억 부동소수점 연산 수행하는 GPU/TPU에서 실행
    • PyTorch가 텐서에 대한 autograd 처리, FlashAttention 같은 CUDA 커널이 여러 연산 융합
    • 수학은 동일, 많은 스칼라가 병렬 처리
  • 아키텍처
    • microgpt: 4,192개 파라미터, GPT-4급 모델: 수천억 개
    • 전반적으로 매우 유사한 Transformer 신경망이나 훨씬 넓고(임베딩 차원 10,000+) 훨씬 깊음(100+ 레이어)
    • 추가 레고 블록 유형과 순서 변경:
      • RoPE(회전 위치 임베딩) — 학습된 위치 임베딩 대신
      • GQA(그룹화된 쿼리 어텐션) — KV 캐시 크기 감소
      • 게이트 선형 활성화 — ReLU 대신
      • MoE(전문가 혼합) 레이어
    • 잔차 스트림 위에 어텐션(통신)과 MLP(계산)가 교차하는 핵심 구조는 잘 보존
  • 학습
    • 스텝당 하나의 문서 대신 대규모 배치(스텝당 수백만 토큰), 그래디언트 누적, 혼합 정밀도(float16/bfloat16), 신중한 하이퍼파라미터 튜닝
    • 프론티어 모델 학습에 수천 개의 GPU가 수개월간 실행
  • 최적화
    • microgpt: Adam + 단순 선형 학습률 감소
    • 대규모에서 최적화는 독자적 분야: 감소된 정밀도(bfloat16, fp8), 대규모 GPU 클러스터에서 학습
    • 옵티마이저 설정(학습률, 가중치 감쇠, 베타 파라미터, 워밍업/감쇠 스케줄)을 정밀하게 튜닝 필요, 올바른 값은 모델 크기, 배치 크기, 데이터셋 구성에 따라 다름
    • 스케일링 법칙(예: Chinchilla)이 고정 컴퓨팅 예산을 모델 크기와 학습 토큰 수 사이에 어떻게 할당할지 안내
    • 대규모에서 이 세부사항을 잘못하면 수백만 달러의 컴퓨팅 낭비 가능, 팀들이 전체 학습 실행 전 광범위한 소규모 실험 수행
  • 후학습(Post-training)
    • 학습에서 나온 기본 모델("사전학습" 모델)은 문서 완성기이지 챗봇이 아님
    • ChatGPT로 만드는 과정은 두 단계:
      • SFT(지도 미세조정): 문서를 큐레이션된 대화로 교체하고 학습 계속, 알고리듬적으로 변화 없음
      • RL(강화학습): 모델이 응답 생성 → 점수 부여(인간, "심판" 모델, 알고리듬) → 피드백으로 학습
    • 근본적으로 여전히 문서에 대해 학습하나, 이제 문서가 모델 자체에서 나온 토큰으로 구성
  • 추론
    • 수백만 사용자에게 모델 서빙에 자체 엔지니어링 스택 필요: 요청 배칭, KV 캐시 관리 및 페이징(vLLM 등), 속도를 위한 추측적 디코딩, 메모리 감소를 위한 양자화(int8/int4로 실행), 여러 GPU에 모델 분산
    • 근본적으로 여전히 시퀀스의 다음 토큰을 예측하나 더 빠르게 만드는 엔지니어링에 많은 노력

FAQ

  • 모델이 무언가를 "이해"하는가?
    • 철학적 질문이나 기계적으로: 마법은 일어나지 않음
    • 모델은 입력 토큰을 다음 토큰에 대한 확률 분포로 매핑하는 큰 수학 함수
    • 학습 중 파라미터는 올바른 다음 토큰을 더 확률 높게 만들도록 조정
    • "이해"를 구성하는지는 개인에게 달렸으나 메커니즘은 위 200줄에 완전히 담김
  • 왜 작동하는가?
    • 모델에 수천 개의 조정 가능한 파라미터가 있고, 옵티마이저가 각 스텝에서 손실을 낮추도록 조금씩 이동
    • 많은 스텝에 걸쳐 파라미터가 데이터의 통계적 규칙성을 포착하는 값으로 안정
    • 이름의 경우: 자음으로 시작하는 경우 많음, "qu"가 함께 나타나는 경향, 자음 3개 연속은 드묾 등
    • 모델은 명시적 규칙이 아닌 이를 반영하는 확률 분포를 학습
  • ChatGPT와 어떤 관련이 있는가?
    • ChatGPT는 이 동일한 핵심 루프(다음 토큰 예측, 샘플링, 반복)를 엄청나게 확장하고 대화형으로 만드는 후학습 추가
    • 채팅 시 시스템 프롬프트, 사용자 메시지, 응답 모두 시퀀스의 토큰일 뿐
    • 모델은 microgpt가 이름을 완성하는 것과 동일하게 한 번에 하나의 토큰으로 문서를 완성
  • "환각"은 무엇인가?
    • 모델은 확률 분포에서 샘플링해 토큰 생성
    • 진실 개념이 없으며 학습 데이터에 비추어 통계적으로 그럴듯한 시퀀스만 앎
    • microgpt가 "karia" 같은 이름을 "환각"하는 것은 ChatGPT가 거짓 사실을 자신 있게 말하는 것과 동일한 현상
    • 둘 다 실제가 아닌 그럴듯하게 들리는 완성
  • 왜 이렇게 느린가?
    • microgpt는 순수 Python에서 한 번에 하나의 스칼라 처리, 단일 학습 스텝에 수 초 소요
    • GPU에서 동일한 수학이 수백만 스칼라를 병렬 처리해 수 자릿수 더 빠르게 실행
  • 더 좋은 이름을 생성하게 할 수 있는가?
    • 가능: 더 오래 학습(num_steps 증가), 모델 크기 증가(n_embd, n_layer, n_head), 더 큰 데이터셋 사용
    • 대규모에서도 중요한 동일한 조절 요소
  • 데이터셋을 바꾸면?
    • 모델은 데이터에 있는 어떤 패턴이든 학습
    • 도시 이름, 포켓몬 이름, 영어 단어, 짧은 시 파일로 교체하면 대신 그것들을 생성하도록 학습
    • 나머지 코드는 변경 불필요

Source code

"""
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

# 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)}")

# 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}")

# 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

# 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)}")

# 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

# 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

# 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')

# 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)}")

See also

Favorite site