LLM from Prompt 2 · 한국어 강좌 · 바이브코딩 편

코드를 치지 않고, 사양으로
대형 언어 모델을 밑바닥부터 만든다

여러분은 한국어로 무엇이 되어야 하는지를 쓰고, 코딩 에이전트(hermes + qwen3.8:27b)가 코드를 만들고, 여러분은 동작을 검증한 뒤 강좌 제작자가 직접 쓴 기준 구현과 비교합니다. 이 루프가 강좌의 전부입니다.

① 사양 작성→ ② 에이전트가 코드 생성→ ③ 실행 + 검증→ ④ 기준 구현과 비교→ ✓ 다음 스텝 · ✗ 실패 시 사양 보완(최대 3회)

Orientation

강좌 소개 — 2판에서 무엇이 달라졌나

이 강좌의 1판(llm_from_prompt)은 실습 프롬프트가 검증을 쉽게 하려고 원서 코드를 한 줄씩 한국어로 받아쓰게 시켰습니다 — "변수 x에 이 식을 담고, 그 다음 이 함수를 이 인자로 호출하고…". 결과는 원서와 똑같이 나왔지만, 학생이 배우는 것은 "코드를 한국어로 읽어 주는 법"이 되어 버렸습니다. 그럴 바엔 코드를 직접 주는 편이 낫습니다.

2판은 방향을 바꿉니다. 한 줄로 요약하면 "사양으로 요구하고, 동작으로 검증한다."

1판2판 (이 페이지)
프롬프트코드를 한 줄씩 지시사양 — 접점·동작·제약만 지시
생성된 코드원서 코드와 동일해야 함구현은 자유 — 입력·출력만 같으면 정답
정답 코드프롬프트 안에 숨어 있음제작자가 직접 쓴 기준 구현(solutions/)을 공개
검증출력 대조출력 대조 — 구현이 달라도 통과하도록 계약을 설계
교안원서 코드 설명기준 구현을 읽으며 개념 확인 + 사양 쓰는 법
  1. 학생은 코드를 치지 않습니다. 대신 "무엇이 되어야 하는지"를 한국어 사양으로 씁니다 — 만들 파일, 다음 스텝이 갖다 쓸 함수 이름, 입력, 출력 줄의 정확한 형식, 지켜야 할 제약.
  2. 코딩 에이전트가 코드를 만듭니다. 교내 GPU 서버의 오픈 모델(qwen3.8:27b)을 사용하는 hermes 에이전트가 사양을 읽고 실행 가능한 파이썬 코드를 생성합니다. 구현 방식은 에이전트의 몫입니다.
  3. 학생은 동작을 검증합니다. 만들어진 코드를 실행하고 기대 출력과 대조합니다. 통과하면 기준 구현과 자기 코드를 나란히 읽고, 실패하면 코드를 고치는 대신 사양을 고쳐서 다시 시킵니다.

이 과정을 반복하면 두 가지를 동시에 배웁니다: LLM의 내부 구조(토큰화부터 미세조정까지 전 과정)와 AI 에이전트 활용 능력(모호하지 않은 사양을 쓰고, 결과를 검증하고, 실패하면 사양을 개선하는 법 — 앞으로의 개발 방식입니다). 코딩이 서툴러도 괜찮습니다.

교재와 코드의 출처

이 강좌는 Sebastian Raschka의 저서 Build a Large Language Model (From Scratch)의 개념 흐름을 따라가되, 교안은 자연어 처리를 처음 접하는 학생을 위해 보충 설명을 더해 독자적으로 새로 집필했습니다. 기준 구현은 Apache-2.0으로 공개된 공식 코드 저장소(rickiepark/llm-from-scratch)의 동작을 기준으로 삼되, 이 강좌의 스텝 구조에 맞게 다시 작성했습니다. 원서(한국어판: 길벗)를 함께 읽으면 학습 효과가 큽니다.

시작하는 법 — 명령 세 줄

# 1. 시스템 구성 (한 번만 — hermes 설치, GPU 서버 연결, 파이썬 환경까지 전부)
git clone https://github.com/mrgrit/llm_from_prompt2.git
cd llm_from_prompt2
bash setup/setup.sh

끝나면 자가 진단이 자동 실행되어 모든 항목이 [OK]인지 보여 줍니다. 이후 실습은 언제나 같은 명령의 반복입니다:

# 2. 루프 돌리기 — 사양으로 코드 생성 → 검증 → 기준 구현과 비교
bash harness/run_loop.sh ch00 prompts/ch00/step01-warmup.md
bash harness/verify.sh ch00 step01
bash harness/verify.sh ch00 step01 --solution
diff workspace/ch00/step01_text_load.py solutions/ch00/step01_text_load.py

Method

학습 방식 — 사양 · 구현 · 검증

이 강좌의 실습 인프라는 두 개념 위에 서 있습니다. 하네스(harness)는 에이전트가 일하기 전에 미리 깔아 둔 작업 환경 — 규칙 문서, 검증 장치, 디렉토리 규약 — 을 말합니다. 루프(loop)는 그 환경 안에서 여러분이 반복하는 사이클입니다. 2판의 하네스는 한 가지 경계를 분명히 긋습니다: 사양은 학생이, 구현은 에이전트가, 검증은 스크립트가.

flowchart LR
    subgraph 학생["학생이 쓴다 — 사양"]
        S1["접점
파일명 · 함수 이름 · 입력/반환"] S2["동작
입력 → 출력 줄"] S3["제약
라이브러리 · 시드 · 금지"] end subgraph 에이전트["에이전트가 정한다 — 구현"] I1["변수명 · 자료구조
알고리즘 · 함수 분리"] end subgraph 검증기["검증기가 본다 — 동작"] V1["실행 출력 = 기대 출력 ?"] end 학생 --> 에이전트 --> 검증기 R["기준 구현 (solutions/)
강좌 제작자가 직접 쓴 정답"] -.->|기대 출력의 출처| V1
사양·구현·검증의 경계. 학생은 왼쪽 상자만 쓰고, 검증기는 오른쪽 상자만 본다. 가운데는 에이전트의 영역이다.
flowchart LR
    A["① 사양 읽기·쓰기"] --> B["② 코드 생성
hermes + qwen3.8:27b"] B --> C["③ 실행 + 검증
verify.sh 자동 대조"] C -->|통과| D["④ 기준 구현과 비교
verify.sh --solution · diff"] D --> E["⑤ 다음 스텝"] C -->|실패| F["사양 보완
(최대 3회)"] F --> B
강좌의 기본 루프. 한 스텝 = 루프 한 바퀴. 실패하면 코드가 아니라 사양으로 돌아간다.

중요한 설계가 하나 있습니다: 코드를 만드는 쪽(hermes)과 채점하는 쪽(verify.sh)이 분리되어 있습니다. 검증 기준(기대 출력)은 코드를 만들기 전에 사양 파일 안에 먼저 선언되고, 판정은 언제나 실제 실행 결과로 합니다. 그리고 그 기대 출력은 전부 기준 구현을 실행해 실측한 값이며, 어떤 올바른 구현이든 같은 값이 나오도록 설계됩니다 — 동률 처리, 난수 시드 위치, 소수점 자릿수처럼 구현에 따라 흔들릴 수 있는 것은 사양에 규칙으로 적거나 검증에서 뺍니다.

읽고 실행한다 — 두 가지 실행 모드

run_loop.sh는 hermes를 바로 실행하지 않습니다. 실행 전에 화면에 세 가지를 차례로 보여 줍니다: ① 이 스텝의 배경 설명(무엇을 만드는지, 왜 하는지, 지금까지와 어떻게 이어지는지, 무엇이 나오면 성공인지), ② hermes에게 전달될 사양 전문, ③ 기대 산출물과 기대 출력. 읽고 Enter를 누르면 자동 실행됩니다.

$ bash harness/run_loop.sh ch01 prompts/ch01/step02-bigram-model.md

◆ ① 이 스텝은 무엇인가 — 실행 전에 꼭 읽으세요 ─────────────
> 무엇을 — "어떤 단어 뒤에 어떤 단어가 몇 번 왔는지"를 세어 다음 단어를 예측하는 바이그램 언어 모델
> 왜 — 언어 모델의 본질(말뭉치의 통계 학습)을 실제 소설 전체로 체험하기 위해
> 맥락 — 0장에서 받은 텍스트를 다시 쓰며, 다음 스텝(step03)이 이 모델로 문장을 생성한다
> 기대 결과 — 'of' 다음에 가장 자주 나온 단어: the (21번)
...
◆ ② hermes에게 전달되는 사양 ───────────────────────────────
...(사양 전문)...
◆ ③ 기대 산출물과 기대 출력 ────────────────────────────────
...
위 ①~③을 읽었으면 [Enter] — hermes가 위 사양을 자동 실행합니다.
직접 실행해 보려면 [q] — 손으로 hermes를 부르는 방법을 안내합니다.

q를 누르면 자동 실행 대신 직접 실행 방법이 안내됩니다 — 같은 사양을 여러분 손으로 hermes에 넣는 방법입니다. 어느 쪽으로 실행해도 검증(verify.sh)은 동일합니다.

# 사양 본문 추출 (읽기/복사용 — 앞에 작업 디렉토리 지시 두 줄이 붙는다)
bash harness/show_prompt.sh ch01 step02
# hermes에 직접 전달 (자동 실행과 동일한 동작)
cd workspace/ch01
hermes -z "$(bash ../../harness/show_prompt.sh ch01 step02)" -m qwen3.8:27b --reasoning high --in .

AI 조교 — 교재를 읽다 막히면

이 페이지의 오른쪽 아래 "AI 조교에게 묻기" 버튼은 교내 GPU 서버의 모델과 직접 대화하는 챗봇입니다. 본문을 드래그해 선택하면 "선택한 본문을 질문에 첨부" 칩이 뜨고, 첨부한 본문을 근거로 답합니다. 패널에 GPU 서버 주소(예: 192.168.0.10:11434)를 입력하고 [연결]을 누르면 서버의 모델 목록을 받아 와 고를 수 있습니다(목록에 강좌 공식 모델 qwen3.8:27b가 있으면 그것을 골라 둡니다). "깊게 생각하기"를 켜면 느리지만 어려운 질문에 강해집니다.

챗봇은 로컬 서버로 연 페이지에서만 동작합니다

GPU 서버(Ollama)는 보안상 http://localhost에서 연 페이지의 요청만 받습니다. 파일을 더블클릭해 열거나(file://) 외부 호스팅에서 열면 본문은 읽을 수 있지만 챗봇은 연결 오류를 냅니다. 저장소에서 bash harness/serve_docs.sh를 실행하고 http://localhost:8000/으로 여세요.

3회 규칙, 그리고 하지 말아야 할 두 가지

검증에 실패하면 사양을 보완해 다시 시도하되 최대 3회까지만. 3회를 넘기면 스텝이 너무 큰 것입니다 — 더 작게 쪼개거나 조교/교수에게 질문하세요. 그리고 실패했을 때 절대 하지 말 것: ① 생성된 코드를 손으로 고치기, ② 사양에 코드를 붙여 넣어 통과시키기. 둘 다 이 강좌가 기르려는 능력이 아닙니다. 무엇이 모호했는지 찾아 사양을 고치세요.

Chapter 00 전 스텝 검증 완료 (1/1)

0장. 오리엔테이션 — 시스템 구성, 첫 루프, 그리고 사양과 구현의 분리

이 장에서 배우는 것

  • 이 강좌가 코드를 치지 않고 무엇을 하는지 — "사양으로 요구하고, 동작으로 검증한다"
  • 실습 환경을 명령 하나로 구성하는 법과 자가 진단 읽는 법
  • 강좌의 기본 동작인 루프 — 사양 → 코드 생성 → 실행·검증 → 기준 구현과 비교
  • 사양을 읽고 쓰는 법: 못 박는 것(접점·동작·제약)과 맡기는 것(구현)의 경계
  • 첫 루프 한 바퀴: 단편소설을 내려받아 문자 수를 세는 스크립트

들어가며 — 코드를 치지 않고 LLM을 만든다는 것

이 강좌의 제목은 "프롬프트로 밑바닥부터 만드는 LLM"입니다. 여러분은 이 강좌에서 파이썬 코드를 한 줄도 직접 치지 않습니다. 대신 무엇이 만들어져야 하는지를 한국어로 정확히 쓰고, 코딩 에이전트가 만든 결과를 실행해서 검증합니다. 코드는 에이전트가 씁니다.

이것이 요즘 "바이브코딩(vibe coding)"이라고 불리는 개발 방식과 닿아 있습니다. 다만 바이브코딩이라는 말에는 "대충 말하면 AI가 알아서 해 준다"는 오해가 따라붙습니다. 이 강좌에서 배우는 것은 그 반대입니다 — 대충 말하면 대충 나오고, 정확히 말하면 정확히 나온다. 요구를 정확히 정의하는 능력과 결과를 검증하는 능력, 이 둘이 이 강좌에서 기르는 핵심 기술입니다. LLM의 내부 구조는 그 능력을 연습하는 훌륭한 교재이자, 동시에 이 강좌의 두 번째 학습 목표입니다.

0.1 이 강좌의 방식 — 사양과 구현의 분리

먼저 용어 하나를 정합니다. 여러분이 에이전트에게 주는 글을 이 강좌에서는 사양(specification, 스펙)이라고 부릅니다. 흔히 프롬프트(prompt)라고 부르는 것과 같은 것이지만, "무엇이 되어야 하는지를 정의한 글"이라는 뜻을 분명히 하려고 사양이라는 말을 씁니다. 사양에는 세 가지만 들어갑니다.

사양이 못 박는 것뜻예
접점(interface)만들 파일의 이름, 다음 스텝이 갖다 쓸 함수의 이름과 입력·반환파일 step01_text_load.py, 함수 load_text()는 문자열을 반환
동작(behavior)입력이 무엇이고, 출력이 정확히 어떤 줄로 나와야 하는지입력: 이 URL의 파일, 출력: 총 문자 수: 20479
제약(constraint)지켜야 할 조건과 금지 사항표준 라이브러리만, 명령행 인자 금지

그리고 사양에 들어가지 않는 것이 있습니다. 변수 이름, 어떤 자료구조를 쓸지, 계산 순서, 함수를 몇 개로 나눌지 — 즉 구현(implementation). 이것은 에이전트의 몫입니다. 같은 사양을 두 번 주면 두 번 다른 코드가 나올 수 있고, 그래도 됩니다. 동작이 같으면 정답입니다.

flowchart LR
    subgraph 학생["학생이 쓴다 — 사양"]
        S1["접점"]
        S2["동작"]
        S3["제약"]
    end
    subgraph 에이전트["에이전트가 정한다 — 구현"]
        I1["변수명 · 자료구조
알고리즘 · 함수 분리"] end subgraph 검증기["검증기가 본다 — 동작"] V1["실행 출력 = 기대 출력 ?"] end 학생 --> 에이전트 --> 검증기 R["기준 구현 (solutions/)"] -.->|기대 출력의 출처| V1
그림 0-1. 사양·구현·검증의 경계. 학생은 왼쪽 상자만 쓰고, 검증기는 오른쪽 상자만 본다. 가운데는 에이전트의 영역이다.

그림 아래쪽의 기준 구현(reference implementation)도 이 강좌의 중요한 구성 요소입니다. 강좌 제작자가 직접 쓴 정답 코드로, 저장소의 solutions/ 폴더에 공개되어 있습니다. 세 가지 역할을 합니다.

  1. 검증에 쓰는 기대 출력의 출처 — 사양 파일에 적힌 정답값은 전부 기준 구현을 실행해 실측한 값입니다.
  2. 교안이 설명하는 코드 — 각 장의 "기준 구현 읽기" 절에서 이 코드를 읽으며 개념을 확인합니다.
  3. 비교 대상 — 검증을 통과한 뒤 여러분의 에이전트가 만든 코드와 나란히 놓고 읽습니다. 못 박은 것(접점·출력)은 같고 못 박지 않은 것(변수명·구조)은 다르다는 사실을 매 스텝 눈으로 확인합니다.

0.2 시스템 구성 — 명령 하나

실습 환경은 세 부분으로 이루어집니다.

flowchart LR
    subgraph PC["학생 PC (Ubuntu / WSL2)"]
        H["hermes 에이전트
사양을 읽고 코드 파일을 만든다"] V[".venv 파이썬 환경
만든 코드를 실행·검증한다"] W["workspace/chNN/
코드가 생성되는 작업 공간"] end subgraph GPU["교내 GPU 서버 (Ollama)"] M["qwen3.8:27b
코드를 생각해 내는 대형 언어 모델"] end H <-->|"사양 전송 · 코드 응답"| M H --> W --> V
그림 0-2. 실습 환경. 무거운 모델은 서버에서 돌고, 학생 PC에는 GPU가 필요 없다. hermes는 서버의 모델을 손발처럼 부리는 에이전트다.
  • hermes: 코딩 에이전트(coding agent)입니다. 에이전트란 언어 모델에 "파일을 만들고, 명령을 실행하는" 손발을 붙인 프로그램을 말합니다. 여러분의 사양을 서버의 모델에 보내고, 모델이 결정한 코드를 실제 파일로 저장합니다.
  • qwen3.8:27b: 서버에서 서빙되는 오픈 소스 대형 언어 모델입니다(Qwen 3.8 계열, 273억 파라미터, 문맥 262,144토큰, 도구 호출·사고(thinking) 지원). 코드를 "생각해 내는" 쪽입니다. 1장에서 이 모델이 어떤 원리로 동작하는지 배웁니다.
  • Ollama: 서버에서 모델을 실행해 주는 프로그램입니다. hermes는 Ollama가 열어 둔 주소로 접속합니다.
  • .venv: 여러분 PC의 파이썬 가상환경(virtual environment)입니다. 시스템 파이썬을 건드리지 않고 이 강좌에 필요한 패키지(torch 등)만 따로 설치한 격리 공간입니다.

setup.sh는 ① 기본 도구 확인 → ② GPU 서버 연결 확인 → ③ hermes 설치 → ④ hermes를 서버 모델로 설정 → ⑤ .venv 구성을 차례로 하고, 끝에 자가 진단(setup/check_env.sh)을 실행합니다. 자가 진단은 항목마다 [OK] 또는 [FAIL]을 찍고, 실패한 항목에는 해결 힌트를 붙입니다. 전부 [OK]면 준비 끝입니다.

0.3 루프 — 강좌의 기본 동작

환경이 준비되면 이 강좌는 끝까지 같은 동작의 반복입니다 (위 "학습 방식"의 루프 그림). 명령으로는 이렇습니다.

# ② 사양을 hermes 에게 전달해 코드를 생성시킨다
bash harness/run_loop.sh ch00 prompts/ch00/step01-warmup.md
# ③ 생성된 코드를 실행하고 기대 출력과 대조한다
bash harness/verify.sh ch00 step01
# ④ 기준 구현도 같은 검증기로 돌려 보고, 내 코드와 나란히 읽는다
bash harness/verify.sh ch00 step01 --solution
diff workspace/ch00/step01_text_load.py solutions/ch00/step01_text_load.py

verify.sh는 코드를 실행하고 실제 출력을 그대로 보여 준 뒤, 사양 파일의 기대 출력 블록 각 줄이 출력에 들어 있는지 한 줄씩 [OK]/[FAIL]로 판정합니다. 판정하는 것은 모델이 아니라 이 스크립트입니다 — 코드를 만드는 쪽(hermes)과 채점하는 쪽(verify.sh)이 분리되어 있어서, 에이전트가 자기 답을 자기가 채점하는 일은 없습니다.

0.4 사양 읽는 법 — 첫 사양 해부

이번 장의 실습 사양(prompts/ch00/step01-warmup.md의 "프롬프트" 섹션)을 0.1절의 세 범주로 나눠 읽어 봅시다.

hermes에게 전달되는 사양 (prompts/ch00/step01-warmup.md)
step01_text_load.py 라는 파이썬 파일을 만들어줘.

만들 것: 단편소설 텍스트 파일을 내려받아 읽고, 얼마나 긴지 알려 주는 스크립트.

입력:
- 아래 URL의 텍스트 파일. 현재 디렉토리에 the-verdict.txt 라는 이름으로 저장한다.
  파일이 이미 있으면 다시 내려받지 않는다.
  https://raw.githubusercontent.com/rickiepark/llm-from-scratch/main/ch02/01_main-chapter-code/the-verdict.txt

출력 (직접 실행하면 다음 두 줄을 정확히 이 형식으로 출력한다):
- `총 문자 수: ` 뒤에 파일 전체의 문자 수
- `앞 50자: ` 뒤에 파일의 처음 50글자

접점 (다음 스텝들이 import 해서 쓴다):
- 함수 load_text() — 인자 없이 부르면 (파일이 없으면 내려받은 뒤) 파일을 UTF-8 텍스트로 읽어
  문자열 하나로 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다. 출력은 직접 실행했을 때만.

지켜야 할 것:
- 표준 라이브러리만 사용한다. 외부 패키지 설치 금지.
- 요구한 것 외의 기능(명령행 인자, 클래스, 로깅, 예외 처리)은 추가하지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
범주이 사양에서
접점파일명 step01_text_load.py, 함수 load_text()가 문자열을 반환, import 시 조용할 것
동작입력 URL과 저장 파일명 the-verdict.txt, 있으면 재사용, 출력 두 줄의 정확한 형식
제약표준 라이브러리만, 기능 추가 금지, 한국어 주석 한 줄

그리고 이 사양에 없는 것: 어떤 함수로 내려받을지(urlretrieve? urlopen?), 파일이 있는지 어떻게 확인할지, 변수 이름을 무엇으로 할지, 출력에 f-문자열을 쓸지 print("라벨:", 값)을 쓸지. 전부 에이전트가 정합니다. 그런데도 출력은 정확히 같아야 하고, 실제로 같습니다 — 같은 파일을 읽으면 문자 수는 20479이고 처음 50글자는 소설의 첫 문장이기 때문입니다. 어떤 올바른 구현이든 같은 값을 내는 것만 기대 출력에 넣는다 — 이것이 2판 사양 설계의 핵심 규칙입니다.

"접점"에 있는 마지막 조건 — import만 했을 때는 아무것도 출력하지 않는다 — 를 눈여겨보세요. 1장의 실습이 이 파일의 load_text()를 가져다 쓸 때, import 하는 순간 "총 문자 수: …"가 찍혀 버리면 1장 출력이 오염됩니다. 다음 스텝을 위한 조건이 사양에 미리 들어 있는 것입니다.

0.5 실습 — 첫 루프

무엇을 — 코딩 에이전트(hermes)에게 첫 코드를 생성시킨다: 단편소설을 내려받아 문자 수를 세는 스크립트
왜 — 사양 → 코드 생성 → 실행 → 검증 → 기준 구현과 비교의 루프가 내 환경에서 실제로 도는지 확인하기 위해
맥락 — 강좌의 첫 루프. 여기서 받는 텍스트는 이후 모든 장(1장 바이그램, 2장 토큰화)의 실습 재료가 된다
기대 결과 — 총 문자 수: 20479 가 그대로 출력되면 성공. 이 숫자는 1장과 2장에서 다시 만난다

내려받는 텍스트는 《The Verdict》(Edith Wharton, 1908)라는 단편소설입니다. 저작권이 만료된 퍼블릭 도메인(public domain) 작품이라 자유롭게 쓸 수 있고, 원서의 실습도 이 텍스트를 씁니다. 3,600여 단어의 짧은 소설이지만 2장에서 토큰화를 배우고 5장에서 작은 모델을 훈련하는 데까지 계속 등장합니다.

bash harness/run_loop.sh ch00 prompts/ch00/step01-warmup.md
bash harness/verify.sh ch00 step01
--- 검증 결과 (실측) ---
총 문자 수: 20479
앞 50자: I HAD always thought Jack Gisburn rather a cheap g
[OK]   총 문자 수: 20479
[OK]   앞 50자: I HAD always thought Jack Gisburn rather a cheap g   →   ✅ 검증 통과

"문자 수"에는 공백과 줄바꿈도 들어갑니다. 파일 크기가 20,479바이트인 것과 문자 수가 20479인 것이 일치하는 이유는 이 소설이 ASCII 범위의 영문자만 써서 한 글자가 한 바이트이기 때문입니다 — 한국어라면 글자 하나가 UTF-8로 3바이트라 둘이 달라집니다. 사양이 "UTF-8 텍스트로 읽는다"고 못 박은 이유는 어느 컴퓨터에서 실행해도 같은 문자열이 나오게 하기 위해서입니다.

0.6 기준 구현 읽기

검증을 통과했으면 기준 구현 solutions/ch00/step01_text_load.py를 엽니다. 여러분은 코드를 치지 않지만, 읽을 수는 있어야 합니다. 이 강좌의 모든 장에 이 절이 있습니다.

# 0장 step01 기준 구현 — 단편소설 《The Verdict》를 내려받아 문자 수를 센다
import os
import urllib.request

URL = ("https://raw.githubusercontent.com/rickiepark/llm-from-scratch/"
       "main/ch02/01_main-chapter-code/the-verdict.txt")
FILE_NAME = "the-verdict.txt"


def download_if_missing(url=URL, file_name=FILE_NAME):
    """파일이 없을 때만 내려받는다 — 같은 파일을 두 번 받지 않는다."""
    if not os.path.exists(file_name):
        urllib.request.urlretrieve(url, file_name)
    return file_name


def load_text():
    """접점: 다음 스텝들이 import 해서 쓰는 함수.
    (필요하면 내려받은 뒤) 파일을 UTF-8 로 읽어 문자열 하나로 돌려준다."""
    download_if_missing()
    with open(FILE_NAME, "r", encoding="utf-8") as f:
        return f.read()


if __name__ == "__main__":
    raw_text = load_text()
    print("총 문자 수:", len(raw_text))
    print("앞 50자:", raw_text[:50])

읽는 요령은 사양의 세 범주를 코드에서 찾아보는 것입니다.

  • 접점: load_text()가 있고 문자열을 반환합니다. 맨 아래 if __name__ == "__main__":이 "직접 실행했을 때만"이라는 조건입니다 — 파이썬은 파일을 직접 실행하면 __name__이 "__main__"이 되고, 다른 파일이 import 하면 파일 이름이 됩니다. 그래서 이 블록 안의 print는 import 때 실행되지 않습니다.
  • 동작: URL, FILE_NAME, os.path.exists로 "있으면 재사용", encoding="utf-8", 출력 두 줄.
  • 제약: import는 os와 urllib.request뿐 — 표준 라이브러리입니다.
  • 구현(사양에 없던 것): 내려받기를 download_if_missing이라는 별도 함수로 뺀 것, urlretrieve를 쓴 것, raw_text라는 변수 이름, print("라벨:", 값) 형태. 여러분의 코드는 여기가 다를 수 있습니다.

0.7 검증 통과 후 — 내 코드와 기준 구현 비교

bash harness/verify.sh ch00 step01 --solution
diff workspace/ch00/step01_text_load.py solutions/ch00/step01_text_load.py

첫 명령은 기준 구현을 같은 검증기에 넣습니다 — 기대 출력이 기준 구현에서 나온 값이라는 것을 검증기 자신이 확인하는 셈입니다. 두 번째 명령은 두 파일의 다른 줄을 보여 줍니다. 차이가 많이 나와도 놀라지 마세요. 다음 표로 분류해 보면 됩니다.

같아야 하는 것 (사양이 못 박음)달라도 되는 것 (구현)
파일명, load_text()의 존재와 반환 타입함수를 하나로 합쳤는지 둘로 나눴는지
출력 두 줄의 형식과 값urlretrieve인지 urlopen + write인지
import 시 조용한지변수 이름, f-문자열 여부, 주석 문구
표준 라이브러리만 쓰는지상수를 대문자로 뺐는지 인라인으로 썼는지

실측 — 이 교안을 만들 때 hermes가 만든 코드

같은 사양으로 hermes(qwen3.8:27b)가 만든 코드는 기준 구현과 두 군데가 달랐습니다: ① 내려받기를 load_text() 안에 합쳤고(기준 구현은 download_if_missing()이라는 별도 함수), ② 변수 이름이 text였습니다(기준 구현은 raw_text). 출력은 기준 구현과 같은 print("총 문자 수:", len(text)) 꼴이었습니다 — 같은 선택을 할 수도, 다른 선택을 할 수도 있는 것이 구현입니다. 출력 두 줄과 load_text() 접점은 같았고, 검증은 첫 시도에 통과했습니다.

이 표의 왼쪽 열이 다르면 검증이 실패했을 것이고, 오른쪽 열이 다른 것은 정상입니다. 매 스텝 이 비교를 하다 보면 "사양에 무엇을 넣어야 하고 무엇을 빼도 되는지"에 대한 감이 생깁니다 — 그것이 1장부터 여러분이 직접 사양을 다듬을 때 쓰는 감각입니다.

용어 정리

용어원어(약어)한 줄 정의
사양specification (spec)무엇이 되어야 하는지를 정의한 글 — 이 강좌에서 프롬프트를 부르는 이름
접점interface파일명, 함수 이름, 입력·반환처럼 다른 코드가 붙는 지점. 사양이 못 박는다
동작behavior입력이 무엇이고 출력이 무엇인지. 검증은 이것만 본다
제약constraint허용 라이브러리, 시드, 금지 사항
구현implementation변수명·자료구조·알고리즘 — 사양이 맡기는 것, 에이전트의 몫
기준 구현reference implementation강좌 제작자가 직접 쓴 정답 코드 (solutions/). 기대 출력의 출처이자 교안이 설명하는 코드
바이브코딩vibe coding자연어로 요구하고 AI가 코드를 쓰는 개발 방식. 이 강좌는 그 "정확한" 형태를 연습한다
코딩 에이전트coding agent언어 모델에 파일 생성·명령 실행 능력을 붙인 프로그램 (hermes)
hermesHermes Agent이 강좌의 코딩 에이전트. hermes -z "<사양>" 원샷 모드로 쓴다
qwen3.8:27b—서버에서 서빙되는 273억 파라미터 오픈 언어 모델(Qwen 3.8 계열). 코드를 생각해 내는 쪽
Ollama—서버에서 언어 모델을 실행·서빙하는 프로그램
가상환경virtual environment (venv)프로젝트 전용으로 격리된 파이썬 패키지 공간 (.venv)
표준 라이브러리standard library파이썬에 기본으로 딸려 오는 모듈들 (os, urllib 등). 설치가 필요 없다
하네스harness에이전트가 일하기 전에 깔아 둔 환경 — 규칙 문서, 검증 스크립트, 디렉토리 규약
루프loop사양 → 생성 → 검증 → 비교 → 다음 스텝의 반복. 한 스텝 = 한 바퀴
메이커 / 체커maker / checker만드는 쪽(hermes)과 채점하는 쪽(verify.sh)의 분리
기대 출력expected output사양 파일의 expected 블록. 실행 출력에 이 줄들이 있으면 통과
퍼블릭 도메인public domain저작권이 만료되어 자유롭게 쓸 수 있는 저작물 (《The Verdict》)
UTF-8—전 세계 문자를 바이트로 적는 표준 부호화. 영문자는 1바이트, 한글은 3바이트
__main__ 가드main guardif __name__ == "__main__": — 직접 실행할 때만 도는 블록. import 시 조용하게 만든다

정리

  • 이 강좌에서 학생은 코드가 아니라 사양을 쓴다. 사양은 접점·동작·제약만 못 박고 구현은 에이전트에게 맡긴다.
  • 검증은 동작(입출력)으로만 한다. 기대 출력은 기준 구현으로 실측한 값이며, 어떤 올바른 구현이든 같은 값이 나오도록 설계된다.
  • 루프는 사양 → 생성 → 검증 → 기준 구현과 비교 → 다음 스텝. 실패하면 코드가 아니라 사양으로 돌아간다 (최대 3회).
  • 첫 루프에서 《The Verdict》를 내려받았고, 문자 수 20479를 확인했다. 이 텍스트와 숫자는 1장·2장에서 다시 만난다.

다음 장 예고

1장에서는 코드를 잠시 내려놓고, 방금 여러분에게 코드를 만들어 준 qwen3.8:27b가 어떤 원리로 동작하는지 — 대형 언어 모델이 무엇이고 "다음 단어 예측"이 왜 그 본질인지 — 를 배웁니다. 그리고 실습에서는 오늘 받은 소설로 가장 원시적인 언어 모델(바이그램)을 만들어 다음 단어를 예측하고, 그 모델로 문장을 생성해 봅니다.


Chapter 01 전 스텝 검증 완료 (3/3)

1장. 대형 언어 모델 이해하기

이 장에서 배우는 것

  • 대형 언어 모델(LLM)이 정확히 무엇이고, "다음 단어 예측"이 왜 그 본질인지
  • LLM을 만드는 두 단계 — 사전훈련과 미세조정
  • 트랜스포머 아키텍처의 큰 그림과 GPT의 위치
  • 이 강좌에서 우리가 무엇을, 어떤 순서로 만들게 되는지
  • 실습: 세는 것만으로 만드는 첫 언어 모델(바이그램) — 예측하고, 확률을 계산하고, 문장을 생성해 본다

들어가며 — 0장에서 1장으로

0장에서 여러분은 실습 환경을 구성하고 첫 루프를 돌려 봤습니다. 사양 하나를 hermes에게 전달했더니, GPU 서버의 qwen3.8:27b 모델이 파이썬 코드를 만들어 줬고, 그 코드는 단편소설 한 편을 내려받아 "총 문자 수: 20479"를 출력했습니다. 그리고 기준 구현과 나란히 놓고 보니 — 사양이 못 박은 접점과 출력은 같고, 못 박지 않은 변수 이름과 함수 구조는 달랐습니다.

잠깐 멈춰서 생각해 봅시다. 방금 여러분의 사양을 읽고 코드를 "생각해 낸" qwen3.8:27b — 그것이 바로 대형 언어 모델입니다. 여러분은 이미 LLM을 "사용"해 본 것입니다. 이 강좌의 목표는 이제 방향을 뒤집는 것입니다: 사용하는 쪽에서, 만드는 쪽으로. 그 첫걸음으로, 이번 장에서는 우리가 앞으로 7개 장에 걸쳐 만들 대상이 정확히 무엇인지 이해합니다. 건물을 짓기 전에 설계도 전체를 한 번 훑어보는 시간이라고 생각하면 됩니다. 그리고 장 끝에서는 0장에서 받은 소설로 가장 원시적인 언어 모델을 직접 만들어, 예측하고 생성하는 것이 무엇인지 손으로 만져 봅니다.

1.1 LLM이란 무엇인가

대형 언어 모델(Large Language Model, LLM)이라는 이름은 세 단어로 쪼개면 그대로 정의가 됩니다.

  • 언어(Language): 사람이 쓰는 자연어 텍스트를 다룹니다. 그래서 이 분야를 자연어 처리(Natural Language Processing, NLP)라고 부릅니다. "자연어"는 파이썬 같은 인공 언어(프로그래밍 언어)와 구분하기 위한 표현으로, 한국어·영어처럼 사람이 자연스럽게 쓰는 말을 뜻합니다.
  • 모델(Model): 여기서 모델은 "데이터에서 규칙을 스스로 찾아낸 수학 함수"를 뜻합니다. 사람이 규칙을 일일이 코딩해 넣는 것이 아니라, 대량의 예시를 보여 주면 규칙을 스스로 찾아냅니다. 이런 방법론이 머신러닝(Machine Learning, ML), 그중에서도 여러 층의 인공 신경망을 쓰는 방법이 딥러닝(Deep Learning, DL)입니다.
  • 대형(Large): 이 함수의 조절 가능한 숫자, 즉 파라미터(parameter)가 수억~수천억 개에 달한다는 뜻입니다. 파라미터는 라디오의 주파수 조절 나사에 비유할 수 있습니다. 나사를 이리저리 돌려 방송이 잘 잡히는 위치를 찾듯, 훈련(training)이란 수십억 개의 나사를 데이터에 맞게 자동으로 조금씩 돌려 가는 과정입니다.
flowchart TB
    subgraph AI["인공지능 (AI) — 사람의 지적 작업을 기계로"]
        subgraph ML["머신러닝 (ML) — 데이터에서 규칙을 학습"]
            subgraph DL["딥러닝 (DL) — 여러 층의 신경망"]
                LLM["LLM — 대량의 텍스트로
훈련된 초대형 신경망"] end end end
그림 1-1. AI ⊃ 머신러닝 ⊃ 딥러닝 ⊃ LLM의 포함 관계. 안쪽으로 갈수록 범위가 좁고 구체적이다.

전통 프로그래밍과 무엇이 다른가

머신러닝이 처음이라면 "규칙을 스스로 찾는다"는 말이 낯설 것입니다. 전통 프로그래밍과 나란히 놓고 보면 차이가 분명해집니다.

flowchart TB
    subgraph T["전통 프로그래밍"]
        direction LR
        R["사람이 규칙 작성
(if 문, 사전...)"] --> P1["프로그램"] D1["입력 데이터"] --> P1 P1 --> O1["출력"] end subgraph M["머신러닝"] direction LR D2["입력 데이터"] --> TR["훈련 과정"] A2["정답 예시"] --> TR TR --> P2["모델
(규칙을 담은 파라미터)"] P2 --> O2["새 입력에 대한 출력"] end O1 ~~~ D2
그림 1-2. 전통 프로그래밍은 사람이 규칙을 쓰고, 머신러닝은 데이터와 정답 예시에서 규칙을 뽑아낸다.

"스팸 메일 걸러내기"를 예로 들어 봅시다. 전통 방식은 사람이 "'대출'이라는 단어가 있으면 스팸"처럼 규칙을 하나하나 코딩합니다. 규칙에 없는 새 수법이 나오면 속수무책입니다. 머신러닝 방식은 스팸 메일 수만 통과 정상 메일 수만 통을 보여 주고 구분 규칙을 스스로 찾게 합니다. 언어는 규칙의 예외가 무한히 많은 영역이라, LLM 이전의 NLP도 이미 머신러닝에 크게 의존하고 있었습니다.

0장에서 본 사양과 구현의 관계도 이 그림과 닮았습니다. 여러분은 규칙(코드)을 쓰지 않고 "이런 입력에 이런 출력"이라는 예시(사양)를 줬고, 에이전트가 규칙을 찾아냈습니다. 다만 에이전트는 훈련이 아니라 추론으로 그 일을 합니다 — 이미 훈련된 LLM이 사양을 읽고 코드를 "예측"한 것입니다.

LLM의 본질 — 다음 단어 예측

그렇다면 LLM은 어떤 "규칙"을 배운 모델일까요? 놀랍게도 핵심 과제는 단 하나입니다.

LLM의 단 하나의 과제

지금까지의 텍스트가 주어졌을 때, 바로 다음에 올 단어를 예측하라.

예를 들어 "나는 아침에 일어나서 이를"까지 읽었다면, 다음 단어로 "닦았다"가 올 확률이 "먹었다"보다 훨씬 높다고 판단하는 것입니다. 이 단순한 과제를 인터넷 규모의 텍스트로 수없이 반복 연습하면, 문법·상식·추론 능력처럼 훨씬 복잡해 보이는 능력이 함께 생겨납니다. 다음 단어를 잘 맞히려면 결국 문맥을 "이해"해야 하기 때문입니다.

여러분이 0장에서 쓴 qwen3.8:27b가 파이썬 코드를 만들어 낸 것도 같은 원리입니다. "이런 사양 다음에 올 가장 그럴듯한 텍스트"를 한 토막씩 이어 붙인 결과가 완성된 코드였던 것입니다.

챗봇만이 아니다 — 생성형 AI와의 관계

LLM처럼 새로운 콘텐츠(글, 그림, 소리)를 만들어 내는 AI를 통틀어 생성형 AI(Generative AI)라고 부릅니다. LLM은 생성형 AI 중 "텍스트 생성"을 맡는 축이고, 이미지 생성 모델(예: 확산 모델 계열)과는 구조가 다릅니다. 이 강좌는 텍스트, 즉 LLM에 집중합니다.

1.2 LLM은 어디에 쓰일까

다음 단어 예측이라는 한 가지 능력이 얼마나 넓게 응용되는지 봅시다.

응용 분야예시"다음 단어 예측"과의 연결
기계 번역한국어 → 영어 번역"한국어 문장 + '영어로:'" 다음에 올 텍스트 생성
요약긴 보고서를 세 줄로"본문 + '요약:'" 다음에 올 텍스트 생성
질의응답지식 질문에 답하기"질문" 다음에 올 가장 그럴듯한 답 생성
감성 분석리뷰가 긍정인지 부정인지"리뷰 + '감정:'" 다음 단어로 긍정/부정 예측
코드 생성0장에서 hermes가 한 일사양 다음에 올 그럴듯한 코드 생성
대화챗봇, 비서대화 기록 다음에 올 응답 생성

포인트는 이것입니다: 과제마다 다른 모델을 만드는 것이 아니라, 하나의 LLM이 입력을 어떻게 주느냐에 따라 전부 수행한다는 것. LLM 이전의 NLP는 번역 모델, 요약 모델, 분류 모델을 따로따로 만들었습니다. LLM은 이 판을 바꿨고, 그래서 하나의 모델을 여러 과제의 기반으로 쓴다는 의미에서 파운데이션 모델(foundation model)이라고도 부릅니다.

1.3 LLM을 만들고 사용하는 단계

LLM 구축은 크게 두 단계로 나뉩니다. 이 두 단어는 강좌 끝까지 계속 나오므로 여기서 확실히 잡고 갑시다.

flowchart TB
    subgraph S1["1단계: 사전훈련 (pretraining)"]
        direction LR
        D["방대한 원시 텍스트
(레이블 없음)"] --> P["다음 단어 예측을
반복 연습"] P --> B["기반 모델
(base model)"] end subgraph S2["2단계: 미세조정 (fine-tuning)"] direction LR B2["기반 모델"] --> F["작지만 정제된
레이블 데이터로 추가 훈련"] F --> M1["분류 모델
(예: 스팸 판별)"] F --> M2["지시 따르기 모델
(예: 챗봇)"] end B --> B2
그림 1-3. LLM 구축의 두 단계. 사전훈련으로 언어의 일반 능력을 얻고, 미세조정으로 특정 과제에 맞춘다.
  • 사전훈련(pretraining): 레이블(정답 표시)이 없는 방대한 원시 텍스트로 "다음 단어 예측"을 연습하는 단계입니다. 사람이 정답을 달아 주지 않아도 됩니다 — 텍스트의 다음 단어 자체가 정답이니까요. 이렇게 데이터가 스스로 정답을 제공하는 학습 방식을 자기지도학습(self-supervised learning)이라고 합니다. 수능으로 비유하면, 교과서 전체를 통째로 읽으며 기본기를 쌓는 과정입니다.
  • 미세조정(fine-tuning): 사전훈련된 모델을 출발점 삼아, 작지만 목적이 분명한 데이터로 추가 훈련하는 단계입니다. 스팸/정상 레이블이 달린 메일 데이터로 훈련하면 분류기가 되고(6장), "지시 → 좋은 응답" 쌍으로 훈련하면 챗봇처럼 지시를 따르는 모델이 됩니다(7장). 기본기를 쌓은 학생이 특정 과목을 집중 대비하는 과정에 해당합니다.

왜 밑바닥부터 만들어 보는가

"이미 훌륭한 LLM이 많은데 왜 직접 만들지?"라는 질문이 나올 때입니다. 세 가지 이유가 있습니다.

  1. 이해: 내부를 한 번 만들어 본 사람과 API만 써 본 사람은, 모델이 이상하게 동작할 때 원인을 짚는 깊이가 다릅니다.
  2. 통제: 사내 데이터처럼 외부로 내보낼 수 없는 데이터로 자체 모델을 만들거나, 특정 도메인(법률, 의료)에 맞춘 소형 모델을 만드는 실무 수요가 실제로 큽니다. 작은 맞춤 모델이 거대 범용 모델을 이기는 과제도 많습니다.
  3. 규모의 마법 이해: 우리가 만들 모델은 교육용으로 작지만, 구조는 대형 모델과 동일합니다. 작은 모델을 완전히 이해하면 큰 모델은 "같은 구조를 더 크게"일 뿐입니다.

1.4 트랜스포머 아키텍처 소개

현대 LLM의 뼈대는 2017년 논문 "Attention Is All You Need"에서 제안된 트랜스포머(transformer)라는 신경망 구조입니다. 원래는 기계 번역(영어→독일어 등)을 위해 만들어졌습니다. 구조를 크게 보면 두 부분입니다.

flowchart TB
    IN["입력 문장 (예: 영어)"] --> ENC["인코더 (encoder)
입력 전체를 읽고 의미를 숫자로 압축"] ENC --> V["문맥 벡터"] V --> DEC["디코더 (decoder)
문맥을 참고하며 출력을 하나씩 생성"] DEC --> OUT["출력 문장 (예: 독일어)"]
그림 1-4. 원조 트랜스포머의 인코더-디코더 구조. 번역기에서 인코더는 '읽기', 디코더는 '쓰기' 담당이다.

트랜스포머의 심장은 어텐션(attention) 메커니즘입니다. 지금은 한 줄만 기억하면 됩니다: "다음 단어를 만들 때, 앞에 나온 단어들 중 어디에 주목할지를 모델이 스스로 계산하는 장치." 예컨대 "그 사과는 빨갛고 맛있어서 나는 그것을 ___"에서 "그것"이 "사과"를 가리킨다는 것을 붙잡아 내는 능력입니다. 3장 전체를 이 어텐션을 직접 만드는 데 씁니다. 이번 장 실습의 마지막 스텝에서 "직전 단어 하나만 보는" 모델이 어떻게 무너지는지 직접 보게 되는데, 그것이 어텐션이 필요한 이유의 첫 체험입니다.

두 갈래 후손 — BERT와 GPT

트랜스포머가 나온 뒤, 인코더와 디코더를 각각 떼어 특화한 두 계열이 등장했습니다.

flowchart TB
    T["트랜스포머 (2017)
인코더 + 디코더"] --> B["BERT 계열 — 인코더만
과제: 가려진 단어 맞히기
강점: 이해·분류"] T --> G["GPT 계열 — 디코더만
과제: 다음 단어 예측
강점: 생성"]
그림 1-5. BERT는 빈칸 채우기(양방향 이해), GPT는 다음 단어 예측(단방향 생성)에 특화됐다. 이 강좌에서 만드는 것은 GPT 계열이다.
  • BERT(Bidirectional Encoder Representations from Transformers): 인코더 계열. 문장 중간의 가려진 단어를 앞뒤 문맥을 모두 보고 맞히는 방식으로 훈련합니다. 이해·분류 과제에 강합니다.
  • GPT(Generative Pretrained Transformer): 디코더 계열. 왼쪽에서 오른쪽으로 다음 단어를 예측하며 훈련합니다. 생성 과제에 강하고, 현대 챗봇의 기반입니다.

용어 주의

"트랜스포머"라는 이름은 다른 곳에서도 쓰입니다 — 변압기, 영화 제목, 그리고 딥러닝 라이브러리 이름(허깅페이스의 transformers)까지. 이 강좌에서 트랜스포머는 언제나 신경망 아키텍처를 뜻합니다.

1.5 얼마나 많은 데이터로 배우는가

"대형"의 감을 잡기 위해 실제 숫자를 봅시다. GPT-3(2020, ChatGPT 초기 버전의 기반 모델)의 사전훈련 데이터는 공개된 논문 기준으로 다음과 같습니다.

데이터 출처설명규모 (토큰 수, 대략)
CommonCrawl (필터링)웹 크롤링 텍스트4,100억
WebText2품질 좋은 웹 문서190억
Books1 / Books2도서 말뭉치670억
Wikipedia백과사전30억

여기서 새 용어 두 개가 나왔습니다.

  • 말뭉치(corpus): 모델 훈련에 쓰이는 대량의 텍스트 모음을 부르는 NLP 용어입니다. 이번 장 실습의 말뭉치는 0장에서 받은 소설 한 편 — 3,634단어 — 입니다. GPT-3의 말뭉치와 비교하면 1억 분의 1도 안 되지만, 언어 모델이 무엇인지 보여 주기에는 충분합니다.
  • 토큰(token): 모델이 텍스트를 처리하는 최소 단위입니다. 대략 "단어 조각"이라고 생각하면 됩니다 — 짧은 단어는 한 토큰, 긴 단어는 여러 토큰으로 쪼개집니다. 왜 글자도 단어도 아닌 "조각"인지는 2장에서 직접 만들며 확인합니다. 이번 장에서는 일단 "공백으로 나눈 단어"를 토큰으로 씁니다.

GPT-3는 위 데이터에서 3,000억 토큰을 뽑아 훈련했고, 파라미터는 1,750억 개입니다. 훈련 비용은 수백만 달러 규모로 추정됩니다. 우리가 만들 모델(GPT-2 소형, 1.24억 파라미터)은 이보다 천 배 이상 작지만, 구조는 동일합니다. 그리고 5장에서 배우겠지만, 다행히 직접 수백만 달러를 쓸 필요 없이 공개된 사전훈련 가중치를 받아 쓸 수 있습니다.

1.6 GPT 아키텍처 자세히 보기

GPT는 원조 트랜스포머에서 인코더를 떼어 내고 디코더만 쌓아 올린 구조입니다. 번역기처럼 "읽는 부분"과 "쓰는 부분"을 나누는 대신, "지금까지 쓴 것을 보고 다음을 쓰는" 한 가지 동작만 반복합니다. 이렇게 이전 출력이 다음 입력이 되는 방식을 자기회귀(autoregressive)라고 합니다.

flowchart TB
    A["나는 아침에"] --> M1["GPT"] --> B["예측: 일어나서"]
    B --> C["나는 아침에 일어나서"] --> M2["GPT"] --> D["예측: 이를"]
    D --> E["나는 아침에 일어나서 이를"] --> M3["GPT"] --> F["예측: 닦았다"]
그림 1-6. 자기회귀 생성. 한 번에 한 토큰씩 예측하고, 예측 결과를 입력에 붙여 다시 예측한다. 그림의 GPT 세 개는 같은 모델 하나가 반복 실행되는 것이다.

챗봇이 답변을 한 글자씩 "타이핑하듯" 출력하는 것을 본 적 있을 것입니다. 그것은 연출이 아니라, 실제로 모델이 토큰을 하나씩 생성하고 있기 때문입니다. 이번 장의 step03에서 이 그림의 절차를 그대로 — 다만 GPT 대신 우리가 만든 바이그램 모델로 — 돌려 봅니다.

한 가지 흥미로운 사실: GPT의 사전훈련 과제는 "다음 단어 예측" 하나뿐이었는데, 훈련을 마친 모델은 번역, 요약, 간단한 추론처럼 명시적으로 가르친 적 없는 과제까지 해냅니다. 이렇게 훈련 목표에 없던 능력이 규모와 함께 나타나는 현상을 창발적 행동(emergent behavior)이라고 부릅니다. 예시 몇 개만 보여 주면 새 과제를 수행하는 퓨샷(few-shot) 능력, 예시 없이 지시만으로 수행하는 제로샷(zero-shot) 능력이 대표적입니다.

1.7 이 강좌에서 우리가 만드는 것

이제 전체 지도를 펼칠 차례입니다. 이 강좌는 세 스테이지로 진행됩니다.

flowchart TB
    subgraph ST1["스테이지 1 — 재료 준비와 조립 (2~4장)"]
        C2["2장 텍스트 데이터
토큰화 · 임베딩"] --> C3["3장 어텐션
트랜스포머의 심장"] C3 --> C4["4장 GPT 모델 조립"] end subgraph ST2["스테이지 2 — 사전훈련 (5장)"] C5["5장 훈련 루프 구현
공개 가중치 적재"] end subgraph ST3["스테이지 3 — 미세조정 (6~7장)"] C6["6장 분류 미세조정
스팸 판별기"] C7["7장 지시 미세조정
지시를 따르는 모델"] end ST1 --> ST2 --> ST3
그림 1-7. 강좌 로드맵. 1장(현재)은 지도를 읽는 단계이고, 2장부터 실제 구현이 시작된다.

그리고 이 모든 구현을 우리는 사양으로 합니다. 개념을 교안에서 이해하고 → 접점·동작·제약을 자연어로 정확히 쓰고 → hermes(qwen3.8:27b)가 만든 코드를 실행·검증하고 → 기준 구현과 비교하는 루프(0장에서 경험한 그것)를 챕터마다 돌립니다. 여러분이 기르는 것은 타이핑 실력이 아니라 요구를 정확히 정의하고 결과를 검증하는 능력입니다.

손계산 — 다음 단어 예측을 확률로 써 보기

"다음 단어 예측"을 수식으로 한 번만 적어 봅시다. 지금까지 나온 단어들이 w1, w2, …, wt−1일 때, 다음 단어 wt가 특정 단어일 확률:

P(wt | w1, w2, …, wt−1)

기호 P(A | B)는 "B가 주어졌을 때 A일 확률(조건부 확률)"이라고 읽습니다. LLM은 어휘에 있는 모든 단어 후보에 대해 이 확률을 계산하고, 높은 쪽을 선택합니다.

손계산 ① 세 문장으로 확률 구하기

거창해 보이지만, 아주 작은 말뭉치로 직접 계산할 수 있습니다. 말뭉치가 다음 세 문장뿐이라고 합시다.

나는 밥을 먹었다
나는 학교에 갔다
나는 밥을 지었다

"나는" 다음 단어의 확률을 세어 봅시다. "나는"은 3번 나왔고, 그 뒤에는 "밥을"이 2번, "학교에"가 1번 왔습니다.

P(밥을 | 나는) = 23 ≈ 0.67    P(학교에 | 나는) = 13 ≈ 0.33

따라서 이 말뭉치로 배운 모델에게 "나는"을 주면 "밥을"을 예측합니다. 방금 우리가 만든 것은 "직전 단어 하나만 보고 세는" 가장 원시적인 언어 모델로, 바이그램(bigram, 연속한 단어 2개 묶음) 모델이라고 합니다. 모델의 "파라미터"는 세어 둔 횟수 표가 전부입니다.

flowchart LR
    subgraph 모델["바이그램 모델 = 횟수 표"]
        N["나는"] -->|"2"| B["밥을"]
        N -->|"1"| S["학교에"]
        B -->|"1"| M["먹었다"]
        B -->|"1"| J["지었다"]
        S -->|"1"| G["갔다"]
    end
그림 1-8. 세 문장 말뭉치의 바이그램 모델. 화살표의 숫자가 "앞 단어 뒤에 뒷 단어가 온 횟수"다. 예측이란 어떤 단어에서 출발하는 화살표 중 숫자가 가장 큰 것을 고르는 일이다.

손계산 ② 생성해 보기 — 그리고 동률

이번에는 1.6절의 자기회귀 절차를 이 표로 돌려 봅시다. "나는"에서 출발해 가장 큰 화살표만 따라갑니다 (이렇게 매 단계 최고 후보만 고르는 것을 탐욕(greedy) 생성이라고 합니다).

  1. "나는" → 밥을(2) vs 학교에(1) → 밥을
  2. "밥을" → 먹었다(1) vs 지었다(1) → 동률!

두 번째 단계에서 막힙니다. "밥을" 뒤에는 "먹었다"와 "지었다"가 한 번씩 왔으니 확률이 ½로 같습니다. 데이터가 적으면 예측이 갈팡질팡한다는 것이 손계산으로 확인됩니다. 그런데 이 동률(tie)은 이번 장 실습에서 실제로 중요한 문제입니다. 프로그램은 어떻게든 하나를 골라야 하는데, "먼저 등장한 것"을 고를지 "알파벳 순 첫 것"을 고를지는 구현마다 다를 수 있습니다. 그러면 같은 사양으로 만든 두 코드가 다른 문장을 생성하게 되고, 검증이 흔들립니다. 그래서 step02의 사양은 동률 규칙을 명시하고("먼저 등장한 것"), step03의 사양은 동률이 한 번도 없는 시작 단어를 고릅니다. 0장에서 배운 "어떤 올바른 구현이든 같은 값이 나오게 계약을 설계한다"의 실전 사례입니다.

손계산 ③ 진짜 데이터로 — P(the | of)

실습의 말뭉치(《The Verdict》, 3,634단어)에서 "of"는 앞 단어로 95번 나오고, 그중 21번은 뒤에 "the"가 옵니다. 그러니

P(the | of) = 2195 ≈ 0.2211

step02의 마지막 출력 줄이 정확히 이 값입니다. 21/95는 세 문장 말뭉치의 2/3과 같은 계산이고, 다른 점은 데이터가 천 배쯤 많다는 것뿐입니다. 실제 LLM과의 차이는 두 가지입니다: ① 직전 단어 하나가 아니라 긴 문맥 전체를 보고(그래서 어텐션이 필요합니다), ② 단순히 세는 대신 신경망으로 일반화합니다(그래서 본 적 없는 문장에도 대응합니다). 이 두 차이를 메우는 것이 2~4장의 내용 전부라고 해도 과언이 아닙니다.

실습 — 루프 세 바퀴

이번 장의 실습은 세 스텝입니다. 0장에서 배운 루프 그대로, 사양 실행 → 검증 → 기준 구현과 비교 순서로 하나씩 진행하세요. 세 스텝은 이어져 있습니다 — step02가 만든 접점을 step03이 import 합니다.

flowchart LR
    S1["step01
torch 점검
텐서 하나"] ~~~ S2["step02
바이그램 모델
load_words · build_bigram_model · predict_next"] S2 -->|"접점 3개 import"| S3["step03
텍스트 생성
of → the → picture → …"]
그림 1-9. 1장 실습의 흐름. step01은 독립된 환경 점검이고, step02의 접점 세 개를 step03이 가져다 쓴다.

step01. 파이토치와 첫 만남 (부록 A 맛보기)

2장부터 우리가 만들 모든 코드는 파이토치(PyTorch)라는 딥러닝 라이브러리 위에서 동작합니다. 파이토치의 핵심 재료는 텐서(tensor) — 숫자를 담는 다차원 상자입니다(숫자 하나 = 0차원, 리스트 = 1차원, 표 = 2차원, …). 본격 학습은 부록 A에서 하고, 여기서는 설치 확인을 겸해 텐서 하나를 만들어 봅니다.

무엇을 — 파이토치를 불러와 2×2 텐서 하나를 만들고 모양(shape)과 원소 합을 출력하는 스크립트
왜 — setup.sh가 설치한 실습 환경(torch)이 정상 동작하는지 확인 + 텐서를 처음 만져 보기 위해
맥락 — 1장은 개념 장이지만, 2장부터 모든 구현이 파이토치 위에서 돌므로 여기서 미리 점검한다 (부록 A 맛보기)
기대 결과 — 텐서 모양: torch.Size([2, 2]), 텐서 원소 합: 10 — 모양 확인 습관의 첫 연습
hermes에게 전달되는 사양 (prompts/ch01/step01-torch-hello.md)
step01_torch_hello.py 라는 파이썬 파일을 만들어줘.

만들 것: 파이토치가 설치되어 있는지 확인하고, 텐서 하나를 만들어 모양과 원소 합을 보여 주는 스크립트.

입력:
- 2행 2열의 정수 텐서 [[1, 2], [3, 4]] (코드 안에 직접 적는다).

출력 (실행하면 다음 네 줄을 정확히 이 형식으로, 이 순서로 출력한다):
- `파이토치 버전: ` 뒤에 설치된 파이토치의 버전 문자열
- `텐서 모양: ` 뒤에 그 텐서의 모양 — torch.Size([2, 2]) 처럼 파이토치가 모양을 표기하는 그대로
- `텐서 원소 합: ` 뒤에 모든 원소의 합을 **파이썬 정수**로 (10 이어야 하고 tensor(10) 이면 안 된다)
- `파이토치 준비 완료`

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(함수 정의, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.

사양을 읽어 보세요. 입력(텐서 값)과 출력 네 줄의 형식만 있습니다. "원소 합을 파이썬 정수로"는 출력 형식의 일부라 못 박았지만, .item()을 쓸지 int()를 쓸지는 없습니다. 파이토치 버전 줄은 설치 시점마다 달라지므로 기대 출력에서 뺐습니다 — 흔들리는 값은 검증하지 않는다는 계약 설계 규칙입니다.

bash harness/run_loop.sh ch01 prompts/ch01/step01-torch-hello.md
bash harness/verify.sh ch01 step01
--- 검증 결과 (실측) ---
파이토치 버전: 2.13.0+cu130
텐서 모양: torch.Size([2, 2])
텐서 원소 합: 10
파이토치 준비 완료
[OK] 3줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch01/step01_torch_hello.py):

import torch

print("파이토치 버전:", torch.__version__)

# 텐서(tensor)는 숫자를 담는 다차원 상자다. 이것은 2행 2열짜리 2차원 텐서.
t = torch.tensor([[1, 2], [3, 4]])

print("텐서 모양:", t.shape)          # 모양(shape)을 확인하는 습관 — 딥러닝 디버깅의 절반
print("텐서 원소 합:", int(t.sum()))  # 사양: 합은 파이썬 정수로 (tensor(10) 이 아니라 10)
print("파이토치 준비 완료")

t.sum()은 0차원 텐서 tensor(10)을 돌려주고, int()로 감싸야 파이썬 정수 10이 됩니다. 여러분의 코드는 t.sum().item()을 썼을 수 있습니다 — 같은 동작, 다른 구현입니다. t.shape 대신 t.size()를 썼어도 출력은 같습니다. (실측: 이 교안을 만들 때 hermes(qwen3.8:27b)는 정확히 그렇게 했습니다 — t.sum().item(). 변수명과 print("라벨:", 값) 꼴은 기준 구현과 같았고, 출력 네 줄은 동일했습니다.)

step02. 세는 것만으로 다음 단어 예측하기 (바이그램 모델)

손계산 ①의 바이그램 모델을 그대로 프로그램으로 만들어, 0장에서 받은 단편소설 《The Verdict》 전체(3,634단어)에 적용합니다. "of" 다음에 올 단어로 무엇이 예측되는지 확인해 보세요 — 여러분의 첫 언어 모델입니다.

무엇을 — "어떤 단어 뒤에 어떤 단어가 몇 번 왔는지"를 세어 다음 단어를 예측하는 바이그램 언어 모델
왜 — 언어 모델의 본질(말뭉치의 통계 학습)을 세 줄짜리 손계산이 아닌 실제 소설 전체로 체험하기 위해
맥락 — 교안 손계산 절의 확장판. 0장에서 받은 텍스트를 다시 쓰며, 다음 스텝(step03)이 이 모델로 문장을 생성한다
기대 결과 — 'of' 다음에 가장 자주 나온 단어: the (21번) — 첫 언어 모델이 데이터에서 스스로 찾아낸 패턴
hermes에게 전달되는 사양 (prompts/ch01/step02-bigram-model.md)
step02_bigram_model.py 라는 파이썬 파일을 만들어줘.

만들 것: 소설 텍스트에서 "어떤 단어 뒤에 어떤 단어가 몇 번 왔는지"를 세어 두었다가,
단어 하나를 주면 그 다음에 올 단어를 예측하는 바이그램 언어 모델.

입력:
- 아래 URL의 텍스트 파일. 현재 디렉토리에 the-verdict.txt 로 저장하고, 이미 있으면 다시 내려받지 않는다.
  https://raw.githubusercontent.com/rickiepark/llm-from-scratch/main/ch02/01_main-chapter-code/the-verdict.txt
- 단어 분리 규칙: 텍스트를 **공백(스페이스·줄바꿈) 기준으로만** 나눈다. 대소문자를 바꾸거나 구두점을
  떼어 내지 않는다 — the 와 The, end 와 end. 는 서로 다른 단어로 센다.

접점 (다음 스텝이 import 해서 쓰므로 이름과 입력·반환을 정확히 지킨다):
- load_words() — 인자 없음. (파일이 없으면 내려받은 뒤) 파일을 UTF-8 로 읽고 위 규칙으로 나눈 단어 리스트를 반환한다.
- build_bigram_model(words) — 단어 리스트를 받아, "앞 단어 → 뒤 단어 → 횟수"를 조회할 수 있는 모델 객체를 반환한다.
  객체의 형태는 자유다.
- predict_next(model, word) — 모델과 앞 단어 하나를 받아 (가장 자주 뒤따른 단어, 그 횟수) 튜플을 반환한다.
  횟수가 같은 후보가 여럿이면 말뭉치에서 먼저 등장한 것을 고른다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다. 출력은 직접 실행했을 때만.

출력 (직접 실행하면 다음 일곱 줄을 정확히 이 형식으로 출력한다):
- `전체 단어 수: ` 뒤에 단어 리스트의 길이
- `서로 다른 단어 수: ` 뒤에 중복을 뺀 단어의 개수
- `가장 흔한 단어: the (162번)` 형식으로, 가장 많이 나온 단어와 횟수
- `가장 흔한 바이그램: of the (21번)` 형식으로, 가장 많이 나온 연속 두 단어와 횟수
- `'I' 다음에 가장 자주 나온 단어: had (12번)` 형식으로, I 뒤에 가장 자주 온 단어와 횟수 (predict_next 를 사용)
- 같은 형식으로 of 에 대해서도 한 줄
- `'of' 다음에 'the'가 올 확률: 21/95 = 0.2211` 형식으로 — of 뒤에 the 가 온 횟수 / of 가 앞 단어로 나온 총 횟수,
  그리고 그 나눗셈 결과를 소수점 넷째 자리까지 반올림한 값
  (위 예시의 숫자는 형식을 보여 주기 위한 것이다 — 실제 값은 데이터에서 계산한다)

지켜야 할 것:
- 표준 라이브러리만 사용한다. 외부 패키지 설치 금지.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.

이 사양에서 눈여겨볼 곳 세 군데:

  • 분리 규칙이 동작이다. "공백 기준, 대소문자·구두점 유지"가 없으면 어떤 에이전트는 소문자로 바꾸고 어떤 에이전트는 마침표를 뗄 것이고, 그러면 단어 수부터 달라집니다. 규칙을 적는 것은 구현을 지시하는 것이 아니라 "무엇을 단어로 볼 것인가"라는 요구를 정의하는 것입니다.
  • 접점의 형태는 자유다. build_bigram_model이 돌려주는 "모델 객체의 형태는 자유"라고 했습니다. 딕셔너리든 Counter든 클래스든, predict_next가 읽을 수만 있으면 됩니다. 다음 스텝은 이 두 함수만 통해 모델을 쓰기 때문에 안이 어떻게 생겼는지 몰라도 됩니다.
  • 동률 규칙을 적었다. 손계산 ②에서 본 문제입니다. 실측해 보면 이 일곱 출력 값에는 동률이 없지만(the 162 대 다음 I 100, I→had 12 대 다음 8 …), predict_next는 step03이 다른 단어에도 쓸 함수이므로 규칙을 정해 둡니다.
bash harness/run_loop.sh ch01 prompts/ch01/step02-bigram-model.md
bash harness/verify.sh ch01 step02
--- 검증 결과 (실측) ---
전체 단어 수: 3634
서로 다른 단어 수: 1485
가장 흔한 단어: the (162번)
가장 흔한 바이그램: of the (21번)
'I' 다음에 가장 자주 나온 단어: had (12번)
'of' 다음에 가장 자주 나온 단어: the (21번)
'of' 다음에 'the'가 올 확률: 21/95 = 0.2211
[OK] 7줄 전부 일치   →   ✅ 검증 통과

3,634개 단어 중 서로 다른 것은 1,485개 — 한 단어가 평균 2.4번쯤 반복된다는 뜻입니다. "the"가 162번으로 가장 흔하고, "of the"가 21번으로 가장 흔한 두 단어 조합입니다. 영어 텍스트라면 어디서나 비슷한 결과가 나옵니다. 여러분의 첫 언어 모델이 방금 그것을 데이터에서 스스로 찾아냈습니다.

기준 구현 읽기 (solutions/ch01/step02_bigram_model.py, 핵심 부분):

def load_words():
    if not os.path.exists(FILE_NAME):
        urllib.request.urlretrieve(URL, FILE_NAME)
    with open(FILE_NAME, "r", encoding="utf-8") as f:
        return f.read().split()          # 공백 기준 분리 — 대소문자·구두점은 그대로


def build_bigram_model(words):
    model = defaultdict(Counter)         # {앞 단어: Counter(뒤 단어 → 횟수)}
    for prev_word, next_word in zip(words, words[1:]):
        model[prev_word][next_word] += 1
    return model


def predict_next(model, word):
    next_word, count = model[word].most_common(1)[0]   # 동률이면 먼저 등장한 것 (삽입 순서 유지)
    return next_word, count
  • f.read().split() — 인자 없는 split()은 스페이스·탭·줄바꿈을 모두 공백으로 보고 나눕니다. 사양의 분리 규칙이 이 한 줄입니다.
  • zip(words, words[1:]) — 리스트와 "한 칸 민 리스트"를 짝지으면 연속한 두 단어 쌍이 나옵니다. 3,634단어에서 쌍은 3,633개입니다.
  • defaultdict(Counter) — 그림 1-8의 화살표 표를 그대로 담은 자료구조입니다. model["of"]가 "of 뒤에 온 단어들의 횟수표"입니다. 여러분의 코드는 딕셔너리의 딕셔너리, 또는 쌍을 키로 하는 Counter 하나를 썼을 수 있습니다 — 접점만 같으면 정답입니다.
  • most_common(1) — 횟수가 가장 큰 항목 하나. 파이썬의 Counter는 횟수가 같으면 먼저 넣은 것을 앞에 두므로 사양의 동률 규칙과 일치합니다. max(counter, key=counter.get)으로 써도 같은 규칙이 됩니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 모델을 {앞 단어: {뒤 단어: [횟수, 첫 등장 순번]}} 꼴의 딕셔너리로 만들고, 동률 규칙을 max(후보, key=(횟수, -첫 등장 순번))으로 명시적으로 구현했습니다 — 기준 구현이 most_common의 삽입 순서에 기대는 것과 정반대의 선택입니다. 가장 흔한 단어와 바이그램도 첫 등장 위치까지 추적하는 루프를 직접 썼습니다. 기준 구현과 같은 줄이 거의 없는데도 접점 세 개와 출력 일곱 줄은 완전히 같았습니다.

step03. 바이그램 모델로 텍스트 생성하기 (자기회귀 맛보기)

1.6절의 자기회귀 절차를 step02의 모델로 그대로 돌립니다: "of"에서 출발해 predict_next를 10번 되풀이합니다.

무엇을 — step02 의 바이그램 모델로 시작 단어 하나에서 출발해 단어를 하나씩 이어 붙이는 텍스트 생성기
왜 — 1.6절의 자기회귀(예측한 단어가 다음 입력이 된다)를 직접 돌려 보고, "직전 1단어만 보는 모델"의 한계를 눈으로 확인하기 위해
맥락 — step02 의 접점 세 개를 import 해서 쓴다. 이 스텝의 "돌고 도는 문장"이 3장 어텐션(긴 문맥)의 출발점이 된다
기대 결과 — 생성 결과: of the picture above the picture above the picture above the — 세 단어짜리 고리에 갇힌 여러분의 첫 생성 모델
hermes에게 전달되는 사양 (prompts/ch01/step03-bigram-generate.md)
step03_bigram_generate.py 라는 파이썬 파일을 만들어줘.

만들 것: 직전 스텝의 바이그램 모델로, 시작 단어 하나에서 출발해 단어를 하나씩 이어 붙이는 텍스트 생성기.

접점 (직전 스텝 재사용):
- 같은 디렉토리의 step02_bigram_model.py 에서 load_words(), build_bigram_model(words),
  predict_next(model, word) 를 import 해서 쓴다. 단어 나누기, 모델 만들기, 다음 단어 예측을 새로 구현하지 않는다.

동작:
- 시작 단어는 of, 이어 붙일 단어 수는 10개다 (둘 다 코드 안에 직접 적는다).
- 현재까지의 마지막 단어를 predict_next 에 넣어 다음 단어를 얻고, 그 단어를 뒤에 붙인다. 이것을 10번 반복한다.
  매번 **직전 단어 하나만** 본다.

출력 (실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- `시작 단어: of`
- `생성 단어 수: 10`
- 반복 한 번마다 한 줄씩, 모두 10줄: `단계 1: 'of' 다음 → 'the' (21번)` 형식으로 — 단계 번호(1부터),
  직전 단어, 예측된 단어, 그 단어가 직전 단어 뒤에 나온 횟수 (위 예시의 단어와 숫자는 형식을 보여 주는 것이다)
- 마지막에 `생성 결과: ` 뒤에 시작 단어부터 마지막 단어까지 11개 단어를 공백 하나로 이어 붙인 문장

지켜야 할 것:
- 표준 라이브러리만 사용한다. 외부 패키지 설치 금지.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.

이 사양에는 구현이 한 줄도 없습니다. 접점(직전 스텝의 함수 세 개), 동작(시작 단어, 반복 횟수, "직전 단어 하나만 본다", 출력 형식), 제약이 전부입니다. 생성 루프를 함수로 뺄지 그냥 쓸지, 단어를 리스트에 모을지 문자열에 이어 붙일지는 에이전트가 정합니다.

bash harness/run_loop.sh ch01 prompts/ch01/step03-bigram-generate.md
bash harness/verify.sh ch01 step03
--- 검증 결과 (실측) ---
시작 단어: of
생성 단어 수: 10
단계 1: 'of' 다음 → 'the' (21번)
단계 2: 'the' 다음 → 'picture' (5번)
단계 3: 'picture' 다음 → 'above' (2번)
단계 4: 'above' 다음 → 'the' (3번)
단계 5: 'the' 다음 → 'picture' (5번)
단계 6: 'picture' 다음 → 'above' (2번)
단계 7: 'above' 다음 → 'the' (3번)
단계 8: 'the' 다음 → 'picture' (5번)
단계 9: 'picture' 다음 → 'above' (2번)
단계 10: 'above' 다음 → 'the' (3번)
생성 결과: of the picture above the picture above the picture above the
[OK] 8줄 전부 일치   →   ✅ 검증 통과

결과를 보세요. 네 번째 단어부터 the → picture → above → the → picture → above …가 끝없이 돕니다.

flowchart LR
    OF["of"] -->|"21"| THE["the"]
    THE -->|"5"| PIC["picture"]
    PIC -->|"2"| ABV["above"]
    ABV -->|"3"| THE
그림 1-10. step03이 갇힌 고리. "the" 뒤에 가장 잦은 단어는 언제나 "picture"이고, "above" 뒤는 언제나 "the"다. 직전 단어 하나만 보는 모델은 문장이 어디까지 왔는지 모르기 때문에 같은 자리를 맴돈다.

왜 이렇게 될까요? 이 모델은 직전 단어 하나밖에 보지 못합니다. "the" 뒤에 가장 자주 온 단어는 소설 전체에서 "picture"(5번)이고, 그 사실은 문장이 "of the"로 시작했든 "above the"로 왔든 변하지 않습니다. 문맥이 1단어뿐이라 "방금 그 말을 했다"는 것조차 모릅니다. 실제 LLM이 문맥 전체를 보아야 하는 이유 — 그리고 3장에서 어텐션을 만드는 이유 — 를 이 열한 단어가 보여 줍니다.

또 하나. 이 경로에는 동률이 한 번도 없습니다(of→the 21 대 5, the→picture 5 대 3, picture→above 2 대 1, above→the 3 대 1). 그래서 어떤 구현이든 같은 문장이 나옵니다. 사양이 시작 단어로 "of"를 고른 이유가 바로 이것입니다 — "I"에서 출발하면 두 번째 단계(been 뒤)에서 동률이 나와 구현에 따라 문장이 갈립니다. 궁금하면 사양의 시작 단어를 "I"로 바꿔 두 번 생성시켜 보세요. 검증은 실패하겠지만, 계약 설계가 왜 필요한지 몸으로 알게 됩니다.

기준 구현 읽기 (solutions/ch01/step03_bigram_generate.py):

from step02_bigram_model import load_words, build_bigram_model, predict_next

START_WORD = "of"
NUM_WORDS = 10


def generate(model, start_word, num_words):
    sequence = [start_word]
    for step in range(1, num_words + 1):
        prev_word = sequence[-1]                       # 직전 단어 하나만 본다
        next_word, count = predict_next(model, prev_word)
        print(f"단계 {step}: '{prev_word}' 다음 → '{next_word}' ({count}번)")
        sequence.append(next_word)                     # 예측이 다음 입력이 된다 — 자기회귀
    return sequence


if __name__ == "__main__":
    words = load_words()
    model = build_bigram_model(words)
    print("시작 단어:", START_WORD)
    print("생성 단어 수:", NUM_WORDS)
    sequence = generate(model, START_WORD, NUM_WORDS)
    print("생성 결과:", " ".join(sequence))
  • 첫 줄의 from step02_bigram_model import …이 접점입니다. step02 파일이 import 시 조용해야 하는 이유가 여기서 드러납니다 — 조용하지 않았다면 step03의 출력 앞에 step02의 일곱 줄이 딸려 나왔을 것입니다.
  • sequence[-1] — 리스트의 마지막 원소, 즉 직전 단어. 이 한 줄이 "문맥 길이 1"입니다. 4장의 GPT는 이 자리에 문맥 전체(최대 1,024 토큰)를 넣습니다.
  • generate()를 함수로 뺀 것은 구현입니다. 여러분의 코드는 for 루프를 __main__ 블록 안에 바로 썼을 수 있습니다 — 출력 열세 줄이 같으면 정답입니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 함수 없이 루프를 파일 맨 아래에 바로 썼고, 변수 이름을 시작_단어, 생성_단어_수, 단계, 다음_단어처럼 한글로 지었습니다 — 파이썬은 한글 변수명을 허용합니다. 직전 단어는 current 변수로 따로 추적했습니다. 출력 13줄과 생성 문장은 기준 구현과 단어 단위로 같았고, 사양 수정 없이 첫 시도에 통과했습니다. 변수 이름이 한글이든 영어든, 함수가 있든 없든 — 전부 오른쪽 열(구현)입니다.

세 스텝을 다 통과했다면 bash harness/verify.sh ch01 step02 --solution과 diff로 여러분의 코드와 기준 구현을 비교해 보세요. 0장의 표(같아야 하는 것 / 달라도 되는 것)를 다시 꺼내 분류하면 됩니다.

용어 정리

용어원어(약어)한 줄 정의
대형 언어 모델Large Language Model (LLM)다음 단어 예측으로 훈련된 초대형 신경망
자연어 처리Natural Language Processing (NLP)사람의 언어를 컴퓨터로 다루는 분야
인공지능 / 머신러닝 / 딥러닝AI / ML / DL그림 1-1의 포함 관계 참고
파라미터parameter훈련으로 조절되는 모델 내부의 숫자 (조절 나사). 바이그램 모델에서는 횟수 표
생성형 AIGenerative AI텍스트·이미지 등 콘텐츠를 만들어 내는 AI 총칭
파운데이션 모델foundation model여러 과제의 기반이 되는 범용 사전훈련 모델
사전훈련pretraining원시 텍스트로 다음 단어 예측을 연습하는 1단계
미세조정fine-tuning사전훈련 모델을 특정 과제 데이터로 추가 훈련하는 2단계
자기지도학습self-supervised learning데이터 자체가 정답을 제공하는 학습 (다음 단어가 곧 정답)
말뭉치corpus훈련용 대량 텍스트 모음. 이번 장은 《The Verdict》 3,634단어
토큰token모델이 텍스트를 처리하는 최소 단위 (단어 조각). 이번 장은 공백으로 나눈 단어
트랜스포머transformer어텐션 기반 신경망 아키텍처 (2017)
인코더 / 디코더encoder / decoder입력을 읽어 요약하는 부분 / 출력을 생성하는 부분
어텐션attention문맥 중 어디에 주목할지 계산하는 장치 (3장에서 구현)
BERTBidirectional Encoder Representations from Transformers인코더 계열, 빈칸 채우기 훈련, 이해·분류에 강함
GPTGenerative Pretrained Transformer디코더 계열, 다음 단어 예측 훈련, 생성에 강함
자기회귀autoregressive이전 출력이 다음 입력이 되는 생성 방식. step03의 sequence[-1]
창발적 행동emergent behavior훈련 목표에 없던 능력이 규모와 함께 나타나는 현상
제로샷 / 퓨샷zero-shot / few-shot예시 없이 / 예시 몇 개만 보고 새 과제를 수행하는 능력
조건부 확률conditional probabilityP(A | B) — B가 주어졌을 때 A일 확률. 다음 단어 예측의 수학 표현
바이그램bigram연속한 단어 2개 묶음. 직전 1단어만 보는 원시 언어 모델
탐욕 생성greedy generation매 단계 가장 확률 높은 후보 하나만 고르는 생성 방식
동률tie최고 후보가 여럿인 상황. 구현마다 선택이 달라질 수 있어 사양에 규칙을 적는다
문맥 길이context length다음 단어를 예측할 때 보는 이전 토큰 수. 바이그램은 1, GPT-2는 1,024
접점interface다른 코드가 붙는 이름과 입력·반환. step02의 함수 세 개
파이토치 / 텐서PyTorch / tensor딥러닝 라이브러리 / 그 핵심 자료구조(다차원 숫자 상자)

정리

  • LLM은 "다음 단어 예측"이라는 단일 과제를 인터넷 규모의 말뭉치로 연습한 초대형 신경망이며, 그 단순한 과제에서 번역·요약·대화 같은 능력이 창발한다.
  • LLM 구축은 사전훈련(자기지도, 대량 원시 텍스트)과 미세조정(소량 레이블 데이터)의 2단계로 이루어진다.
  • 현대 LLM의 뼈대는 트랜스포머이고, GPT는 그중 디코더만 쌓아 자기회귀 생성에 특화한 구조다. 우리가 만들 것이 바로 이것이다.
  • 세는 것만으로도 원시적 언어 모델(바이그램)을 만들 수 있다 — 예측(P(the | of) = 21/95)도 생성(of the picture above …)도 된다. 하지만 문맥이 1단어뿐이라 같은 고리를 맴돈다. 실제 LLM은 여기에 "긴 문맥"(어텐션)과 "신경망 일반화"를 더한 것이다.
  • 사양은 접점·동작·제약만 못 박고, 구현에 따라 흔들릴 수 있는 값(동률)은 규칙을 적거나 검증에서 뺀다.

다음 장 예고

2장에서 드디어 구현이 시작됩니다. 첫 관문은 "텍스트를 어떻게 숫자로 바꿀 것인가"입니다 — 신경망은 숫자만 먹기 때문입니다. 텍스트를 토큰으로 쪼개고(토큰화), 토큰마다 번호를 붙이고(토큰 ID), 그 번호를 의미를 담은 벡터로 바꾸는(임베딩) 과정을 전부 사양으로 만들어 봅니다. 오늘 공백으로 나눈 단어를 그대로 세었던 것과 달리, 2장부터는 GPT-2가 실제로 쓰는 방식 그대로 처리합니다 — "end."가 "end"와 다른 단어로 세어지던 문제가 첫 번째로 해결됩니다.


Chapter 02 전 스텝 검증 완료 (8/8, 사양 수정 2회)

2장. 텍스트 데이터 다루기 — 글자를 숫자로, 숫자를 벡터로

이 장에서 배우는 것

  • 신경망이 텍스트를 먹을 수 있게 만드는 파이프라인 전체: 텍스트 → 토큰 → 토큰 ID → 임베딩 벡터
  • 단순 토크나이저를 직접 만들고, 그 한계(모르는 단어, 어휘 폭발)를 특수 토큰과 BPE로 푸는 과정
  • 슬라이딩 윈도우로 "입력 → 다음 토큰" 훈련 쌍을 만들고 데이터 로더로 배치를 꺼내는 법
  • 토큰 임베딩과 위치 임베딩 — GPT 모델의 진짜 입력이 만들어지기까지
  • 실습 8스텝: 접점으로 이어진 사양 8개, 기준 구현 8개, 2장 끝에는 4장 GPT 모델이 받을 (8, 4, 256) 텐서가 손에 있다

들어가며 — 1장에서 2장으로

1장에서 여러분은 세는 것만으로 첫 언어 모델(바이그램)을 만들었고, 두 가지 한계를 봤습니다. 하나는 문맥이 직전 1단어뿐이라 the picture above를 맴돌던 것(3장 어텐션이 풉니다), 다른 하나는 단어를 그대로 세기만 해서 end.와 end가 남남이었던 것. 신경망은 단어를 세지 않습니다 — 숫자를 곱하고 더합니다. 그러니 신경망으로 언어 모델을 만들려면 먼저 텍스트를 숫자로 바꾸는 규칙이 있어야 합니다. 이번 장은 그 규칙을 처음부터 끝까지 만듭니다. 장 끝에는 4장에서 만들 GPT 모델이 그대로 받아먹을 수 있는 텐서 하나가 손에 있게 됩니다.

0장·1장처럼 여러분은 코드를 치지 않고 사양을 씁니다. 다만 이번 장부터 사양이 여덟 개이고 서로 접점으로 이어집니다 — step01의 tokenize를 step02·03이, step04의 get_gpt2_tokenizer를 step05·06이, step06의 create_dataloader를 step08이 import 합니다. 접점을 정확히 못 박는 연습이 이번 장의 두 번째 학습 목표입니다.

2.1 왜 임베딩인가 — 신경망은 숫자만 먹는다

파이프라인을 먼저 그려 놓고 시작합시다.

flowchart LR
    T["텍스트
It’s the last he painted"] -->|"토큰화
(2.2, 2.5)"| K["토큰
It · ’ · s · the · …"] K -->|"어휘 조회
(2.3, 2.4)"| I["토큰 ID
[56, 2, 850, 988, …]"] I -->|"임베딩 층
(2.8, 2.9)"| E["벡터
[[0.33, -0.17, …], …]"] E --> G["4장 GPT 모델"]
그림 2-1. 2장의 파이프라인. 글자에서 벡터까지 세 번 변환한다. 실습 step01~04가 앞 두 화살표, step05~06이 훈련 쌍 만들기, step07~08이 마지막 화살표다.
  • 토큰(token): 텍스트를 처리하는 최소 단위. 1장에서는 공백으로 나눈 단어였고, 이번 장에서는 구두점도 독립 토큰이 되며(2.2), 끝에 가서는 단어 조각(2.5)이 됩니다.
  • 토큰 ID: 토큰마다 붙인 정수 번호. 어휘(vocabulary)라는 표가 토큰 ↔ ID를 잇습니다.
  • 임베딩(embedding): ID를 실수 벡터로 바꾸는 것. 왜 ID를 그대로 쓰지 않을까요? ID는 전화번호 같은 것이라 "850이 56보다 크다"에 아무 의미가 없습니다. 벡터로 바꾸면 비슷한 뜻의 단어를 비슷한 방향에 놓을 수 있고, 덧셈·곱셈이 의미 있는 연산이 됩니다.

임베딩이라는 아이디어는 LLM보다 오래됐습니다. 2013년의 Word2Vec은 "비슷한 문맥에 나오는 단어는 비슷한 벡터를 갖게 하라"는 원리로 단어 벡터를 훈련했고, 그 결과 왕 − 남자 + 여자 ≈ 여왕 같은 산수가 성립했습니다. GPT의 임베딩 층도 같은 종류의 표이지만, 따로 훈련해 가져오지 않고 모델의 일부로서 함께 훈련된다는 점이 다릅니다. 차원은 GPT-2 소형이 768, 대형 모델은 수천 — 실습에서는 256을 씁니다.

2.2 텍스트를 토큰으로 — 단순 토큰화 (step01)

1장의 split()은 공백만 봤습니다. 이번에는 구두점을 떼어 냅니다. 규칙은 이렇습니다.

문자처리
, . : ; ? _ ! " ( ) ' (11종)각각 독립 토큰
-- (이중 대시)두 글자를 합쳐 하나의 토큰
공백·탭·줄바꿈토큰 사이의 경계 — 버린다
그 밖의 문자 덩어리하나의 토큰 (대소문자 유지)
flowchart LR
    A["Hello, world. Is this-- a test?"] --> B["구두점·--·공백 자리에서 자른다"]
    B --> C["Hello · , · world · . · Is · this · -- · a · test · ?"]
    C --> D["공백 조각은 버린다"]
그림 2-2. 단순 토큰화. 구분자 자체(쉼표, 마침표, --)도 토큰으로 남기고 공백만 버린다.

《The Verdict》 전체에 적용하면 4,690개 토큰이 나옵니다. 1장의 3,634단어보다 많아진 것은 구두점이 독립 토큰으로 세어졌기 때문입니다. 반대로 서로 다른 토큰 수는 1,485 → 1,130으로 줄어듭니다 — end.와 end가 같은 토큰 end로 합쳐졌으니까요. 대소문자는 아직 구분합니다(The와 the는 다른 토큰). 원서도 그렇게 하는데, GPT가 대소문자를 구분하는 모델이기 때문입니다.

2.3 토큰을 ID로 — 어휘와 토크나이저 V1 (step02)

어휘(vocabulary)는 "토큰 → 번호" 표입니다. 만드는 규칙은 하나: 서로 다른 토큰을 정렬해 0부터 번호를 매깁니다. 정렬은 파이썬 기본 문자열 정렬이라 구두점(! " ' …)이 앞에, 대문자 단어가 그 다음, 소문자 단어가 뒤에 옵니다. 그래서 !가 0, yourself가 1129입니다.

flowchart LR
    subgraph V["어휘 (1,130 항목)"]
        direction TB
        a["! → 0"]
        b["(큰따옴표) → 1"]
        c["… "]
        d["painted → 746"]
        e["the → 988"]
        f["yourself → 1129"]
    end
    T["텍스트"] -->|"encode: 토큰 → ID"| V
    V -->|"decode: ID → 토큰"| T
그림 2-3. 어휘는 양방향 표다. encode는 왼쪽에서 오른쪽으로, decode는 오른쪽에서 왼쪽으로 찾는다. 기준 구현은 역방향 표를 하나 더 만들어 둔다.

토크나이저 클래스는 이 표로 두 가지 일을 합니다. encode(text): 텍스트 → 토큰(step01의 규칙) → ID 리스트. decode(ids): ID → 토큰 → 공백으로 이어 붙인 텍스트. 단, 구두점 앞에는 공백을 넣지 않습니다(Hello , world .가 아니라 Hello, world.).

실습에서 소설의 한 문장을 encode 하면 21개 ID가 나오고, decode 하면 " It' s the last he painted, …가 됩니다. 원문은 "It's인데 복원은 " It' s — 큰따옴표 뒤의 공백과 아포스트로피 처리 규칙이 원문과 다르기 때문입니다. 이것은 버그가 아니라 단순 토크나이저의 한계이고, 원서의 결과도 같습니다. 2.5절의 BPE가 이 문제까지 해결합니다.

2.4 특수 토큰 — 모르는 단어와 문서의 경계 (step03)

V1 토크나이저에 소설에 없는 문장을 넣으면 어떻게 될까요? Hello는 어휘에 없으니 표를 찾다가 실패합니다. 해결책은 어휘에 특수 토큰(special token)을 더하는 것입니다.

특수 토큰역할이 장에서의 ID
모르는 단어 토큰 (unk)어휘에 없는 토큰 자리에 대신 넣는다1131
문서 경계 토큰 (endoftext)서로 다른 문서를 이어 붙일 때 경계에 넣는다1130
flowchart LR
    D1["문서 1
Hello, do you like tea?"] --> J["이어 붙이기"] D2["문서 2
In the sunlit terraces of the palace."] --> J J --> S["Hello, do you like tea? ⟨경계⟩ In the sunlit terraces of the palace."] S --> E["encode → [1131, 5, 355, 1126, 628, 975, 10, 1130, 55, 988, 956, 984, 722, 988, 1131, 7]"]
그림 2-4. 두 문서를 경계 토큰으로 이어 붙인 뒤 encode 한다. 소설에 없는 Hello와 palace는 1131(모르는 단어)이 되고, 경계는 1130이 된다.

훈련 데이터는 보통 수많은 문서를 하나로 이어 붙인 것입니다. 경계 토큰이 없으면 모델은 한 문서의 끝과 다음 문서의 시작이 이어진 문장이라고 착각합니다. GPT-2는 이 경계 토큰 하나만 씁니다(ID 50256 — step04에서 확인). 다른 모델들은 문장 시작(BOS), 문장 끝(EOS), 길이 맞추기용 채우기(PAD) 토큰을 따로 두기도 하는데, GPT-2는 셋 다 경계 토큰으로 겸합니다. 모르는 단어 토큰은 GPT-2에 없습니다 — 다음 절의 방식으로 "모르는 단어" 자체를 없애 버리기 때문입니다.

사양 작성 주의 — 특수 토큰 문자열

특수 토큰 문자열(<|로 시작해 |>로 끝나는 꼴)을 사양에 그대로 적으면 모델 서버가 그것을 진짜 특수 토큰으로 해석해 사양이 그 자리에서 잘릴 수 있습니다(1판 제작 중 실측). 그래서 step03·04의 사양은 토큰을 "세 조각을 이어 붙인 문자열"로 정의합니다. 에이전트가 만든 코드 안에서는 완성된 문자열이 쓰입니다.

2.5 GPT-2의 진짜 토크나이저 — 바이트 페어 인코딩 (step04)

모르는 단어 문제의 근본 해결책은 단어보다 작은 단위로 쪼개는 것입니다. 글자 하나하나로 쪼개면 모르는 단어는 없어지지만 토큰 수가 폭발하고 글자 하나에는 뜻이 없습니다. 그 중간 — 자주 함께 나오는 글자 묶음을 한 조각으로 — 이 바이트 페어 인코딩(byte pair encoding, BPE)입니다.

손계산 ① BPE 병합을 손으로

작은 예로 원리를 봅시다. 텍스트가 aaabdaaabac라고 합시다. BPE는 "가장 자주 붙어 나오는 두 글자 쌍을 새 기호로 합치기"를 반복합니다.

단계텍스트가장 잦은 쌍새 기호
0a a a b d a a a b a caa (4회)Z = aa
1Z a b d Z a b a cab (2회)Y = ab
2Z Y d Z Y a cZY (2회)X = ZY
3X d X a c—(멈춤)

11글자가 5개 조각이 됐고, 어휘에는 원래 글자 a·b·c·d에 Z·Y·X가 더해졌습니다. 이 "병합 규칙 목록"이 곧 토크나이저입니다. 새 텍스트가 오면 같은 규칙을 같은 순서로 적용하면 됩니다. 어떤 문자열이든 최악의 경우 글자 단위로는 표현되므로 모르는 단어가 없습니다.

flowchart TB
    L0["a a a b d a a a b a c"] -->|"aa → Z"| L1["Z a b d Z a b a c"]
    L1 -->|"ab → Y"| L2["Z Y d Z Y a c"]
    L2 -->|"ZY → X"| L3["X d X a c"]
그림 2-5. BPE 병합. 매 단계 가장 잦은 쌍을 새 기호로 합친다. 병합 규칙의 순서가 곧 토크나이저다.

GPT-2의 BPE는 글자가 아니라 바이트(256가지)에서 출발해 5만 번 병합했고, 여기에 경계 토큰 하나를 더해 어휘가 50,257개입니다(256 + 50,000 + 1). 우리는 원서와 마찬가지로 이것을 직접 구현하지 않고 OpenAI의 tiktoken 라이브러리를 씁니다 — 훈련된 병합 규칙을 그대로 내려받아 쓰는 것입니다.

  • someunknownPlace처럼 어휘에 없는 단어가 some / unknown / Place 세 조각(617, 34680, 27271)으로 쪼개지고, decode 하면 원문 그대로 돌아옵니다. 모르는 단어 토큰이 필요 없습니다.
  • 뜻 없는 문자열 Akwirw ier도 Ak w ir w ier로 쪼개집니다. 공백이 독립 조각(220)으로 남는 것에 주목하세요 — GPT-2 BPE는 단어 앞의 공백을 토큰의 일부로 다룹니다( and처럼). 2.3절의 " It' s 문제가 이렇게 해결됩니다.

2.6 슬라이딩 윈도우 — 훈련 쌍 만들기 (step05)

이제 텍스트가 ID 열이 됐으니 훈련 데이터를 만들 차례입니다. 1장에서 배운 대로 LLM의 과제는 "지금까지의 토큰 → 다음 토큰" 예측입니다. 문맥 길이 4의 창을 텍스트 위에 놓으면 입력 x와 타깃 y는 이렇게 됩니다.

손계산 ② 창 하나에서 과제 네 개

소설의 51번째 토큰부터 다섯 개가 290, 4920, 2241, 287, 257 ( and established himself in a)이라고 합시다.

x = [290, 4920, 2241, 287]      ← 앞 4개
y = [4920, 2241, 287, 257]      ← x 를 한 칸 민 것

y가 x를 한 칸 민 것인 이유: 모델은 x의 i번째까지 보고 y의 i번째를 맞혀야 합니다. 창 하나에서 과제가 네 개 생깁니다.

본 것맞힐 것
and established
and established himself
and established himself in
and established himself in a
flowchart LR
    subgraph W["창 (문맥 길이 4)"]
        direction LR
        t1["290
and"] --> t2["4920
established"] --> t3["2241
himself"] --> t4["287
in"] end t4 -.->|"다음"| t5["257
a"] X["x = [290, 4920, 2241, 287]"] ~~~ Y["y = [4920, 2241, 287, 257]"]
그림 2-6. 슬라이딩 윈도우. 창 안의 네 토큰이 입력이고, 각 자리의 "다음 토큰"이 타깃이다. 타깃 열은 입력 열을 한 칸 민 것과 같다.

실습에서 소설 전체는 GPT-2 BPE로 5,145개 토큰입니다 — step01의 4,690과 다른 것은 토크나이저가 다르기 때문입니다. 같은 텍스트라도 어떻게 쪼개느냐에 따라 토큰 수가 달라진다는 것, 그래서 "토큰 수"는 언제나 "어떤 토크나이저로"와 함께 말해야 한다는 것을 기억하세요.

2.7 데이터셋과 데이터 로더 — 배치로 꺼내기 (step06)

창 하나를 손으로 만드는 것은 step05에서 했습니다. 소설 전체에서 창을 보폭(stride)만큼 밀며 전부 만들어 두고, 훈련 때 여러 개(배치)씩 꺼내는 것이 파이토치의 Dataset과 DataLoader입니다. Dataset은 "i번째 항목을 달라"에 답하는 객체 — 우리 데이터셋의 i번째 항목은 (입력 텐서, 타깃 텐서) 쌍입니다. DataLoader는 데이터셋의 항목을 배치 크기만큼 묶어 차례로(또는 섞어서) 내주는 객체입니다.

flowchart TB
    subgraph S1["보폭 1 — 창이 겹친다"]
        direction LR
        a1["[40, 367, 2885, 1464]"] --> a2["[367, 2885, 1464, 1807]"] --> a3["[2885, 1464, 1807, 3619]"]
    end
    subgraph S4["보폭 4 — 창이 겹치지 않는다"]
        direction LR
        b1["[40, 367, 2885, 1464]"] --> b2["[1807, 3619, 402, 271]"] --> b3["[10899, 2138, 257, 7026]"]
    end
그림 2-7. 보폭에 따른 창의 겹침. 보폭 1이면 둘째 창의 입력이 첫째 창의 타깃과 같다. 보폭을 문맥 길이와 같게 하면 겹침도 빈틈도 없다.

손계산 ③ 창은 몇 개, 배치는 몇 개

토큰 5,145개, 문맥 길이 4, 보폭 4라고 합시다. 창의 시작 위치는 0, 4, 8, …이고 타깃까지 채우려면 시작 + 4가 5,145보다 작아야 합니다. 시작 위치 최대는 5,140이므로 창의 수는 5,140 ÷ 4 + 1 = 1,286개. 배치 크기 8로 묶으면 1,286 ÷ 8 = 160.75 — 자투리 6개를 버리면(drop_last) 160 배치입니다. 실습의 배치 수: 160이 이 계산입니다. (창의 마지막 하나를 세느냐 마느냐가 구현마다 ±1 다를 수 있는데, 어느 쪽이든 자투리에 묻혀 160은 같습니다 — 사양이 drop_last를 못 박은 이유입니다.)

실습에서 확인할 것: 보폭 1 로더의 둘째 배치 입력 [367, 2885, 1464, 1807]이 첫째 배치의 타깃과 같고, 보폭 4 로더의 8×4 텐서에서는 한 행의 마지막 ID 다음이 다음 행의 첫 ID로 이어집니다. 보폭이 너무 작으면 같은 토큰이 여러 창에 중복되어 과적합 위험이 커지고, 너무 크면 데이터를 건너뛰게 됩니다 — 원서는 보폭 = 문맥 길이를 권합니다.

2.8 토큰 임베딩 — ID를 벡터로 (step07)

드디어 그림 2-1의 마지막 화살표입니다. 임베딩 층은 (어휘 크기 × 차원) 실수 행렬이고, ID가 3이면 3번 행을 꺼냅니다. 그것이 전부입니다.

손계산 ④ 임베딩 = 원-핫 × 행렬 = 행 꺼내기

어휘 크기 6, 차원 3의 층을 시드 123으로 만들면 가중치가 다음과 같습니다(실습 값).

행 0: [ 0.3374, -0.1778, -0.1690]
행 1: [ 0.9178,  1.5810,  1.3010]
행 2: [ 1.2753, -0.2010, -0.1606]
행 3: [-0.4015,  0.9666, -1.1481]
행 4: [-1.1589,  0.3255, -0.6315]
행 5: [-2.8400, -0.7849, -1.4096]

ID 3을 원-핫(one-hot) 벡터로 쓰면 [0, 0, 0, 1, 0, 0] (3번 자리만 1). 이 벡터와 위 행렬을 곱하면

[0,0,0,1,0,0] × 행렬 = 0·행0 + 0·행1 + 0·행2 + 1·행3 + 0·행4 + 0·행5 = 행3 = [-0.4015, 0.9666, -1.1481]

즉 임베딩 조회는 원-핫 벡터와 행렬의 곱과 수학적으로 같습니다. 파이토치가 곱셈 대신 "행 꺼내기"로 구현하는 이유는 0을 곱하는 헛수고를 안 하려는 것뿐입니다. 이 사실이 중요한 이유: 임베딩 층은 신경망의 선형층(행렬 곱)의 한 종류이므로 다른 층과 똑같이 역전파로 훈련됩니다.

flowchart LR
    ID["ID 3"] -->|"원-핫 [0,0,0,1,0,0]"| M["가중치 행렬 6×3
(시드 123 으로 초기화)"] M -->|"3번 행"| V["[-0.4015, 0.9666, -1.1481]"] IDS["ID [2, 3, 5, 1]"] -->|"행 2,3,5,1 을 차례로"| M2["4×3 텐서"]
그림 2-8. 임베딩 조회. ID는 행 번호이고, 결과는 그 행이다. ID 여러 개면 그 행들을 순서대로 쌓는다.

실습에서 사양이 못 박는 것이 하나 더 있습니다 — 시드를 거는 위치. torch.manual_seed(123) 직후에 층을 만들어야 모두가 같은 값을 봅니다. 그 사이에 난수를 쓰는 코드가 하나라도 끼면 값이 달라집니다. 계약 설계 규칙(난수)의 실전 사례입니다.

2.9 위치 임베딩 — 순서를 더하다 (step08)

토큰 임베딩에는 한 가지가 빠져 있습니다. 같은 토큰이면 몇 번째 자리에 있든 같은 벡터라는 것. 그러면 모델은 The cat sat과 cat The sat을 구분할 수 없습니다(3장의 어텐션은 자리를 보지 않고 벡터끼리만 비교하기 때문에 더욱 그렇습니다). 해결책은 자리마다 다른 위치 임베딩(positional embedding) 벡터를 하나 더 두고 토큰 벡터에 더하는 것입니다. GPT는 0번 자리 벡터, 1번 자리 벡터, …를 훈련으로 배우는 절대 위치 임베딩을 씁니다(문맥 길이만큼의 행을 가진 임베딩 층 하나).

손계산 ⑤ 브로드캐스팅 덧셈

차원을 2, 문맥 길이를 2, 배치를 2로 줄여 봅시다. 토큰 임베딩이 배치별로

배치 0: [[1, 2], [3, 4]]      배치 1: [[5, 6], [7, 8]]     (모양 2×2×2)

이고 위치 임베딩이 [[10, 20], [30, 40]] (모양 2×2 — 0번 자리, 1번 자리)이면, 더한 결과는

배치 0: [[11, 22], [33, 44]]   배치 1: [[15, 26], [37, 48]]

위치 임베딩 하나가 배치 두 개 모두에 더해졌습니다. 모양이 (2, 2)인 텐서를 (2, 2, 2)에 더할 때 파이토치가 앞쪽 차원을 자동으로 늘려 주는 것을 브로드캐스팅(broadcasting)이라고 합니다. 실습에서 (4, 256) 위치 임베딩이 (8, 4, 256) 토큰 임베딩에 더해지는 것이 바로 이것입니다.

flowchart LR
    TID["토큰 ID
(8, 4)"] --> TE["토큰 임베딩 층
50257 × 256"] TE --> TV["토큰 임베딩
(8, 4, 256)"] POS["위치 0,1,2,3"] --> PE["위치 임베딩 층
4 × 256"] PE --> PV["위치 임베딩
(4, 256)"] TV --> ADD["+ (브로드캐스팅)"] PV --> ADD ADD --> IE["입력 임베딩
(8, 4, 256) → 4장 GPT"]
그림 2-9. 입력 임베딩 = 토큰 임베딩 + 위치 임베딩. 위치 임베딩은 배치 차원이 없어서 8개 배치 모두에 같은 것이 더해진다.

이 (8, 4, 256) 텐서가 2장의 최종 산출물이자 4장 GPT 모델의 첫 입력입니다. 3장에서는 이 벡터들 사이의 관계를 계산하는 어텐션을 만듭니다.

실습 — 루프 여덟 바퀴

여덟 스텝은 접점으로 이어져 있습니다. 순서대로 진행하세요 — 앞 스텝을 건너뛰면 뒤 스텝의 import가 실패합니다.

flowchart LR
    S1["step01
tokenize"] --> S2["step02
어휘 · V1"] S2 --> S3["step03
특수 토큰 · V2"] S1 --> S5["step05
슬라이딩 윈도우"] S4["step04
BPE (tiktoken)"] --> S5 S4 --> S6["step06
데이터셋 · 로더"] S1 --> S6 S7["step07
토큰 임베딩"] ~~~ S8["step08
입력 임베딩"] S6 --> S8 S1 --> S8
그림 2-10. 2장 실습의 접점 흐름. 화살표는 import다. step07은 독립된 장난감 실험이고, step08이 step06의 로더를 가져다 쓴다.

각 스텝은 bash harness/run_loop.sh ch02 prompts/ch02/stepNN-*.md → bash harness/verify.sh ch02 stepNN → bash harness/verify.sh ch02 stepNN --solution의 세 명령입니다.

step01. 텍스트를 토큰으로 쪼개기

무엇을 — 소설 텍스트를 단어와 구두점 단위의 토큰 리스트로 쪼개는 함수 tokenize(text)
왜 — 신경망은 숫자만 먹는다. 텍스트를 숫자로 바꾸는 첫 단계는 "무엇을 한 조각(토큰)으로 볼 것인가"를 정하는 것이다
맥락 — 1장 바이그램은 공백으로만 나눠 end. 와 end 가 다른 단어가 됐다. 이 스텝이 그 문제를 푼다. step02~03이 이 함수를 import 한다
기대 결과 — 총 토큰 수: 4690 — 1장의 3,634단어가 구두점을 떼어 내니 4,690개 토큰이 된다
hermes에게 전달되는 사양 (prompts/ch02/step01-tokenize.md)
step01_tokenize.py 라는 파이썬 파일을 만들어줘.

만들 것: 소설 텍스트를 "단어와 구두점" 단위의 토큰 리스트로 쪼개는 단순 토큰화 함수.

입력:
- 아래 URL의 텍스트 파일. 현재 디렉토리에 the-verdict.txt 로 저장하고, 이미 있으면 다시 내려받지 않는다.
  https://raw.githubusercontent.com/rickiepark/llm-from-scratch/main/ch02/01_main-chapter-code/the-verdict.txt

분리 규칙 (이 규칙대로 나누면 어떤 방법으로 구현해도 같은 결과가 나온다):
- 다음 구두점 문자 하나하나는 각각 독립된 토큰이다: 쉼표 , 마침표 . 콜론 : 세미콜론 ; 물음표 ?
  밑줄 _ 느낌표 ! 큰따옴표 " 여는 괄호 ( 닫는 괄호 ) 작은따옴표 '
- 이중 대시 -- 는 두 글자를 합쳐 하나의 토큰이다.
- 하이픈 하나 - 는 구분자가 아니다. 단어 속의 하이픈은 단어의 일부다 — well-known 은 하나의 토큰 well-known 이다.
- 공백(스페이스·탭·줄바꿈)은 토큰을 가르는 경계일 뿐 토큰이 아니다. 빈 문자열도 토큰이 아니다.
- 위 구분자와 공백을 뺀 나머지 문자가 이어진 덩어리(단어)는 하나의 토큰이다. 대소문자는 바꾸지 않는다.
- 예: Hello, world. Is this-- a well-known test? → ['Hello', ',', 'world', '.', 'Is', 'this', '--', 'a', 'well-known', 'test', '?']

접점 (다음 스텝들이 import 해서 쓴다):
- load_text() — 인자 없음. (파일이 없으면 내려받은 뒤) 파일을 UTF-8 로 읽어 문자열 하나로 반환한다.
- tokenize(text) — 문자열을 받아 위 규칙대로 나눈 토큰(문자열) 리스트를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음 세 줄을 정확히 이 형식으로 출력한다):
- `총 문자 수: ` 뒤에 텍스트의 문자 수
- `총 토큰 수: ` 뒤에 토큰 리스트의 길이
- `앞 30개 토큰: ` 뒤에 처음 30개 토큰을 파이썬 리스트 표기 그대로 (['I', 'HAD', ...])

지켜야 할 것:
- 표준 라이브러리만 사용한다. 외부 패키지 설치 금지.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.

사양의 절반이 분리 규칙입니다. 이것은 구현 지시가 아니라 "무엇을 토큰으로 볼 것인가"라는 요구의 정의입니다 — 규칙이 다르면 토큰 수가 달라지므로 동작의 일부입니다. 정규식으로 할지 루프로 할지는 없습니다.

--- 검증 결과 (실측) ---
총 문자 수: 20479
총 토큰 수: 4690
앞 30개 토큰: ['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', 'genius', '--', 'though', 'a', 'good', 'fellow', 'enough', '--', 'so', 'it', 'was', 'no', 'great', 'surprise', 'to', 'me', 'to', 'hear', 'that', ',', 'in']
[OK] 3줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch02/step01_tokenize.py, 핵심):

SPLIT_PATTERN = r'([,.:;?_!"()\']|--|\s)'

def tokenize(text):
    pieces = re.split(SPLIT_PATTERN, text)
    return [piece.strip() for piece in pieces if piece.strip()]

정규식의 바깥 괄호가 캡처 그룹입니다 — re.split은 보통 구분자를 버리지만, 캡처 그룹으로 감싸면 구분자도 결과에 남깁니다. 그래서 쉼표와 --가 토큰으로 살아남고, \s(공백)만 두 번째 줄에서 걸러집니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 정규식을 쓰지 않고 문자를 하나씩 훑는 while 루프 — 공백이면 건너뛰고, 구두점이면 한 글자 토큰, --면 두 글자 토큰, 아니면 다음 경계까지 단어를 모으는 네 갈래 — 로 구현했습니다. 기준 구현의 정규식 두 줄과는 생김새가 전혀 다르지만 4,690이 나왔습니다. 첫 시도에서는 사양이 하이픈 하나(-)를 어떻게 볼지 말하지 않아 에이전트가 단어 속 하이픈까지 분리해 4,766이 나왔고, 사양에 "하이픈 하나는 구분자가 아니다 — well-known은 한 토큰" 한 줄을 더하니 통과했습니다. 코드가 아니라 규칙을 더한 것입니다 — 2판의 재시도가 어떻게 생겼는지 보여 주는 사례입니다.

step02. 어휘 만들기와 토크나이저 V1

무엇을 — 서로 다른 토큰마다 번호를 붙인 어휘와, encode/decode를 하는 토크나이저 클래스
왜 — 신경망에 넣을 수 있는 것은 숫자뿐이다. 토큰에 번호를 붙이는 것이 텍스트를 숫자로 바꾸는 두 번째 단계다
맥락 — step01의 tokenize로 쪼갠 토큰에서 어휘를 만든다. step03이 이 어휘와 클래스를 확장한다
기대 결과 — 어휘 크기: 1130, 소설 속 문장 하나가 21개의 ID로 바뀌었다가 거의 원래대로 복원된다
hermes에게 전달되는 사양 (prompts/ch02/step02-vocab.md)
step02_vocab.py 라는 파이썬 파일을 만들어줘.

만들 것: 토큰마다 번호(ID)를 붙인 어휘와, 텍스트 ↔ ID 리스트를 오가는 토크나이저 클래스.

접점 (직전 스텝 재사용 + 다음 스텝이 import 한다):
- 같은 디렉토리의 step01_tokenize.py 에서 load_text() 와 tokenize(text) 를 import 해서 쓴다. 토큰화를 새로 구현하지 않는다.
- build_vocab(tokens) — 토큰 리스트를 받아 {토큰: ID} 딕셔너리를 반환한다. 서로 다른 토큰을
  파이썬 기본 문자열 정렬(sorted) 순서로 늘어놓고 0 부터 차례로 번호를 매긴다.
- 클래스 SimpleTokenizerV1 — 생성자에 어휘 딕셔너리를 받는다.
  - encode(text) — 텍스트를 tokenize 로 나눈 뒤 각 토큰의 ID 리스트를 반환한다.
  - decode(ids) — ID 리스트를 토큰으로 되돌려 공백 하나로 이어 붙이되, 다음 구두점 바로 앞의 공백은 제거한다:
    , . ? ! " ( ) '  (예: Hello , world . → Hello, world.)
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음 다섯 줄을 정확히 이 형식으로 출력한다):
- `어휘 크기: ` 뒤에 어휘 항목 수
- `어휘 앞 5개: ` 뒤에 ID 0~4 의 (토큰, ID) 쌍 5개를 파이썬 리스트 표기로 ([('!', 0), ...])
- `어휘 뒤 3개: ` 뒤에 마지막 ID 3개의 (토큰, ID) 쌍을 같은 표기로
- `토큰 ID: ` 뒤에, 소설에 나오는 다음 문장을 encode 한 ID 리스트:
  "It's the last he painted, you know," Mrs. Gisburn said with pardonable pride.  (큰따옴표까지 문장의 일부다)
- `복원 텍스트: ` 뒤에 그 ID 리스트를 decode 한 문자열

지켜야 할 것:
- 표준 라이브러리만 사용한다. 외부 패키지 설치 금지.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
어휘 크기: 1130
어휘 앞 5개: [('!', 0), ('"', 1), ("'", 2), ('(', 3), (')', 4)]
어휘 뒤 3개: [('younger', 1127), ('your', 1128), ('yourself', 1129)]
토큰 ID: [1, 56, 2, 850, 988, 602, 533, 746, 5, 1126, 596, 5, 1, 67, 7, 38, 851, 1108, 754, 793, 7]
복원 텍스트: " It' s the last he painted, you know," Mrs. Gisburn said with pardonable pride.
[OK] 5줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch02/step02_vocab.py, 핵심):

def build_vocab(tokens):
    unique_tokens = sorted(set(tokens))
    return {token: idx for idx, token in enumerate(unique_tokens)}

class SimpleTokenizerV1:
    def __init__(self, vocab):
        self.str_to_int = vocab
        self.int_to_str = {idx: token for token, idx in vocab.items()}   # 역방향 표

    def encode(self, text):
        return [self.str_to_int[token] for token in tokenize(text)]

    def decode(self, ids):
        text = " ".join(self.int_to_str[idx] for idx in ids)
        return re.sub(r'\s+([,.?!"()\'])', r"\1", text)                 # 구두점 앞 공백 제거

set으로 중복을 없애고 sorted로 순서를 고정하는 것이 어휘의 전부입니다. decode의 정규식은 "공백 하나 이상 + 구두점"을 "구두점"으로 바꿉니다. 여러분의 코드는 역방향 표 없이 매번 어휘를 뒤졌을 수도 있고(느리지만 정답), 공백 제거를 replace 여덟 번으로 했을 수도 있습니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 decode를 정규식 없이 — 토큰을 하나씩 이어 붙이되 구두점이면 앞 공백을 생략하는 루프로 — 구현했고, 구두점 집합을 클래스 속성 PUNCT에 두었습니다. 기준 구현의 re.sub 한 줄과 다르지만 복원 텍스트는 " It' s …까지 똑같았습니다. 첫 시도에 통과.

step03. 특수 토큰 — 토크나이저 V2

무엇을 — 어휘에 없는 단어를 대신하는 토큰과 문서 사이의 경계를 표시하는 토큰을 어휘에 더한 토크나이저 V2
왜 — V1은 어휘에 없는 단어(Hello, palace)를 만나면 멈춘다. 실제 모델은 처음 보는 단어와 여러 문서를 이어 붙인 입력을 다뤄야 한다
맥락 — step02의 어휘·클래스를 확장한다. 문서 경계 토큰은 step04의 BPE와 5장의 훈련 데이터 준비에서 다시 만난다
기대 결과 — 어휘 크기: 1132, 소설에 없는 Hello와 palace가 모르는-단어 토큰(ID 1131)으로 바뀐다
hermes에게 전달되는 사양 (prompts/ch02/step03-special-tokens.md)
step03_special_tokens.py 라는 파이썬 파일을 만들어줘.

만들 것: 특수 토큰 두 개를 어휘에 더하고, 어휘에 없는 단어를 만나도 동작하는 토크나이저 V2.

특수 토큰 정의 (사양에는 조각으로 적는다 — 코드에서는 이어 붙인 완성 문자열을 쓴다):
- 문서 경계 토큰: 세 조각 <| + endoftext + |> 를 이어 붙인 문자열
- 모르는 단어 토큰: 세 조각 <| + unk + |> 를 이어 붙인 문자열

접점 (직전 스텝 재사용 + 이름을 정확히 지킨다):
- 같은 디렉토리의 step01_tokenize.py 에서 load_text(), tokenize(text) 를,
  step02_vocab.py 에서 build_vocab(tokens) 를 import 해서 쓴다. 새로 구현하지 않는다.
- build_vocab_v2(tokens) — build_vocab 으로 만든 어휘의 **뒤에** 문서 경계 토큰, 모르는 단어 토큰을 이 순서로
  덧붙여 반환한다 (기존 ID 는 그대로, 새 토큰은 이어지는 번호).
- 클래스 SimpleTokenizerV2 — 생성자에 어휘 딕셔너리를 받는다.
  - encode(text) — tokenize 로 나눈 뒤, 어휘에 없는 토큰은 모르는 단어 토큰으로 바꾸고 나서 ID 리스트를 반환한다.
  - decode(ids) — step02 의 V1 과 같은 규칙 (공백으로 잇고, , . ? ! " ( ) ' 바로 앞의 공백 제거).
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음 다섯 줄을 정확히 이 형식으로 출력한다):
- `어휘 크기: ` 뒤에 V2 어휘의 항목 수
- `어휘 뒤 2개: ` 뒤에 마지막 두 (토큰, ID) 쌍을 파이썬 리스트 표기로
- `입력 텍스트: ` 뒤에, 다음 두 문장을 문서 경계 토큰으로 이어 붙인 문자열 (문장, 공백, 경계 토큰, 공백, 문장 순):
  첫 문장 Hello, do you like tea?  둘째 문장 In the sunlit terraces of the palace.
- `토큰 ID: ` 뒤에 그 문자열을 V2 로 encode 한 ID 리스트
- `복원 텍스트: ` 뒤에 그 ID 리스트를 decode 한 문자열

지켜야 할 것:
- 표준 라이브러리만 사용한다. 외부 패키지 설치 금지.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
어휘 크기: 1132
어휘 뒤 2개: [('<|endoftext|>', 1130), ('<|unk|>', 1131)]
입력 텍스트: Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace.
토큰 ID: [1131, 5, 355, 1126, 628, 975, 10, 1130, 55, 988, 956, 984, 722, 988, 1131, 7]
복원 텍스트: <|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.
[OK] 4줄 일치 (입력 텍스트 줄은 눈으로 확인)   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch02/step03_special_tokens.py, 핵심):

END_OF_TEXT = "<|" + "endoftext" + "|>"   # 조립식 — 사양 규칙 9
UNKNOWN = "<|" + "unk" + "|>"

def build_vocab_v2(tokens):
    vocab = build_vocab(tokens)
    for special in (END_OF_TEXT, UNKNOWN):
        vocab[special] = len(vocab)       # 이어지는 번호: 1130, 1131
    return vocab

class SimpleTokenizerV2:
    def encode(self, text):
        tokens = tokenize(text)
        tokens = [token if token in self.str_to_int else UNKNOWN for token in tokens]
        return [self.str_to_int[token] for token in tokens]

V1과의 차이는 encode의 가운데 한 줄 — 어휘에 없으면 모르는-단어 토큰으로 바꾸는 것 — 뿐입니다. 기준 구현은 V1을 상속하지 않고 다시 썼는데, 여러분의 에이전트는 class SimpleTokenizerV2(SimpleTokenizerV1)로 상속해 encode만 덮어썼을 수도 있습니다. 둘 다 정답입니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 상속 대신 자기 V1 코드를 그대로 옮기고 encode의 조건식 한 줄만 바꿨습니다 — self.vocab[tok] if tok in self.vocab else self.vocab[UNKNOWN]. 특수 토큰 상수는 사양대로 조각을 이어 붙여 정의했고, 출력 다섯 줄은 기준 구현과 같았습니다. 첫 시도에 통과.

step04. GPT-2의 진짜 토크나이저 — BPE

무엇을 — tiktoken 라이브러리의 GPT-2 BPE 토크나이저로 텍스트를 ID로 바꾸고 되돌리는 스크립트
왜 — step03의 "모르는 단어" 문제를 진짜 모델은 단어를 더 작은 조각(서브워드)으로 쪼개는 BPE로 푼다. 이후 모든 장이 이 토크나이저를 쓴다
맥락 — step01~03의 단순 토크나이저(어휘 1,132)를 GPT-2의 것(어휘 50,257)으로 교체한다. step05~08이 이 파일의 접점을 import 한다
기대 결과 — 소설에 없는 someunknownPlace도, 뜻 없는 Akwirw ier도 모르는-단어 토큰 없이 조각으로 쪼개져 ID가 된다
hermes에게 전달되는 사양 (prompts/ch02/step04-bpe.md)
step04_bpe.py 라는 파이썬 파일을 만들어줘.

만들 것: tiktoken 의 GPT-2 인코딩(gpt2)으로 텍스트를 토큰 ID 로 바꾸고 되돌리는 스크립트.

특수 토큰: 문서 경계 토큰은 세 조각 <| + endoftext + |> 를 이어 붙인 문자열이다. 이 토큰이 텍스트 안에 있을 때
tiktoken 이 오류를 내지 않고 특수 토큰으로 인코딩하도록 허용해야 한다 (tiktoken 의 encode 에는 특수 토큰을 허용하는 인자가 있다).

접점 (다음 스텝들이 import 한다):
- get_gpt2_tokenizer() — 인자 없음. tiktoken 의 GPT-2 인코딩 객체를 반환한다.
- encode_gpt2(text) — 텍스트를 받아 GPT-2 토큰 ID 리스트를 반환한다 (문서 경계 토큰 허용).
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음 일곱 줄을 정확히 이 형식으로 출력한다):
- `어휘 크기: ` 뒤에 GPT-2 인코딩의 어휘 크기
- `토큰 ID: ` 뒤에, 다음 텍스트를 encode 한 ID 리스트 — Hello, do you like tea? 뒤에 공백, 문서 경계 토큰, 공백,
  In the sunlit terraces of someunknownPlace. 를 이어 붙인 한 문자열
- `토큰 수: ` 뒤에 그 리스트의 길이
- `복원 텍스트: ` 뒤에 그 ID 리스트를 decode 한 문자열
- `낯선 단어의 토큰 ID: ` 뒤에 문자열 Akwirw ier 를 encode 한 ID 리스트
- `조각 단위 복원: ` 뒤에 그 ID 를 하나씩 따로 decode 한 문자열들의 파이썬 리스트 표기
- `낯선 단어 복원: ` 뒤에 그 ID 리스트 전체를 decode 한 문자열

지켜야 할 것:
- tiktoken 과 표준 라이브러리만 사용한다 (tiktoken 은 이미 설치되어 있다). 다른 패키지 설치 금지.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
어휘 크기: 50257
토큰 ID: [15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 286, 617, 34680, 27271, 13]
토큰 수: 20
복원 텍스트: Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.
낯선 단어의 토큰 ID: [33901, 86, 343, 86, 220, 959]
조각 단위 복원: ['Ak', 'w', 'ir', 'w', ' ', 'ier']
낯선 단어 복원: Akwirw ier
[OK] 7줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch02/step04_bpe.py, 핵심):

def get_gpt2_tokenizer():
    return tiktoken.get_encoding("gpt2")

def encode_gpt2(text):
    tokenizer = get_gpt2_tokenizer()
    return tokenizer.encode(text, allowed_special={END_OF_TEXT})

allowed_special이 없으면 tiktoken은 텍스트 속 특수 토큰을 보고 오류를 냅니다(실수로 섞인 특수 토큰을 막으려는 안전장치). 사양은 "허용하는 인자가 있다"고만 말하고 이름을 주지 않았는데, 에이전트는 라이브러리 문서를 알고 있으니 찾아 씁니다 — 사양이 못 박을 것은 동작(오류 없이 특수 토큰으로 인코딩)이지 인자 이름이 아닙니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)가 만든 코드는 기준 구현과 거의 같았습니다 — tiktoken을 쓰는 길이 하나뿐이라 구현의 자유가 좁은 스텝입니다. 사양이 이름을 주지 않은 allowed_special 인자를 모델이 스스로 찾아 썼습니다. 첫 시도에 통과.

step05. 슬라이딩 윈도우 — 훈련 쌍 만들기

무엇을 — 토큰 ID 열에서 길이 4짜리 창을 잘라 입력 x와, 그것을 한 칸 민 타깃 y를 만드는 스크립트
왜 — LLM의 훈련 데이터는 "지금까지의 토큰 → 다음 토큰" 쌍이다. 그 쌍이 텍스트에서 어떻게 나오는지 눈으로 본다
맥락 — step04의 BPE로 소설 전체를 ID로 바꾼 뒤(5,145개) 창을 자른다. step06이 이 절차를 데이터셋 클래스로 일반화한다
기대 결과 — x: [290, 4920, 2241, 287], y: [4920, 2241, 287, 257] — y는 x를 정확히 한 칸 민 것
hermes에게 전달되는 사양 (prompts/ch02/step05-sliding-window.md)
step05_sliding_window.py 라는 파이썬 파일을 만들어줘.

만들 것: 소설을 GPT-2 토큰 ID 로 바꾼 뒤, 문맥 길이 4 의 창으로 "입력 → 다음 토큰" 훈련 쌍을 만들어 보여 주는 스크립트.

접점 (직전 스텝 재사용):
- 같은 디렉토리의 step01_tokenize.py 에서 load_text() 를, step04_bpe.py 에서 get_gpt2_tokenizer() 와
  encode_gpt2(text) 를 import 해서 쓴다. 토큰화를 새로 구현하지 않는다.
- make_pairs(ids, context_size) — ID 리스트와 문맥 길이를 받아 (x, y) 를 반환한다. x 는 앞에서 context_size 개,
  y 는 x 를 한 칸 민 것(두 번째 ID 부터 context_size 개). 둘 다 파이썬 리스트.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작:
- 소설 전체를 encode_gpt2 로 ID 리스트로 바꾼다.
- 앞 50개 ID 는 건너뛰고 51번째부터를 표본으로 삼는다. 문맥 길이는 4.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- `총 토큰 수: ` 뒤에 소설 전체의 ID 개수
- `x: ` 뒤에 표본에서 만든 x 리스트, `y: ` 뒤에 y 리스트 (파이썬 리스트 표기)
- 이어서 4줄: 표본의 앞 i 개 ID 리스트, 공백, ---->, 공백, 그 다음(i+1 번째) ID — i 는 1, 2, 3, 4.
  예: [290] ----> 4920
- 이어서 4줄: 같은 내용을 ID 대신 decode 한 텍스트로 — 앞 i 개를 decode 한 문자열, 공백, ---->, 공백, 다음 ID 하나를 decode 한 문자열.
  (decode 한 문자열이 공백으로 시작하면 그대로 둔다 — GPT-2 토큰은 앞 공백을 포함한다)

지켜야 할 것:
- tiktoken 과 표준 라이브러리만 사용한다.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
총 토큰 수: 5145
x: [290, 4920, 2241, 287]
y: [4920, 2241, 287, 257]
[290] ----> 4920
[290, 4920] ----> 2241
[290, 4920, 2241] ----> 287
[290, 4920, 2241, 287] ----> 257
 and ---->  established
 and established ---->  himself
 and established himself ---->  in
 and established himself in ---->  a
[OK] 11줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch02/step05_sliding_window.py, 핵심):

def make_pairs(ids, context_size):
    x = ids[:context_size]
    y = ids[1:context_size + 1]
    return x, y

for i in range(1, CONTEXT_SIZE + 1):
    context = enc_sample[:i]
    desired = enc_sample[i]
    print(context, "---->", desired)

슬라이싱 두 줄이 make_pairs의 전부입니다 — ids[1:5]가 "한 칸 민 것"입니다. 출력 루프에서 enc_sample[:i]는 "앞 i개", enc_sample[i]는 "그 다음 하나"입니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)가 만든 코드는 구조가 기준 구현과 거의 같았습니다 — 슬라이싱 두 줄과 루프 두 개 외에 다른 길이 별로 없는 스텝입니다. 변수 이름만 all_ids/sample로 달랐습니다. 첫 시도에 통과.

step06. 데이터셋과 데이터 로더

무엇을 — 소설 전체를 슬라이딩 윈도우로 잘라 두는 파이토치 데이터셋 클래스와, 그것을 배치 단위로 꺼내 주는 데이터 로더를 만드는 함수
왜 — 5장의 훈련 루프는 "배치 하나 꺼내기 → 손실 계산 → 갱신"의 반복이다. 배치를 꺼내는 부품이 바로 이 데이터 로더다
맥락 — step05의 make_pairs를 소설 전체에 반복 적용해 텐서로 쌓는다. step08과 5장이 create_dataloader를 import 한다
기대 결과 — 보폭 1이면 둘째 배치의 입력이 첫째 배치의 타깃과 같고, 보폭 4면 8×4 텐서 두 개(입력·타깃)가 나온다
hermes에게 전달되는 사양 (prompts/ch02/step06-dataloader.md)
step06_dataloader.py 라는 파이썬 파일을 만들어줘.

만들 것: 소설 텍스트를 슬라이딩 윈도우로 잘라 (입력, 타깃) 쌍을 만드는 파이토치 데이터셋과, 그것을 배치로 꺼내는 데이터 로더.

접점 (직전 스텝 재사용 + 다음 스텝·5장이 import 한다):
- 같은 디렉토리의 step01_tokenize.py 에서 load_text() 를, step04_bpe.py 에서 get_gpt2_tokenizer() 를 import 해서 쓴다.
- 클래스 GPTDataset(text, tokenizer, max_length, stride) — torch.utils.data.Dataset 을 상속한다.
  텍스트를 tokenizer 로 ID 리스트로 바꾼 뒤(문서 경계 토큰 <| + endoftext + |> 는 특수 토큰으로 허용),
  시작 위치 0 부터 stride 씩 밀며 길이 max_length 의 창을 잘라 (입력, 타깃) 쌍을 만든다. 타깃은 입력을 한 칸 민 것.
  창은 타깃까지 완전히 채워지는 것만 만든다. 각 항목은 (입력 텐서, 타깃 텐서) 순서쌍이고, 두 텐서는 길이 max_length 의
  정수 텐서(파이토치 기본 정수형 int64)다.
- create_dataloader(text, batch_size, max_length, stride, shuffle) — 위 데이터셋을 GPT-2 토크나이저로 만들고
  torch.utils.data.DataLoader 로 감싸 반환한다. 배치 크기에 못 미치는 마지막 자투리 배치는 버린다(drop_last).
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다. 텐서는 print(텐서) 의 기본 표기 그대로):
- 배치 크기 1, 문맥 길이 4, 보폭 1, 섞지 않음으로 로더를 만들어 첫 두 배치를 꺼내:
  `첫 배치 입력: ` 뒤에 입력 텐서, `첫 배치 타깃: ` 뒤에 타깃 텐서, `둘째 배치 입력: ` 뒤에 입력 텐서, `둘째 배치 타깃: ` 뒤에 타깃 텐서
- 배치 크기 8, 문맥 길이 4, 보폭 4, 섞지 않음으로 로더를 만들어 첫 배치를 꺼내:
  `배치 8 입력:` 한 줄 뒤 다음 줄부터 입력 텐서, `배치 8 타깃:` 한 줄 뒤 다음 줄부터 타깃 텐서
- `배치 수: ` 뒤에 그 로더의 배치 개수 (len(로더))

지켜야 할 것:
- torch, tiktoken, 표준 라이브러리만 사용한다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.

이 사양에는 파이토치 클래스 이름(Dataset, DataLoader)이 있습니다 — 접점입니다. 5장의 훈련 루프가 DataLoader를 받기로 되어 있고, 다른 무엇으로 대체할 수 없기 때문입니다. "정수형 int64"도 동작의 일부입니다 — 다른 정수형이면 텐서 출력에 dtype=torch.int32가 붙어 기대 출력과 달라지고, 4장의 임베딩 층도 int64를 요구합니다.

--- 검증 결과 (실측) ---
첫 배치 입력: tensor([[  40,  367, 2885, 1464]])
첫 배치 타깃: tensor([[ 367, 2885, 1464, 1807]])
둘째 배치 입력: tensor([[ 367, 2885, 1464, 1807]])
둘째 배치 타깃: tensor([[2885, 1464, 1807, 3619]])
배치 8 입력:
tensor([[   40,   367,  2885,  1464],
        [ 1807,  3619,   402,   271],
        [10899,  2138,   257,  7026],
        [15632,   438,  2016,   257],
        [  922,  5891,  1576,   438],
        [  568,   340,   373,   645],
        [ 1049,  5975,   284,   502],
        [  284,  3285,   326,    11]])
배치 8 타깃:
tensor([[  367,  2885,  1464,  1807],
        [ 3619,   402,   271, 10899],
        [ 2138,   257,  7026, 15632],
        [  438,  2016,   257,   922],
        [ 5891,  1576,   438,   568],
        [  340,   373,   645,  1049],
        [ 5975,   284,   502,   284],
        [ 3285,   326,    11,   287]])
배치 수: 160
[OK] 21줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch02/step06_dataloader.py, 핵심):

class GPTDataset(Dataset):
    def __init__(self, text, tokenizer, max_length, stride):
        self.inputs, self.targets = [], []
        ids = tokenizer.encode(text, allowed_special={END_OF_TEXT})
        for start in range(0, len(ids) - max_length, stride):
            self.inputs.append(torch.tensor(ids[start:start + max_length]))
            self.targets.append(torch.tensor(ids[start + 1:start + max_length + 1]))

    def __len__(self):
        return len(self.inputs)

    def __getitem__(self, idx):
        return self.inputs[idx], self.targets[idx]

def create_dataloader(text, batch_size, max_length, stride, shuffle):
    dataset = GPTDataset(text, get_gpt2_tokenizer(), max_length, stride)
    return DataLoader(dataset, batch_size=batch_size, shuffle=shuffle, drop_last=True)

파이토치 Dataset은 __len__(항목 수)과 __getitem__(i번째 항목) 두 메서드만 요구합니다. 기준 구현은 창을 생성자에서 전부 잘라 두는 방식(메모리는 더 쓰지만 단순)이고, 여러분의 에이전트는 __getitem__에서 그때그때 슬라이싱했을 수도 있습니다. torch.tensor(정수 리스트)가 int64 텐서를 만드는 것이 기본값이라 dtype을 따로 적지 않았습니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 정확히 그 다른 길을 택했습니다 — 창을 파이썬 리스트로 잘라 두고 __getitem__에서 torch.tensor(..., dtype=torch.int64)로 바꿨습니다. 사양의 "int64"를 dtype 인자로 명시한 것도 기준 구현과 다른 점입니다. 21줄 출력이 텐서 정렬 표기까지 같았고, 첫 시도에 통과.

step07. 토큰 임베딩

무엇을 — 어휘 6개·차원 3의 장난감 임베딩 층을 만들어 가중치 행렬을 보고, ID 하나와 ID 네 개의 임베딩을 꺼내 보는 스크립트
왜 — 토큰 ID는 그냥 번호라 크기 비교에 의미가 없다. 신경망이 다룰 수 있는 것은 실수 벡터이고, 임베딩 층이 그 변환표다
맥락 — step06까지는 텍스트 → ID. 이 스텝부터 ID → 벡터. step08이 같은 층을 어휘 50,257·차원 256으로 키운다
기대 결과 — ID 3의 임베딩이 가중치 행렬의 4번째 행([-0.4015, 0.9666, -1.1481])과 정확히 같다 — 임베딩은 "행 꺼내기"다
hermes에게 전달되는 사양 (prompts/ch02/step07-token-embedding.md)
step07_token_embedding.py 라는 파이썬 파일을 만들어줘.

만들 것: 작은 임베딩 층(어휘 6개, 차원 3)을 만들고, 토큰 ID 를 벡터로 바꾸는 것이 "행 꺼내기"임을 보여 주는 스크립트.

접점:
- make_embedding(vocab_size, output_dim, seed) — 파이토치 난수 시드를 seed 로 고정한 **직후에** torch.nn.Embedding(vocab_size, output_dim)
  을 만들어 반환한다. 시드 고정과 층 생성 사이에 난수를 쓰는 코드가 있어서는 안 된다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 어휘 크기 6, 차원 3, 시드 123 으로 층을 만든다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다. 텐서는 print(텐서) 의 기본 표기 그대로):
- `가중치 행렬 모양: ` 뒤에 가중치의 모양 (torch.Size([6, 3]) 형식)
- `가중치 행렬:` 한 줄 뒤 다음 줄부터 가중치 파라미터 전체 (print(층.weight))
- `ID 3 의 임베딩:` 한 줄 뒤 다음 줄부터, 정수 텐서 [3] 을 층에 넣은 결과
- `ID [2, 3, 5, 1] 의 임베딩:` 한 줄 뒤 다음 줄부터, 정수 텐서 [2, 3, 5, 1] 을 층에 넣은 결과

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
가중치 행렬 모양: torch.Size([6, 3])
가중치 행렬:
Parameter containing:
tensor([[ 0.3374, -0.1778, -0.1690],
        [ 0.9178,  1.5810,  1.3010],
        [ 1.2753, -0.2010, -0.1606],
        [-0.4015,  0.9666, -1.1481],
        [-1.1589,  0.3255, -0.6315],
        [-2.8400, -0.7849, -1.4096]], requires_grad=True)
ID 3 의 임베딩:
tensor([[-0.4015,  0.9666, -1.1481]], grad_fn=<EmbeddingBackward0>)
ID [2, 3, 5, 1] 의 임베딩:
tensor([[ 1.2753, -0.2010, -0.1606],
        [-0.4015,  0.9666, -1.1481],
        [-2.8400, -0.7849, -1.4096],
        [ 0.9178,  1.5810,  1.3010]], grad_fn=<EmbeddingBackward0>)
[OK] 12줄 전부 일치   →   ✅ 검증 통과

requires_grad=True는 "이 행렬은 훈련으로 갱신된다"는 표시이고, grad_fn=<EmbeddingBackward0>는 "이 결과가 임베딩 조회에서 나왔으니 역전파 때 그리로 기울기를 보내라"는 기록입니다. 부록 A의 자동 미분에서 정면으로 배웁니다.

기준 구현 읽기 (solutions/ch02/step07_token_embedding.py, 핵심):

def make_embedding(vocab_size, output_dim, seed):
    torch.manual_seed(seed)
    return torch.nn.Embedding(vocab_size, output_dim)

embedding_layer = make_embedding(6, 3, seed=123)
print(embedding_layer(torch.tensor([3])))

시드 고정과 층 생성이 붙어 있습니다. 여러분의 코드가 시드 고정을 __main__ 블록에서 하고 함수는 층만 만들었다면, 그 사이에 난수 소비가 없는 한 값은 같습니다 — 하지만 사양은 "직후에"를 접점 조건으로 못 박았으므로 함수 안에서 고정하는 것이 사양을 지키는 구현입니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 함수 안에서 시드를 고정하고 바로 다음 줄에 층을 만들었습니다 — "사이에 난수 코드 없음"이라는 주석까지 달았습니다. 가중치 값 열두 줄이 기준 구현과 소수점 넷째 자리까지 같았고, 첫 시도에 통과.

step08. 위치 임베딩을 더해 입력 임베딩 완성하기

무엇을 — GPT-2 크기의 토큰 임베딩(50,257 × 256)과 위치 임베딩(4 × 256)을 만들고, step06의 배치에 적용해 입력 임베딩(8 × 4 × 256)을 만드는 스크립트
왜 — 토큰 임베딩만으로는 "같은 단어가 몇 번째 자리에 있는지"를 모델이 알 수 없다. 위치 임베딩을 더해야 순서 정보가 들어간다
맥락 — 2장의 마지막 조각. step06의 로더 + step07의 임베딩 층을 실전 크기로 합친다. 4장의 GPT 모델 첫 부분이 정확히 이것이다
기대 결과 — 입력 임베딩 형태: torch.Size([8, 4, 256]) — 배치 8, 문맥 4, 차원 256의 3차원 텐서가 4장 모델의 입력이다
hermes에게 전달되는 사양 (prompts/ch02/step08-input-embedding.md)
step08_input_embedding.py 라는 파이썬 파일을 만들어줘.

만들 것: GPT-2 크기의 토큰 임베딩과 위치 임베딩을 만들어, 데이터 로더의 첫 배치를 입력 임베딩으로 바꾸는 스크립트.

접점 (직전 스텝 재사용):
- 같은 디렉토리의 step01_tokenize.py 에서 load_text() 를, step06_dataloader.py 에서 create_dataloader(text, batch_size, max_length, stride, shuffle) 를
  import 해서 쓴다. 데이터셋과 로더를 새로 구현하지 않는다.
- build_input_embeddings(token_ids, token_embedding_layer, pos_embedding_layer) — 토큰 ID 배치(모양 (배치, 문맥))와 두 임베딩 층을 받아,
  토큰 임베딩에 위치 임베딩(위치 0, 1, …, 문맥 길이−1 의 벡터)을 더한 텐서(모양 (배치, 문맥, 차원))를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작:
- 파이토치 시드를 123 으로 고정한 뒤, 토큰 임베딩 층 torch.nn.Embedding(50257, 256) 과 위치 임베딩 층 torch.nn.Embedding(4, 256) 을 만든다.
- 소설 텍스트로 배치 크기 8, 문맥 길이 4, 보폭 4, 섞지 않는 로더를 만들어 첫 배치의 입력(토큰 ID)을 꺼낸다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다. 라벨과 값 사이의 공백은 정확히 하나다):
- `토큰 ID:` 한 줄 뒤 다음 줄부터 첫 배치의 입력 텐서 (print(텐서) 기본 표기)
- 그 텐서의 모양을 `토큰 ID 형태: torch.Size([8, 4])` 처럼 — 라벨, 콜론, 공백 하나, 모양
- 토큰 임베딩 층을 통과한 결과의 모양을 `토큰 임베딩 형태: torch.Size([8, 4, 256])` 처럼
- 위치 0~3 의 위치 임베딩 텐서의 모양을 `위치 임베딩 형태: torch.Size([4, 256])` 처럼
- build_input_embeddings 결과의 모양을 `입력 임베딩 형태: torch.Size([8, 4, 256])` 처럼
  (위 예시의 숫자는 실제 값과 같다 — 계산 결과가 이와 다르면 구현이 틀린 것이다)

지켜야 할 것:
- torch, tiktoken, 표준 라이브러리만 사용한다.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.

이 스텝의 기대 출력은 모양만 대조합니다. 50,257 × 256 행렬의 값을 볼 이유가 없고, 시드를 걸어도 두 층을 만드는 순서나 다른 난수 소비에 따라 값이 달라질 수 있어서 계약에 넣지 않습니다. 반면 토큰 ID 배치는 섞지 않은 첫 배치라 구현과 무관하게 같으므로 대조합니다 — 흔들리는 값은 빼고, 흔들리지 않는 값만 넣는 계약 설계 규칙 그대로입니다.

--- 검증 결과 (실측) ---
토큰 ID:
tensor([[   40,   367,  2885,  1464],
        [ 1807,  3619,   402,   271],
        [10899,  2138,   257,  7026],
        [15632,   438,  2016,   257],
        [  922,  5891,  1576,   438],
        [  568,   340,   373,   645],
        [ 1049,  5975,   284,   502],
        [  284,  3285,   326,    11]])
토큰 ID 형태: torch.Size([8, 4])
토큰 임베딩 형태: torch.Size([8, 4, 256])
위치 임베딩 형태: torch.Size([4, 256])
입력 임베딩 형태: torch.Size([8, 4, 256])
[OK] 12줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch02/step08_input_embedding.py, 핵심):

def build_input_embeddings(token_ids, token_embedding_layer, pos_embedding_layer):
    token_embeddings = token_embedding_layer(token_ids)          # (8, 4, 256)
    positions = torch.arange(token_ids.shape[1])                 # tensor([0, 1, 2, 3])
    pos_embeddings = pos_embedding_layer(positions)              # (4, 256)
    return token_embeddings + pos_embeddings                     # 브로드캐스팅 → (8, 4, 256)

torch.arange(4)가 위치 0~3이고, 그것을 위치 임베딩 층에 넣으면 (4, 256)이 나옵니다. 마지막 줄의 덧셈에서 파이토치가 (4, 256)을 (8, 4, 256)에 맞춰 늘려 더합니다(손계산 ⑤). 여러분의 코드가 unsqueeze(0)으로 배치 차원을 직접 붙였어도 결과 모양은 같습니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)의 build_input_embeddings는 기준 구현과 같은 세 줄이었습니다. 첫 시도는 계산은 다 맞았는데 출력 라벨 뒤에 공백이 두 개 찍혀 실패했습니다 — 사양이 라벨을 백틱 안에 끝 공백을 넣어 적었고, 에이전트가 그 공백을 문자열에 넣은 채 print(라벨, 값)을 써서 공백이 겹친 것입니다. 사양의 출력 줄을 토큰 ID 형태: torch.Size([8, 4])처럼 완성된 예시로 바꾸니 통과했습니다. 출력 형식도 동작이고, 예시 한 줄이 설명 한 문단보다 정확합니다.

여덟 스텝을 다 통과했다면 verify.sh ch02 stepNN --solution과 diff로 기준 구현과 비교해 보세요. 이번 장은 스텝이 많아 차이도 많을 것입니다 — 0장의 표(같아야 하는 것 / 달라도 되는 것)를 기준으로 분류하면, 왼쪽 열(접점·출력·제약)은 여덟 스텝 모두 같고 오른쪽 열만 다르다는 것을 확인할 수 있습니다.

용어 정리

용어원어(약어)한 줄 정의
토큰화tokenization텍스트를 토큰 단위로 쪼개는 것. 규칙에 따라 토큰 수가 달라진다
어휘vocabulary토큰 ↔ ID를 잇는 표. 이 장에서 1,130 → 1,132 → 50,257
토큰 IDtoken ID어휘에서 토큰에 붙인 정수 번호. 크기 비교에 의미가 없다
인코딩 / 디코딩encode / decode텍스트 → ID 리스트 / ID 리스트 → 텍스트
특수 토큰special token모르는 단어·문서 경계처럼 텍스트 자체가 아닌 표시용 토큰
모르는 단어 토큰unknown token (unk)어휘에 없는 토큰 자리에 넣는 특수 토큰. GPT-2에는 없다
문서 경계 토큰end-of-text token이어 붙인 문서들의 경계. GPT-2의 ID 50256
BOS / EOS / PADbeginning/end of sequence, padding다른 모델이 쓰는 특수 토큰들. GPT-2는 경계 토큰이 겸한다
바이트 페어 인코딩byte pair encoding (BPE)자주 붙는 쌍을 반복 병합해 어휘를 만드는 서브워드 토큰화
서브워드subword단어보다 작은 토큰 조각 (some / unknown / Place)
tiktokentiktokenOpenAI의 BPE 토크나이저 라이브러리. GPT-2 인코딩을 내려받아 쓴다
슬라이딩 윈도우sliding window토큰 열 위에서 고정 길이 창을 밀며 (입력, 타깃) 쌍을 만드는 방법
문맥 길이context length (max_length)창의 길이. 모델이 한 번에 보는 토큰 수
보폭stride창을 미는 간격. 1이면 겹치고, 문맥 길이와 같으면 겹치지 않는다
입력 / 타깃input / target창 안의 토큰 / 그것을 한 칸 민 토큰 (각 자리의 정답)
데이터셋Dataseti번째 항목을 돌려주는 파이토치 객체 (__len__, __getitem__)
데이터 로더DataLoader데이터셋의 항목을 배치로 묶어 꺼내 주는 파이토치 객체
배치batch한 번에 처리하는 항목 묶음. 실습은 8
drop_lastdrop_last배치 크기에 못 미치는 마지막 자투리를 버리는 로더 옵션
임베딩embedding토큰 ID를 실수 벡터로 바꾸는 것, 또는 그 벡터
임베딩 층embedding layer(어휘 크기 × 차원) 가중치 행렬. ID번째 행을 꺼낸다
원-핫 벡터one-hot vector한 자리만 1인 벡터. 원-핫 × 행렬 = 행 꺼내기
위치 임베딩positional embedding자리마다 다른 벡터. 토큰 임베딩에 더해 순서 정보를 넣는다
절대 위치 임베딩absolute positional embedding0번, 1번, … 자리 벡터를 훈련으로 배우는 방식 (GPT)
입력 임베딩input embedding토큰 임베딩 + 위치 임베딩. GPT 모델의 진짜 입력
브로드캐스팅broadcasting모양이 다른 텐서를 더할 때 작은 쪽을 자동으로 늘리는 규칙
시드seed난수 생성기의 시작값. 같은 시드 + 같은 위치 = 같은 난수
Word2VecWord2Vec2013년의 단어 임베딩 방법. 문맥이 비슷한 단어를 가까운 벡터로

정리

  • 텍스트 → 토큰 → ID → 벡터. 이 세 변환이 2장 전체이고, 실습 8스텝이 그 순서대로 놓여 있다.
  • 단순 토크나이저(구두점 분리 + 정렬 어휘)는 만들기 쉽지만 모르는 단어에 막힌다. 특수 토큰은 임시방편이고, BPE(서브워드)가 근본 해결책이다 — GPT-2는 어휘 50,257의 BPE를 쓰고 모르는 단어가 없다.
  • 훈련 쌍은 슬라이딩 윈도우로 만든다: 타깃은 입력을 한 칸 민 것. 데이터셋·로더가 이것을 배치로 꺼낸다.
  • 임베딩은 행 꺼내기(= 원-핫 × 행렬)이고, 위치 임베딩을 더해야 순서 정보가 생긴다. 결과 (8, 4, 256)이 4장의 입력이다.
  • 사양 설계: 분리 규칙·정수형·시드 위치·drop_last는 동작이라 못 박았고, 큰 행렬의 값은 흔들릴 수 있어 검증에서 뺐다.

다음 장 예고

3장은 이 강좌에서 가장 중요한 장, 어텐션(attention)입니다. 2장 끝의 (8, 4, 256) 텐서는 토큰마다 벡터 하나씩이지만, 아직 토큰들이 서로를 보지 못합니다 — it이 cat을 가리킨다는 것을 벡터에 담을 방법이 없습니다. 어텐션은 "각 토큰이 문맥의 다른 토큰들을 얼마나 참고할지"를 계산해 벡터를 문맥에 맞게 고쳐 씁니다. 1장의 바이그램이 the picture above를 맴돌던 이유(문맥 1단어)가 정면으로 해결되는 장입니다. 단순 어텐션 → 훈련 가능한 어텐션 → 인과 마스크 → 멀티헤드까지, 사양 여덟 개로 만듭니다.


Chapter 03 전 스텝 검증 완료 (9/9, 사양 수정 0회)

3장. 어텐션 메커니즘 — 토큰들이 서로를 보게 하다

이 장에서 배우는 것

  • 문맥이 길어지면 왜 "직전 토큰만 보기"로는 안 되는지, 어텐션이 그 문제를 어떻게 푸는지
  • 어텐션의 뼈대 세 단계: 내적으로 점수 → 소프트맥스로 비율 → 가중합으로 문맥 벡터
  • 훈련 가능한 질의·키·값 투영, 스케일링(√d_k), nn.Linear로 만든 SelfAttention
  • 미래를 가리는 인과 마스크, 과적합을 막는 드롭아웃, 배치를 처리하는 CausalAttention
  • 헤드 여러 개를 나란히 두는 멀티헤드 — 래퍼 방식과 GPT-2가 실제로 쓰는 효율적 방식
  • 실습 9스텝: 접점으로 이어진 사양 9개, 기준 구현 9개, 3장 끝에는 4장 트랜스포머 블록에 끼울 MultiHeadAttention이 손에 있다

들어가며 — 2장에서 3장으로

2장 끝에서 여러분은 (8, 4, 256) 입력 임베딩을 만들었습니다. 토큰마다 벡터 하나씩 — 하지만 그 벡터들은 아직 서로를 모릅니다. The cat sat on the mat because it was tired에서 it의 벡터는 cat을 가리킨다는 정보를 담을 길이 없습니다. 위치 임베딩이 "몇 번째 자리"는 알려 주지만 "누구와 관계있는지"는 알려 주지 않으니까요.

1장의 바이그램 모델이 of the picture above the picture above…를 맴돌던 이유를 기억하세요 — 문맥이 직전 1단어뿐이었습니다. 이번 장의 어텐션(attention)은 그 정반대입니다: 각 토큰이 문맥의 모든 토큰을 보고, 누구를 얼마나 참고할지를 숫자로 정해, 자기 벡터를 문맥에 맞게 고쳐 씁니다. 이 장은 강좌에서 가장 중요한 장이고, 가장 많은 손계산이 있는 장입니다. 아홉 스텝을 차례로 쌓아 올리면 4장 GPT 모델의 심장인 MultiHeadAttention이 완성됩니다.

3.1 긴 문맥의 문제 — 왜 직전 토큰만으로는 안 되는가

어텐션 이전의 언어 모델(순환 신경망, RNN)은 문장을 왼쪽에서 오른쪽으로 읽으며 하나의 상태 벡터에 지금까지의 내용을 눌러 담았습니다. 번역기라면 인코더가 입력 문장 전체를 벡터 하나로 요약하고, 디코더가 그 벡터만 보고 출력을 씁니다.

flowchart LR
    subgraph RNN["RNN 번역기 — 병목"]
        direction LR
        a["The"] --> b["cat"] --> c["sat"] --> d["…"] --> H["상태 벡터 하나
(문장 전체를 압축)"] H --> O["출력"] end subgraph ATT["어텐션 — 필요한 토큰을 직접 본다"] direction LR a2["The"] -.-> O2["출력의 각 토큰"] b2["cat"] ==>|"많이"| O2 c2["sat"] -.-> O2 d2["…"] -.-> O2 end
그림 3-1. 병목과 어텐션. 왼쪽은 문장 전체를 벡터 하나에 눌러 담아야 해서 긴 문장일수록 앞부분이 지워진다. 오른쪽은 출력을 만들 때마다 입력의 어떤 토큰을 볼지 직접 고른다 — 굵은 화살표가 "많이 본다".

문장이 길어지면 벡터 하나에 다 담기지 않습니다 — 앞부분이 희미해지고, 멀리 떨어진 두 단어(it과 cat)의 관계를 붙잡기 어렵습니다. 2014년에 제안된 어텐션은 "출력을 만들 때 입력의 어느 부분을 볼지 매번 다시 고르자"는 아이디어였고, 2017년 트랜스포머는 "순환을 아예 버리고 어텐션만으로 하자"였습니다. 1장 1.4절에서 미뤄 둔 이야기가 여기서 이어집니다.

3.2 어텐션이란 — 참고할 비율을 숫자로

어텐션의 산출물은 각 토큰이 다른 각 토큰을 얼마나 참고할지를 나타내는 비율 표(가중치)와, 그 비율로 다른 토큰 벡터들을 섞어 만든 새 벡터(문맥 벡터)입니다.

flowchart LR
    subgraph IN["입력 토큰 벡터"]
        Y["Your"]
        J["journey"]
        S["starts"]
        W["with"]
        O["one"]
        T["step"]
    end
    J ==>|"0.24 (자기 자신)"| C["journey 의
문맥 벡터"] S ==>|"0.23"| C Y -->|"0.14"| C T -->|"0.16"| C W -.->|"0.12"| C O -.->|"0.11"| C
그림 3-2. "journey" 토큰의 어텐션. 여섯 토큰 각각을 얼마나 볼지(가중치, 합 1)가 정해지고, 그 비율로 토큰 벡터들을 섞은 것이 journey의 새 벡터(문맥 벡터)다. 실습 step01의 실제 값이다.

이 장의 재료는 원서와 같은 장난감 문장 Your journey starts with one step — 여섯 토큰을 3차원 벡터로 적은 (6, 3) 텐서입니다. 3차원인 이유는 손으로 계산할 수 있어서이고, 실제 GPT-2는 768차원입니다. 원리는 같습니다.

토큰벡터
Your[0.43, 0.15, 0.89]
journey[0.55, 0.87, 0.66]
starts[0.57, 0.85, 0.64]
with[0.22, 0.58, 0.33]
one[0.77, 0.25, 0.10]
step[0.05, 0.80, 0.55]

3.3 단순 어텐션 — 훈련 가중치 없이 (step01, step02)

가장 단순한 어텐션은 훈련할 것이 하나도 없습니다. 세 단계입니다.

flowchart LR
    Q["질의 토큰 벡터
(journey)"] --> D["① 내적
여섯 토큰 각각과"] D --> S["어텐션 점수 6개
[0.95, 1.50, 1.48, 0.84, 0.71, 1.09]"] S --> SM["② 소프트맥스"] SM --> Wt["어텐션 가중치 6개
합 = 1"] Wt --> WS["③ 가중합
가중치 × 토큰 벡터"] WS --> C["문맥 벡터
[0.44, 0.65, 0.57]"]
그림 3-3. 단순 어텐션의 세 단계. 내적으로 "얼마나 닮았나"를 재고, 소프트맥스로 합이 1인 비율로 바꾸고, 그 비율로 벡터를 섞는다.

손계산 ① 내적 — 얼마나 닮았나

내적(dot product)은 두 벡터의 같은 자리끼리 곱해 더한 값입니다. 질의 journey [0.55, 0.87, 0.66]과 Your [0.43, 0.15, 0.89]:

0.55 × 0.43 + 0.87 × 0.15 + 0.66 × 0.89 = 0.2365 + 0.1305 + 0.5874 = 0.9544

step01의 첫 번째 어텐션 점수 0.9544가 이 값입니다. journey와 자기 자신의 내적은 0.55² + 0.87² + 0.66² = 1.4950으로 가장 크고, starts [0.57, 0.85, 0.64]와의 내적은 1.4754로 그 다음입니다 — 두 벡터가 닮았기 때문입니다. 내적은 "같은 방향을 얼마나 향하는가"의 척도이고, 어텐션은 이것을 "얼마나 참고할 것인가"의 점수로 씁니다.

손계산 ② 소프트맥스 — 점수를 비율로

점수를 그대로 가중치로 쓰면 합이 1이 아니고 음수가 될 수도 있습니다. 소프트맥스(softmax)는 각 점수의 지수를 전체 지수 합으로 나눕니다:

softmax(s)i = esiΣj esj

점수 세 개 [2, 1, 0]으로 해 봅시다. e² ≈ 7.39, e¹ ≈ 2.72, e⁰ = 1, 합 ≈ 11.11:

[7.39/11.11, 2.72/11.11, 1/11.11] = [0.665, 0.245, 0.090]      합 = 1

지수 덕에 큰 점수는 더 크게, 작은 점수는 더 작게 벌어지고, 전부 양수이며 합이 1입니다. step01에서 점수 [0.9544, 1.4950, …]에 이 계산을 하면 가중치 [0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581]이 됩니다 — 그림 3-2의 숫자입니다.

③ 가중합은 가중치를 계수로 여섯 벡터를 더하는 것입니다: 0.1385 × Your + 0.2379 × journey + … = [0.4419, 0.6515, 0.5683]. 이것이 journey의 문맥 벡터 — 원래 벡터 [0.55, 0.87, 0.66]이 이웃들을 반영해 조금 이동한 것입니다.

모든 토큰에 한꺼번에 — 행렬 곱 두 번 (step02)

질의를 토큰 하나가 아니라 여섯 개 전부로 하면, 점수는 6×6 행렬이 됩니다 — i행 j열은 "i번째 토큰이 j번째 토큰을 보는 점수". 이것은 입력 행렬 X와 그 전치 Xᵀ의 곱 한 번으로 나옵니다. 소프트맥스는 행마다 적용하고, 문맥 벡터 행렬은 가중치 행렬과 X의 곱입니다.

flowchart LR
    X["X (6×3)"] --> M1["X @ Xᵀ"]
    M1 --> S6["점수 (6×6)"]
    S6 --> SM["행마다 소프트맥스"]
    SM --> W6["가중치 (6×6)
각 행의 합 = 1"] W6 --> M2["W @ X"] X --> M2 M2 --> C6["문맥 벡터 (6×3)"]
그림 3-4. 행렬 버전. 루프 없이 행렬 곱 두 번과 소프트맥스 한 번이다. 가중치 행렬의 두 번째 행이 step01의 가중치와 같다.

step02의 계약에는 이 사실이 교차 검산으로 들어 있습니다: 문맥 벡터 행렬의 두 번째 행이 step01의 문맥 벡터와 정확히 같아야 합니다.

3.4 훈련 가능한 가중치 — 질의·키·값 (step03, step04)

단순 어텐션은 "닮은 토큰을 본다"만 할 수 있습니다. 하지만 it이 cat을 봐야 하는 이유는 두 벡터가 닮아서가 아니라 문법적으로 연결되어서입니다. 그런 관계는 데이터에서 배워야 하고, 배우려면 훈련할 가중치가 있어야 합니다.

해결책은 입력 벡터를 세 가지 역할로 투영(projection)하는 가중치 행렬 세 개를 두는 것입니다. 검색 시스템의 비유를 씁니다.

역할비유만드는 법쓰임
질의(query, Q)내가 찾는 것X @ W_q점수 계산의 "묻는 쪽"
키(key, K)각 토큰이 내세우는 이름표X @ W_k점수 계산의 "대답하는 쪽"
값(value, V)각 토큰이 실제로 건네주는 내용X @ W_v가중합의 재료
flowchart LR
    X["입력 X (6×3)"] --> Wq["W_query (3×2)"] --> Q["질의 Q (6×2)"]
    X --> Wk["W_key (3×2)"] --> K["키 K (6×2)"]
    X --> Wv["W_value (3×2)"] --> V["값 V (6×2)"]
    Q --> SC["점수 = Q @ Kᵀ (6×6)"]
    K --> SC
    SC --> SD["÷ √d_k, 소프트맥스"]
    SD --> W["가중치 (6×6)"]
    W --> CV["문맥 벡터 = W @ V (6×2)"]
    V --> CV
그림 3-5. 훈련 가능한 어텐션. 세 행렬이 입력을 세 역할로 바꾸고, 점수는 질의와 키 사이에서, 가중합은 값으로 한다. 세 행렬이 훈련으로 배우는 파라미터다.

세 단계는 3.3절과 같고 재료만 바뀝니다: 점수 = Q와 K의 내적, 문맥 벡터 = 가중치로 V를 가중합. 수식으로 한 줄:

Attention(Q, K, V) = softmax(QKT√dk) V

새로 들어간 것이 하나 있습니다 — 점수를 키 차원 d_k의 제곱근으로 나누는 스케일링. 이름 "스케일드 닷 프로덕트 어텐션"의 그 스케일입니다.

손계산 ③ 왜 √d_k로 나누는가

차원이 커지면 내적의 크기가 커집니다(더하는 항이 많아지니까). 점수가 커지면 소프트맥스가 극단으로 치우칩니다. 점수 [8, 0, 0]을 그대로 소프트맥스하면

e⁸ ≈ 2981, e⁰ = 1, 1  →  [2981/2983, 1/2983, 1/2983] ≈ [0.9993, 0.0003, 0.0003]

거의 원-핫 — 한 토큰만 보고 나머지는 무시합니다. 이러면 기울기가 거의 0이 되어 훈련이 멈춥니다. d_k = 64라면 √64 = 8로 나눠 [1, 0, 0]:

e¹ ≈ 2.72, 1, 1  →  [2.72/4.72, 1/4.72, 1/4.72] ≈ [0.576, 0.212, 0.212]

여전히 첫 토큰을 가장 많이 보지만 나머지도 봅니다. 실습(d_k = 2)에서는 √2 ≈ 1.41로 나누며, step03의 가중치 [0.1500, 0.2264, …]가 이 나눗셈을 거친 값입니다.

step03 → step04: 난수 행렬에서 nn.Linear로

step03은 torch.rand로 W_q, W_k, W_v를 만들어 두 번째 토큰의 문맥 벡터 [0.3061, 0.8210]을 계산합니다. 시드 123 직후 질의 → 키 → 값 순서로 만들어야 이 값이 나옵니다 — 순서가 바뀌면 같은 시드라도 다른 행렬이 나오니, 사양은 순서를 계약으로 못 박습니다. step04는 같은 계산을 nn.Module 클래스에 담고 행렬을 nn.Linear(d_in, d_out, bias=False) 층으로 바꿉니다. nn.Linear는 (d_out × d_in) 가중치를 가진 층으로, 층(x)가 x @ 가중치ᵀ를 계산합니다 — step03의 x @ W와 같은 수학이지만 초기값 분포가 더 좋고 model.parameters()에 자동 등록되어 훈련됩니다. 시드 789로 만든 인스턴스의 출력 첫 행 [-0.0739, 0.0713]이 원서의 SelfAttention_v2와 같은 값입니다.

3.5 인과 마스크 — 미래를 가린다 (step05)

GPT는 다음 토큰을 예측합니다. 훈련 데이터에는 다음 토큰이 이미 있으니, 어텐션이 그것을 보면 답을 베끼는 셈입니다. 그래서 각 토큰이 자기와 그 앞의 토큰만 보게 대각선 위(미래 자리)를 가립니다 — 인과 마스크(causal mask)입니다.

flowchart TB
    subgraph M["6×6 가중치에서 보이는 자리 (행 = 보는 토큰, 열 = 보이는 토큰)"]
        r1["Your → Your"]
        r2["journey → Your, journey"]
        r3["starts → Your, journey, starts"]
        r4["with → 앞 4개"]
        r5["one → 앞 5개"]
        r6["step → 전부 6개"]
    end
그림 3-6. 인과 마스크. 첫 토큰은 자기만, 마지막 토큰은 전부 본다. 가중치 행렬로 보면 대각선 위가 0인 하삼각 행렬이 된다.

가리는 방법은 두 가지이고, step05는 둘 다 계산해 같음을 확인합니다.

손계산 ④ 0을 곱하고 정규화 = −∞로 채우고 소프트맥스

세 번째 토큰(starts)의 행을 봅시다. 마스크 전 가중치가 [0.2036, 0.1659, 0.1662, 0.1498, 0.1664, 0.1480]이라 합시다(실습 값).

  • 방법 ① 뒤 세 자리를 0으로 곱하면 [0.2036, 0.1659, 0.1662, 0, 0, 0], 합 0.5357로 나누면 [0.3800, 0.3097, 0.3103, 0, 0, 0].
  • 방법 ② 소프트맥스 전에 점수의 뒤 세 자리를 −∞로 바꾸면 e^(−∞) = 0이라 분모에서 사라지고, 앞 세 항의 비율은 원래 소프트맥스의 앞 세 항 비율과 같으므로 결과는 [0.3800, 0.3097, 0.3103, 0, 0, 0] — 방법 ①과 같습니다.

수학적으로 같지만 ②는 한 번에 끝나고 "정규화"라는 추가 단계가 없어 실제 구현이 쓰는 방법입니다. step05의 마지막 줄 두 방법이 같은가: True가 이 손계산의 검증입니다. 그리고 마지막 행(step)은 여섯 토큰을 전부 보니 마스크 전과 같은 값 [0.1935, 0.1663, …] 그대로입니다 — 마스크는 가리기만 할 뿐, 보이는 토큰들 사이의 비율은 건드리지 않습니다.

3.6 드롭아웃 — 일부를 무작위로 끈다 (step06)

드롭아웃(dropout)은 훈련 중 어떤 값들을 무작위로 0으로 만드는 기법입니다. 어텐션 가중치에 적용하면 모델이 특정 토큰 하나에만 기대는 것을 막아 과적합(overfitting) — 훈련 데이터만 외우고 새 데이터에 약해지는 것 — 을 줄입니다. 추론 때는 끕니다.

손계산 ⑤ 남은 값이 2배가 되는 이유

비율 0.5로 원소 절반을 끄면 전체 합이 절반으로 줄어듭니다. 그러면 훈련 때와 추론 때(드롭아웃 없음)의 값 크기가 달라져 모델이 혼란스러워집니다. 그래서 남은 값을 1/(1−0.5) = 2배로 키웁니다. 가중치 [0.38, 0.31, 0.31]에서 둘째가 꺼졌다면 [0.76, 0, 0.62] — 합은 1.38로 원래(1.0)와 다르지만, 기대값(꺼질 자리를 무작위로 고르는 것을 평균하면)은 1.0으로 유지됩니다.

이 스텝의 계약은 특이합니다. 어느 자리가 꺼지는지는 난수이고, 같은 시드라도 파이토치 버전에 따라 다를 수 있습니다 — 이 강좌 환경(torch 2.13)의 패턴은 원서와 두 행이 다릅니다. 그래서 자리(값)를 대조하지 않고 드롭아웃이 지키는 규칙 세 가지를 대조합니다: 남은 값은 전부 2.0, 가려진 미래 자리는 여전히 0, 남은 가중치는 원래의 2배. 계약 설계 규칙 "흔들리는 값은 불변식으로"의 실전 사례입니다.

3.7 CausalAttention 클래스 — 부품으로 만들기 (step07)

step04의 클래스에 마스크와 드롭아웃을 넣고 배치를 받게 만들면 4장에 끼울 수 있는 부품이 됩니다.

flowchart TB
    subgraph CA["CausalAttention(d_in, d_out, context_length, dropout)"]
        L["W_query · W_key · W_value
(nn.Linear, 편향 없음) — 파라미터"] D["nn.Dropout — 파라미터 없음"] B["mask 버퍼 (triu) — 파라미터 아님, 모델과 함께 저장·이동"] end X["(배치, 토큰, d_in)"] --> CA --> Y["(배치, 토큰, d_out)"]
그림 3-7. CausalAttention의 구성. 훈련되는 것은 선형층 세 개뿐이고, 마스크는 버퍼로 등록해 모델을 GPU로 옮기거나 저장할 때 따라가게 한다.

세 가지가 새롭습니다. ① 입력이 (배치, 토큰, 차원)이라 키의 전치는 마지막 두 차원 사이에서 합니다. ② 마스크를 register_buffer로 등록합니다 — 파라미터가 아니면서(훈련되지 않음) 모델의 일부(저장·이동은 같이)인 텐서를 두는 파이토치의 방법입니다. ③ 마스크를 [:토큰 수, :토큰 수]로 잘라 씁니다 — 문맥 길이보다 짧은 입력도 처리하려고요. 실습은 드롭아웃 0.0이라 값이 결정적이고, 시드 123의 첫 행 [-0.4519, 0.2216]이 원서 멀티헤드 래퍼 출력의 앞 두 열과 같습니다(다음 절의 이유).

3.8 멀티헤드 어텐션 — 여러 관계를 동시에 (step08, step09)

어텐션 하나(헤드, head)는 한 종류의 관계에 집중합니다 — 예컨대 주어와 동사. 문장에는 관계가 여러 종류(수식, 지시, 병렬…) 있으니 헤드를 여러 개 두고 각자 다른 관계를 배우게 합니다. GPT-2 소형은 헤드 12개입니다.

래퍼 — 단순하지만 느린 방법 (step08)

flowchart LR
    X["(2, 6, 3)"] --> H1["헤드 1
CausalAttention(3→2)"] X --> H2["헤드 2
CausalAttention(3→2)"] H1 --> C["이어 붙이기 (마지막 차원)"] H2 --> C C --> Y["(2, 6, 4)"]
그림 3-8. 멀티헤드 래퍼. 헤드를 독립된 모듈로 만들어 각각 돌리고 결과를 옆으로 붙인다. 출력 차원이 d_out × 헤드 수가 된다.

시드 123 직후 헤드 두 개를 순서대로 만들면 첫 헤드의 초기값은 step07의 인스턴스와 같습니다 — 그래서 출력 앞 두 열이 step07과 같습니다(교차 검산). 이 방식은 헤드마다 어텐션을 따로 계산해 느립니다.

효율적인 방법 — 큰 투영을 쪼갠다 (step09)

GPT-2가 실제로 쓰는 구조는 다릅니다. 질의·키·값 투영을 각각 하나의 큰 선형층(d_in → d_out)으로 만들고, 결과의 마지막 차원을 (헤드 수 × head_dim)으로 쪼개서 헤드마다 어텐션을 한 번의 배치 연산으로 계산한 뒤, 다시 합치고 출력 투영(out_proj)을 거칩니다.

flowchart LR
    X["(배치, 토큰, d_in)"] --> P["W_query · W_key · W_value
(d_in → d_out) 한 번씩"] P --> R["view: (배치, 토큰, 헤드, head_dim)
transpose: (배치, 헤드, 토큰, head_dim)"] R --> A["헤드마다 점수·마스크·소프트맥스
(배치 연산 한 번)"] A --> J["transpose · view: (배치, 토큰, d_out)"] J --> O["out_proj (d_out → d_out)"] O --> Y["(배치, 토큰, d_out)"]
그림 3-9. 효율적 멀티헤드. 큰 행렬 곱 한 번으로 모든 헤드의 질의를 만들고, 모양만 바꿔 헤드를 분리한다. 출력 차원은 d_out 그대로다.

손계산 ⑥ 헤드 나누기

d_out = 768, 헤드 12개면 head_dim = 768 ÷ 12 = 64. 질의 텐서 (배치, 토큰, 768)을 (배치, 토큰, 12, 64)로 모양만 바꾸면(숫자는 그대로, 보는 방식만) 헤드 12개의 64차원 질의가 됩니다. 실습은 d_out = 2, 헤드 2개라 head_dim = 1 — 헤드마다 1차원짜리 질의·키로 어텐션을 합니다. 장난감이라 그렇습니다.

손계산 ⑦ 파라미터 세기

step09의 파라미터 개수: 24를 손으로 세 봅시다. d_in = 3, d_out = 2.

층모양개수
W_query3 × 26
W_key3 × 26
W_value3 × 26
out_proj 가중치2 × 24
out_proj 편향22
마스크 버퍼(6 × 6)세지 않음

합 24. 마스크는 버퍼라 parameters()에 없습니다 — 3.7절에서 버퍼로 등록한 이유가 여기서 확인됩니다. GPT-2 소형(d = 768)에 같은 계산을 하면 층 하나에 3 × 768² + 768² + 768 ≈ 236만 개이고, 이것이 12층 쌓입니다.

래퍼와 효율적 방식은 같은 일을 하지만 값은 다릅니다 — 가중치를 나누는 방식이 달라 시드 123의 초기값이 다르게 배정되기 때문입니다(래퍼: 헤드 1의 W_q 3×2 → 헤드 1의 W_k → …, 효율적: W_q 3×2 전체 → W_k → …). 둘 다 원서와 같은 값이 나오는 것은 사양이 층 생성 순서를 계약으로 못 박았기 때문입니다.

실습 — 루프 아홉 바퀴

아홉 스텝은 접점으로 이어져 있습니다. 순서대로 진행하세요.

flowchart LR
    S1["step01
get_inputs"] --> S2["step02"] S1 --> S3["step03"] S1 --> S4["step04
SelfAttention"] S4 --> S5["step05
causal_mask"] S4 --> S6["step06"] S5 --> S6 S1 --> S7["step07
CausalAttention"] S7 --> S8["step08
래퍼"] S1 --> S9["step09
MultiHeadAttention"]
그림 3-10. 3장 실습의 접점 흐름. step01의 입력을 전부가 쓰고, step04의 클래스를 step05·06이, step07의 클래스를 step08이 가져다 쓴다. step09는 4장이 import 한다.

각 스텝은 bash harness/run_loop.sh ch03 prompts/ch03/stepNN-*.md → bash harness/verify.sh ch03 stepNN → bash harness/verify.sh ch03 stepNN --solution의 세 명령입니다. 이번 장의 사양에는 새 종류의 계약이 등장합니다 — 층을 만드는 순서. 난수 시드는 "몇 번째 난수를 누가 받느냐"까지 정해야 같은 값이 나오므로, step03·04·07·09의 사양은 질의 → 키 → 값(→ 출력 투영) 순서를 접점의 일부로 못 박습니다.

step01. 훈련 가중치 없는 단순 어텐션 — 질의 토큰 하나

무엇을 — 여섯 토큰의 3차원 임베딩에서, 두 번째 토큰(질의)이 나머지 토큰들을 얼마나 참고할지(어텐션 가중치)를 계산하고 그 가중합(문맥 벡터)을 만드는 스크립트
왜 — 어텐션의 뼈대는 "내적 → 소프트맥스 → 가중합" 세 단계다. 훈련 가중치를 붙이기 전에 이 뼈대를 숫자로 본다
맥락 — 2장의 입력 임베딩(토큰마다 벡터 하나)에서 출발한다. 이 스텝의 get_inputs()가 3장 전체의 재료다. step02가 모든 토큰으로 확장한다
기대 결과 — 어텐션 가중치: tensor([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581]), 문맥 벡터: tensor([0.4419, 0.6515, 0.5683])
hermes에게 전달되는 사양 (prompts/ch03/step01-simple-attention.md)
step01_simple_attention.py 라는 파이썬 파일을 만들어줘.

만들 것: 훈련 가중치가 없는 가장 단순한 어텐션 — 질의 토큰 하나가 문맥의 각 토큰을 얼마나 참고하는지 계산하고, 그 비율로 토큰 벡터를 가중합해 문맥 벡터를 만든다.

입력 (코드 안에 직접 적는다): 여섯 토큰의 3차원 임베딩을 담은 (6, 3) 실수 텐서. 값은 순서대로
[0.43, 0.15, 0.89], [0.55, 0.87, 0.66], [0.57, 0.85, 0.64], [0.22, 0.58, 0.33], [0.77, 0.25, 0.10], [0.05, 0.80, 0.55].

동작 (질의 토큰은 두 번째 토큰, 즉 인덱스 1):
- 어텐션 점수: 질의 벡터와 여섯 토큰 벡터 각각의 내적 — 길이 6 의 텐서.
- 어텐션 가중치: 점수에 소프트맥스를 적용한 것 — 합이 1 인 길이 6 의 텐서.
- 문맥 벡터: 가중치를 계수로 여섯 토큰 벡터를 가중합한 것 — 길이 3 의 텐서.

접점 (다음 스텝들이 import 한다):
- get_inputs() — 인자 없음. 위 (6, 3) 텐서를 반환한다.
- attention_for_query(inputs, query_index) — 입력 텐서와 질의 토큰 인덱스를 받아 (어텐션 점수, 어텐션 가중치, 문맥 벡터) 튜플을 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음 다섯 줄을 정확히 이 형식으로 출력한다. 텐서는 print("라벨:", 텐서) 의 기본 표기, 라벨과 값 사이 공백 하나):
- 입력 모양: torch.Size([6, 3]) 처럼 입력 텐서의 모양
- 어텐션 점수: tensor([...]) 처럼 질의 토큰의 어텐션 점수
- 어텐션 가중치: tensor([...]) 처럼 어텐션 가중치
- 가중치 합: 1.0 처럼 가중치의 합을 파이썬 실수로 소수점 넷째 자리까지 반올림한 값
- 문맥 벡터: tensor([...]) 처럼 문맥 벡터

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
입력 모양: torch.Size([6, 3])
어텐션 점수: tensor([0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865])
어텐션 가중치: tensor([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581])
가중치 합: 1.0
문맥 벡터: tensor([0.4419, 0.6515, 0.5683])
[OK] 5줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch03/step01_simple_attention.py, 핵심):

def attention_for_query(inputs, query_index):
    query = inputs[query_index]
    scores = inputs @ query                       # (6,) — 토큰마다 내적 하나
    weights = torch.softmax(scores, dim=0)        # 합이 1인 비율로
    context = weights @ inputs                    # (3,) — 가중합
    return scores, weights, context

inputs @ query는 (6, 3) 행렬과 길이 3 벡터의 곱 — 행마다 내적 하나가 나와 길이 6이 됩니다. weights @ inputs는 길이 6 벡터와 (6, 3) 행렬의 곱 — 가중합입니다. 여러분의 코드는 torch.matmul을 썼거나, 여섯 토큰을 루프로 돌며 torch.dot을 했을 수 있습니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 계산 세 줄을 기준 구현과 똑같이 썼습니다 — 어텐션의 뼈대는 수학이 하나라 구현의 자유가 좁은 스텝입니다. 입력 텐서를 모듈 상수 대신 함수 안에 직접 적은 것, 합을 float()로 바꾼 것이 달랐습니다. 첫 시도에 통과.

step02. 모든 토큰에 대한 단순 어텐션

무엇을 — step01의 세 단계를 여섯 토큰 전부에 한꺼번에 적용해 (6, 6) 가중치 행렬과 (6, 3) 문맥 벡터 행렬을 만드는 스크립트
왜 — 토큰마다 루프를 돌 필요가 없다. 입력 @ 입력ᵀ 한 번, 행마다 소프트맥스, 가중치 @ 입력 한 번 — 이것이 실제 구현의 모양이다
맥락 — step01의 get_inputs()를 재사용한다. 결과의 두 번째 행이 step01의 문맥 벡터와 같아야 한다 (교차 검산)
기대 결과 — 문맥 벡터 행렬의 두 번째 행 tensor([0.4419, 0.6515, 0.5683]) — step01과 정확히 같다
hermes에게 전달되는 사양 (prompts/ch03/step02-all-attention.md)
step02_all_attention.py 라는 파이썬 파일을 만들어줘.

만들 것: 여섯 토큰 전부를 질의로 삼아, 단순 어텐션(훈련 가중치 없음)의 가중치 행렬과 문맥 벡터 행렬을 한꺼번에 계산하는 스크립트.

접점 (직전 스텝 재사용 + 다음 스텝이 import 한다):
- 같은 디렉토리의 step01_simple_attention.py 에서 get_inputs() 를 import 해서 입력 (6, 3) 텐서를 얻는다. 입력 값을 다시 적지 않는다.
- simple_self_attention(inputs) — (토큰 수, 차원) 입력을 받아 (어텐션 가중치 행렬, 문맥 벡터 행렬) 튜플을 반환한다.
  어텐션 점수 행렬의 i행 j열은 i번째 토큰 벡터와 j번째 토큰 벡터의 내적이고, 가중치는 점수 행렬의 **각 행**에 소프트맥스를
  적용한 것(각 행의 합이 1), 문맥 벡터 행렬은 가중치 행렬로 입력 벡터들을 가중합한 것(모양 (토큰 수, 차원))이다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다. 텐서는 print 기본 표기):
- 어텐션 점수 모양: torch.Size([6, 6]) 처럼 점수 행렬의 모양
- 어텐션 가중치: 한 줄 뒤 다음 줄부터 가중치 행렬
- 행 합: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0] 처럼 각 행의 합을 소수점 넷째 자리로 반올림한 파이썬 실수 리스트
- 문맥 벡터: 한 줄 뒤 다음 줄부터 문맥 벡터 행렬
- 두 번째 문맥 벡터: tensor([...]) 처럼 문맥 벡터 행렬의 두 번째 행(인덱스 1)

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
어텐션 점수 모양: torch.Size([6, 6])
어텐션 가중치:
tensor([[0.2098, 0.2006, 0.1981, 0.1242, 0.1220, 0.1452],
        [0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581],
        [0.1390, 0.2369, 0.2326, 0.1242, 0.1108, 0.1565],
        [0.1435, 0.2074, 0.2046, 0.1462, 0.1263, 0.1720],
        [0.1526, 0.1958, 0.1975, 0.1367, 0.1879, 0.1295],
        [0.1385, 0.2184, 0.2128, 0.1420, 0.0988, 0.1896]])
행 합: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
문맥 벡터:
tensor([[0.4421, 0.5931, 0.5790],
        [0.4419, 0.6515, 0.5683],
        [0.4431, 0.6496, 0.5671],
        [0.4304, 0.6298, 0.5510],
        [0.4671, 0.5910, 0.5266],
        [0.4177, 0.6503, 0.5645]])
두 번째 문맥 벡터: tensor([0.4419, 0.6515, 0.5683])
[OK] 15줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch03/step02_all_attention.py, 핵심):

def simple_self_attention(inputs):
    scores = inputs @ inputs.T                    # (6, 6)
    weights = torch.softmax(scores, dim=-1)       # 각 행의 합이 1
    context = weights @ inputs                    # (6, 3)
    return weights, context

dim=-1은 "마지막 차원을 따라" — 행렬이라면 행마다 소프트맥스입니다. dim=0으로 쓰면 열마다가 되어 행 합이 1이 아니게 됩니다. 사양이 "각 행에"라고 못 박은 이유입니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)의 계산 세 줄은 기준 구현과 같았고, 행 합을 구하는 방식(텐서를 돌며 float()로 바꿔 반올림)만 달랐습니다. 두 번째 행의 교차 검산까지 첫 시도에 통과.

step03. 훈련 가능한 가중치 — 질의·키·값

무엇을 — 난수로 초기화한 질의·키·값 가중치 행렬 세 개로 두 번째 토큰의 어텐션 가중치와 문맥 벡터를 계산하는 스크립트
왜 — 단순 어텐션은 배울 것이 없다. 입력을 세 가지 역할(질의·키·값)로 투영하는 행렬을 두면 훈련으로 "무엇을 볼지"를 배울 수 있다
맥락 — step01의 세 단계는 그대로이고, 내적을 "입력끼리"가 아니라 "질의 벡터와 키 벡터"로 바꾼다. step04가 이것을 클래스로 만든다
기대 결과 — 문맥 벡터: tensor([0.3061, 0.8210]) — 2차원(d_out=2)으로 줄어든 두 번째 토큰의 문맥 벡터
hermes에게 전달되는 사양 (prompts/ch03/step03-qkv-weights.md)
step03_qkv_weights.py 라는 파이썬 파일을 만들어줘.

만들 것: 훈련 가능한 질의·키·값 가중치 행렬로 어텐션을 계산하는 스크립트 (질의 토큰은 두 번째 토큰).

접점 (직전 스텝 재사용 + 다음 스텝이 import 한다):
- 같은 디렉토리의 step01_simple_attention.py 에서 get_inputs() 를 import 해서 (6, 3) 입력을 얻는다.
- make_qkv_weights(d_in, d_out, seed) — 파이토치 시드를 seed 로 고정한 **직후에**, 질의 → 키 → 값 순서로
  torch.rand(d_in, d_out) 행렬 세 개를 만들어 (질의 가중치, 키 가중치, 값 가중치) 튜플로 반환한다. 이 순서가 계약이다.
- scaled_attention_weights(query, keys) — 질의 벡터 하나(길이 d_out)와 키 행렬(토큰 수, d_out)을 받아,
  점수(질의와 각 키의 내적)를 **키 차원의 제곱근으로 나눈 뒤** 소프트맥스한 가중치(길이 토큰 수)를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: d_in = 3, d_out = 2, 시드 123. 질의 행렬 = 입력 @ 질의 가중치, 키·값도 같은 방식. 질의 토큰은 인덱스 1.

출력 (직접 실행하면 다음 여섯 줄을 정확히 이 형식으로 출력한다. 모든 출력은 print("라벨:", 값) 꼴 — f-문자열은 0차원 텐서의 tensor(...) 표기를 지우므로 쓰지 않는다):
- 키 모양: torch.Size([6, 2]) 처럼 키 행렬의 모양
- 두 번째 질의 벡터: tensor([...]) 처럼 인덱스 1 토큰의 질의 벡터
- 점수(질의2·키2): tensor(...) 처럼 그 질의 벡터와 인덱스 1 토큰의 키 벡터의 내적 (0차원 텐서 그대로)
- 어텐션 점수: tensor([...]) 처럼 그 질의 벡터와 여섯 키 벡터의 내적 (스케일링 전)
- 어텐션 가중치: tensor([...]) 처럼 scaled_attention_weights 의 결과
- 문맥 벡터: tensor([...]) 처럼 가중치로 값 벡터들을 가중합한 것

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
키 모양: torch.Size([6, 2])
두 번째 질의 벡터: tensor([0.4306, 1.4551])
점수(질의2·키2): tensor(1.8524)
어텐션 점수: tensor([1.2705, 1.8524, 1.8111, 1.0795, 0.5577, 1.5440])
어텐션 가중치: tensor([0.1500, 0.2264, 0.2199, 0.1311, 0.0906, 0.1820])
문맥 벡터: tensor([0.3061, 0.8210])
[OK] 6줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch03/step03_qkv_weights.py, 핵심):

def make_qkv_weights(d_in, d_out, seed):
    torch.manual_seed(seed)
    w_query = torch.rand(d_in, d_out)
    w_key = torch.rand(d_in, d_out)
    w_value = torch.rand(d_in, d_out)
    return w_query, w_key, w_value

def scaled_attention_weights(query, keys):
    d_k = keys.shape[-1]
    scores = query @ keys.T
    return torch.softmax(scores / d_k ** 0.5, dim=-1)

torch.rand를 세 번 부르는 순서가 값을 결정합니다. 사양의 "이 순서가 계약이다"가 이 세 줄입니다. 점수(질의2·키2) 줄이 tensor(1.8524)로 나오려면 print("라벨:", 값) 꼴이어야 합니다 — f-문자열은 0차원 텐서를 1.8524…로 풀어 버립니다(prompt-guide 원칙 10).

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 점수를 keys @ query로(기준 구현은 query @ keys.T) 계산했습니다 — 같은 내적을 반대 방향으로 쓴 것이라 값이 같습니다. print("라벨:", 값) 규칙을 지켜 tensor(1.8524) 표기가 그대로 나왔고, 첫 시도에 통과.

step04. SelfAttention 클래스

무엇을 — 질의·키·값 투영을 nn.Linear 층으로 가진 SelfAttention 클래스. 입력 (6, 3) → 문맥 벡터 (6, 2)
왜 — step03의 torch.rand 행렬은 훈련 루프가 모르는 그냥 텐서다. nn.Module + nn.Linear로 만들어야 파이토치가 파라미터로 관리하고 4장 모델에 부품으로 들어간다
맥락 — step03의 계산을 클래스에 담고 모든 토큰에 적용한다. step05·06이 이 클래스의 층(W_query, W_key)을 꺼내 쓰고, step07이 인과 마스크를 더해 확장한다
기대 결과 — 시드 789로 만든 인스턴스의 첫 행 [-0.0739, 0.0713] — 원서와 같은 값
hermes에게 전달되는 사양 (prompts/ch03/step04-self-attention.md)
step04_self_attention.py 라는 파이썬 파일을 만들어줘.

만들 것: 질의·키·값 투영을 선형층으로 가진 셀프 어텐션 클래스.

접점 (직전 스텝 재사용 + 다음 스텝들이 import 한다):
- 같은 디렉토리의 step01_simple_attention.py 에서 get_inputs() 를 import 해서 (6, 3) 입력을 얻는다.
- 클래스 SelfAttention(d_in, d_out) — torch.nn.Module 을 상속한다. 생성자에서 선형층 세 개를 **속성 이름 W_query, W_key, W_value 로,
  이 순서로, 편향 없이**(bias=False) 만든다 (각각 d_in → d_out).
  - forward(x) — (토큰 수, d_in) 입력을 받아: 세 층으로 질의·키·값 행렬을 만들고, 점수 = 질의 @ 키ᵀ, 가중치 = 점수를
    키 차원의 제곱근으로 나눈 뒤 행마다 소프트맥스, 문맥 벡터 = 가중치 @ 값 — (토큰 수, d_out) 을 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 파이토치 시드를 789 로 고정한 **직후에** SelfAttention(d_in=3, d_out=2) 인스턴스를 만들고 입력을 통과시킨다.

출력 (직접 실행하면 다음을 정확히 이 형식으로 출력한다. 텐서는 print 기본 표기 그대로):
- 문맥 벡터 모양: torch.Size([6, 2]) 처럼 결과의 모양
- 문맥 벡터: 한 줄 뒤 다음 줄부터 결과 텐서

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
문맥 벡터 모양: torch.Size([6, 2])
문맥 벡터:
tensor([[-0.0739,  0.0713],
        [-0.0748,  0.0703],
        [-0.0749,  0.0702],
        [-0.0760,  0.0685],
        [-0.0763,  0.0679],
        [-0.0754,  0.0693]], grad_fn=<MmBackward0>)
[OK] 7줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch03/step04_self_attention.py, 핵심):

class SelfAttention(nn.Module):
    def __init__(self, d_in, d_out):
        super().__init__()
        self.W_query = nn.Linear(d_in, d_out, bias=False)
        self.W_key = nn.Linear(d_in, d_out, bias=False)
        self.W_value = nn.Linear(d_in, d_out, bias=False)

    def forward(self, x):
        queries, keys, values = self.W_query(x), self.W_key(x), self.W_value(x)
        scores = queries @ keys.T
        weights = torch.softmax(scores / keys.shape[-1] ** 0.5, dim=-1)
        return weights @ values

nn.Module을 상속하고 super().__init__()을 부른 뒤 층을 속성으로 두면, 파이토치가 그 층들의 가중치를 parameters()로 모아 줍니다. 속성 이름 W_query 등은 접점입니다 — step05가 sa.W_query(inputs)로 꺼내 씁니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 전치를 .T 대신 transpose(0, 1)로, 스케일링을 소프트맥스 전 별도 줄로 썼고 변수명을 단수(query, key, value)로 지었습니다. 층 세 개의 순서와 편향 없음을 지켜 시드 789의 값 열두 개가 기준 구현과 소수점 넷째 자리까지 같았습니다. 첫 시도에 통과.

step05. 인과 마스크

무엇을 — 어텐션 점수 행렬의 대각선 위(미래 자리)를 가려, 각 토큰이 자기와 그 앞의 토큰만 보게 만드는 스크립트. 순진한 방법과 제대로 된 방법을 둘 다 계산해 같은지 확인한다
왜 — GPT는 다음 토큰을 예측한다. 예측할 때 미래를 보면 반칙이다. 훈련 때도 미래를 못 보게 해야 예측 능력이 생긴다
맥락 — step04의 SelfAttention(시드 789)에서 질의·키를 꺼내 6×6 가중치를 만들고 가린다. step07이 이 마스크를 클래스 안에 넣는다
기대 결과 — 인과 어텐션 가중치의 둘째 행 [0.5517, 0.4483, 0.0000, ...] — 앞 두 토큰만 보고, 두 값의 합은 1
hermes에게 전달되는 사양 (prompts/ch03/step05-causal-mask.md)
step05_causal_mask.py 라는 파이썬 파일을 만들어줘.

만들 것: 어텐션 가중치에서 미래 토큰(대각선 위)을 가리는 인과 마스크. 순진한 방법과 -inf 방법을 둘 다 계산해 비교한다.

접점 (직전 스텝 재사용 + 다음 스텝들이 import 한다):
- 같은 디렉토리의 step01_simple_attention.py 에서 get_inputs() 를, step04_self_attention.py 에서 SelfAttention 을 import 한다.
- causal_mask(context_length) — (context_length, context_length) **불리언** 텐서를 반환한다. 대각선 위(행 번호 < 열 번호)가 True(가려지는 자리), 나머지는 False.
- masked_softmax(scores, mask, d_k) — 점수 행렬에서 mask 가 True 인 자리를 음의 무한대로 바꾼 뒤, d_k 의 제곱근으로 나누고 행마다 소프트맥스한 가중치를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작:
- 파이토치 시드 789 고정 직후 SelfAttention(d_in=3, d_out=2) 를 만들고(step04 와 같은 가중치가 된다), 그 W_query·W_key 층으로 입력의 질의·키 행렬을 만든다. 점수 = 질의 @ 키ᵀ, d_k = 키 차원(2).
- ① 마스크 없는 가중치: 점수를 √d_k 로 나눠 행마다 소프트맥스.
- ② 순진한 방법: 6×6 하삼각 행렬(대각선 포함 아래가 1, 위가 0)을 ①에 원소별로 곱한 뒤, 행마다 합으로 나눠 다시 정규화.
- ③ -inf 방법: masked_softmax(점수, causal_mask(6), d_k).

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다. 텐서는 print 기본 표기):
- 마스크 전 가중치: 한 줄 뒤 다음 줄부터 ① 텐서
- 하삼각 마스크: 한 줄 뒤 다음 줄부터 하삼각 행렬
- 0 을 곱하고 다시 정규화한 가중치: 한 줄 뒤 다음 줄부터 ② 텐서
- 인과 마스크(True 가 가려지는 자리): 한 줄 뒤 다음 줄부터 causal_mask(6)
- 인과 어텐션 가중치: 한 줄 뒤 다음 줄부터 ③ 텐서
- 두 방법이 같은가: True 처럼 ②와 ③이 torch.allclose 로 같은지

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측, 일부) ---
마스크 전 가중치:
tensor([[0.1921, 0.1646, 0.1652, 0.1550, 0.1721, 0.1510],
        [0.2041, 0.1659, 0.1662, 0.1496, 0.1665, 0.1477],
        ...
인과 어텐션 가중치:
tensor([[1.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.5517, 0.4483, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.3800, 0.3097, 0.3103, 0.0000, 0.0000, 0.0000],
        [0.2758, 0.2460, 0.2462, 0.2319, 0.0000, 0.0000],
        [0.2175, 0.1983, 0.1984, 0.1888, 0.1971, 0.0000],
        [0.1935, 0.1663, 0.1666, 0.1542, 0.1666, 0.1529]], grad_fn=<SoftmaxBackward0>)
두 방법이 같은가: True
[OK] 13줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch03/step05_causal_mask.py, 핵심):

def causal_mask(context_length):
    return torch.triu(torch.ones(context_length, context_length), diagonal=1).bool()

def masked_softmax(scores, mask, d_k):
    masked = scores.masked_fill(mask, float("-inf"))
    return torch.softmax(masked / d_k ** 0.5, dim=-1)

torch.triu(..., diagonal=1)은 "대각선 바로 위부터 위쪽"만 남기는 상삼각 함수이고, torch.tril은 하삼각입니다. masked_fill은 마스크가 True인 자리를 주어진 값으로 바꿉니다. 여러분의 코드는 torch.ones(...).triu(1) == 1처럼 썼거나, 인덱스 비교 torch.arange(6)[None, :] > torch.arange(6)[:, None]로 마스크를 만들었을 수도 있습니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 torch.ones(..., dtype=torch.bool)에 바로 triu를 적용해 마스크를 만들었습니다 — 기준 구현의 "실수 행렬 → .bool()"과 순서가 반대입니다. 흥미로운 점: 에이전트가 자기 코드를 확인하려고 프로브 스크립트를 따로 돌리다가 프로브 쪽의 버그를 세 번 고친 뒤에야 "스텝 파일은 처음부터 맞았다"고 결론지었습니다. 검증기는 그 우회로를 보지 않고 출력 13줄만 대조합니다 — 첫 시도에 통과.

step06. 드롭아웃

무엇을 — 드롭아웃(비율 0.5)을 전부 1인 행렬과 step05의 인과 어텐션 가중치에 적용해 무엇이 꺼지고 남은 값이 어떻게 커지는지 보는 스크립트
왜 — 훈련 중 어텐션 가중치 일부를 무작위로 0으로 만들면 모델이 특정 토큰에만 의존하지 못해 과적합이 줄어든다. 추론 때는 끈다
맥락 — step05의 인과 가중치를 재사용한다. step07의 CausalAttention 클래스가 드롭아웃 층을 내장한다
기대 결과 — 남은 값은 전부 2.0인가: True, 가려진 미래 자리는 여전히 0인가: True — 어느 자리가 꺼졌는지는 눈으로, 규칙은 검증기로
hermes에게 전달되는 사양 (prompts/ch03/step06-dropout.md)
step06_dropout.py 라는 파이썬 파일을 만들어줘.

만들 것: 드롭아웃을 어텐션 가중치에 적용해 보고, 드롭아웃이 지키는 규칙을 확인하는 스크립트.

접점 (직전 스텝 재사용):
- 같은 디렉토리의 step01_simple_attention.py 에서 get_inputs() 를, step04_self_attention.py 에서 SelfAttention 을,
  step05_causal_mask.py 에서 causal_mask(context_length) 와 masked_softmax(scores, mask, d_k) 를 import 한다. 새로 구현하지 않는다.
- apply_dropout(tensor, rate, seed) — 파이토치 시드를 seed 로 고정한 **직후에** 비율 rate 의 드롭아웃(torch.nn.Dropout, 훈련 모드)을
  텐서에 한 번 적용한 결과를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작:
- ① 전부 1 인 (6, 6) 텐서에 apply_dropout(텐서, 0.5, 123).
- ② step05 와 똑같이(시드 789 직후 SelfAttention(3, 2), 그 W_query·W_key 로 점수, masked_softmax(점수, causal_mask(6), 2)) 인과 어텐션
  가중치를 만든 뒤 apply_dropout(가중치, 0.5, 123).

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다. 텐서는 print 기본 표기, 참/거짓은 파이썬 True/False):
- 1 행렬에 드롭아웃: 한 줄 뒤 다음 줄부터 ① 결과 텐서
- 남은 값은 전부 2.0인가: True 처럼 — ① 결과에서 0 이 아닌 원소가 모두 정확히 2.0 인지
- 꺼진 자리 수: 15 처럼 — ① 결과에서 0 인 원소의 개수 (숫자는 예시 — 실제로 센 값을 쓴다)
- 인과 가중치에 드롭아웃: 한 줄 뒤 다음 줄부터 ② 결과 텐서
- 가려진 미래 자리는 여전히 0인가: True 처럼 — ② 결과에서 causal_mask(6) 이 True 인 자리가 모두 0 인지
- 남은 값은 원래의 2배인가: True 처럼 — ② 결과에서 0 이 아닌 자리의 값이 원래 가중치의 정확히 2배인지 (torch.allclose)

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 클래스, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측, 이 강좌 환경 torch 2.13) ---
1 행렬에 드롭아웃:
tensor([[2., 2., 0., 2., 2., 0.],
        [0., 0., 0., 2., 0., 2.],
        [2., 2., 2., 2., 0., 2.],
        [0., 2., 2., 0., 0., 2.],
        [0., 2., 0., 2., 0., 2.],
        [0., 2., 2., 2., 2., 0.]])
남은 값은 전부 2.0인가: True
꺼진 자리 수: 15
인과 가중치에 드롭아웃:
tensor([[2.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
        [0.7599, 0.6194, 0.6206, 0.0000, 0.0000, 0.0000],
        ...
가려진 미래 자리는 여전히 0인가: True
남은 값은 원래의 2배인가: True
[OK] 5줄(라벨 2 + 불변식 3) 일치   →   ✅ 검증 통과

꺼진 자리의 패턴은 여러분의 파이토치 버전에서 다를 수 있습니다 — 그래서 검증기는 패턴이 아니라 규칙 세 줄만 대조합니다. 둘째 행이 전부 0이 된 것을 보세요 — journey 토큰이 이번 훈련 스텝에서는 아무도 못 보게 된 것입니다. 이런 극단적인 경우도 훈련의 일부이고, 비율 0.5는 원서의 시연용 값입니다(GPT-2 훈련에서는 0.1).

기준 구현 읽기 (solutions/ch03/step06_dropout.py, 핵심):

def apply_dropout(tensor, rate, seed):
    torch.manual_seed(seed)
    dropout = nn.Dropout(rate)
    return dropout(tensor)

print("남은 값은 전부 2.0인가:", bool(torch.all(dropped[dropped != 0] == 2.0)))
print("가려진 미래 자리는 여전히 0인가:", bool(torch.all(dropped_weights[future] == 0)))

dropped[dropped != 0]은 불리언 인덱싱 — 조건이 참인 원소만 골라냅니다. nn.Dropout은 기본이 훈련 모드라 그대로 부르면 꺼집니다(.eval()을 부르면 아무것도 안 합니다).

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 dropout.train()을 굳이 명시적으로 부르고, "전부 2.0" 검사를 (값 == 0) | (값 == 2.0) 꼴로 썼습니다 — 같은 규칙을 다른 식으로 확인한 것입니다. 이 환경에서는 꺼진 자리도 15개로 같았습니다. 첫 시도에 통과.

step07. CausalAttention 클래스

무엇을 — 인과 마스크(버퍼)와 드롭아웃 층을 내장하고, (배치, 토큰, 차원) 입력을 받는 CausalAttention 클래스
왜 — step04~06의 조각(투영·마스크·드롭아웃)을 한 모듈에 모아야 4장의 트랜스포머 블록에 부품으로 끼울 수 있다. 실제 데이터는 배치로 온다
맥락 — step04의 SelfAttention에 step05의 마스크와 step06의 드롭아웃을 더하고 배치 차원을 추가한다. step08이 이것을 여러 개 묶는다
기대 결과 — 문맥 벡터 모양 torch.Size([2, 6, 2]), 첫 배치 첫 행 [-0.4519, 0.2216] — 원서와 같은 값
hermes에게 전달되는 사양 (prompts/ch03/step07-causal-attention.md)
step07_causal_attention.py 라는 파이썬 파일을 만들어줘.

만들 것: 인과 마스크와 드롭아웃을 갖추고 배치 입력을 처리하는 인과 어텐션 클래스.

접점 (직전 스텝 재사용 + 다음 스텝이 import 한다):
- 같은 디렉토리의 step01_simple_attention.py 에서 get_inputs() 를 import 한다.
- 클래스 CausalAttention(d_in, d_out, context_length, dropout) — torch.nn.Module 을 상속한다. 생성자에서:
  - 선형층 세 개를 속성 이름 W_query, W_key, W_value 로, 이 순서로, 편향 없이 만든다 (d_in → d_out).
  - 비율 dropout 의 torch.nn.Dropout 층을 만든다.
  - (context_length, context_length) 인과 마스크(대각선 위가 1, 나머지 0)를 **버퍼로 등록**한다 (register_buffer) — 학습 파라미터가 아니다.
  - forward(x) — (배치, 토큰 수, d_in) 입력을 받아: 질의·키·값 (배치, 토큰 수, d_out) 을 만들고, 점수 = 질의 @ 키ᵀ (배치, 토큰, 토큰),
    마스크의 앞 토큰 수 × 토큰 수 부분이 1 인 자리를 음의 무한대로 채우고, 키 차원의 제곱근으로 나눠 마지막 차원에 소프트맥스,
    드롭아웃을 적용한 뒤 값과 곱해 (배치, 토큰 수, d_out) 을 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 입력 (6, 3) 을 같은 것 두 개로 쌓아 (2, 6, 3) 배치를 만든다. 파이토치 시드 123 고정 직후 CausalAttention(d_in=3, d_out=2, context_length=6, dropout=0.0) 을 만들어 배치를 통과시킨다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 배치 모양: torch.Size([2, 6, 3]) 처럼 배치 텐서의 모양
- 문맥 벡터 모양: torch.Size([2, 6, 2]) 처럼 결과의 모양
- 두 배치가 같은가: True 처럼 결과의 첫 배치와 둘째 배치가 torch.equal 로 같은지 (같은 입력이니 같아야 한다)
- 첫 배치 문맥 벡터: 한 줄 뒤 다음 줄부터 결과의 첫 배치 (6, 2) 텐서

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
배치 모양: torch.Size([2, 6, 3])
문맥 벡터 모양: torch.Size([2, 6, 2])
두 배치가 같은가: True
첫 배치 문맥 벡터:
tensor([[-0.4519,  0.2216],
        [-0.5874,  0.0058],
        [-0.6300, -0.0632],
        [-0.5675, -0.0843],
        [-0.5526, -0.0981],
        [-0.5299, -0.1081]], grad_fn=<SelectBackward0>)
[OK] 9줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch03/step07_causal_attention.py, 핵심):

self.register_buffer("mask", torch.triu(torch.ones(context_length, context_length), diagonal=1))

def forward(self, x):
    _, num_tokens, _ = x.shape
    queries, keys, values = self.W_query(x), self.W_key(x), self.W_value(x)
    scores = queries @ keys.transpose(1, 2)                       # (배치, 토큰, 토큰)
    scores.masked_fill_(self.mask.bool()[:num_tokens, :num_tokens], float("-inf"))
    weights = torch.softmax(scores / keys.shape[-1] ** 0.5, dim=-1)
    weights = self.dropout(weights)
    return weights @ values

keys.transpose(1, 2)가 배치 차원을 놔두고 마지막 두 차원만 바꿉니다 — 2차원일 때의 .T에 해당합니다. masked_fill_의 끝 밑줄은 "제자리에서 바꾼다"는 파이토치 관례입니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 3분 만에 기준 구현과 거의 같은 구조의 파일을 만들었고 값 12개가 일치했습니다. 그런데 그 뒤 자기 코드를 확인하는 점검을 29분간 반복해 제작자가 프로세스를 끊어야 했습니다 — 파일은 처음부터 정답이었습니다. 이 일로 하네스에 실행 시간 상한(15분)이 생겼습니다. 에이전트의 "확신 부족"도 실습에서 만나게 될 현상입니다.

step08. 멀티헤드 래퍼

무엇을 — CausalAttention 인스턴스(헤드)를 여러 개 만들어 같은 입력에 각각 적용하고, 결과를 마지막 차원으로 이어 붙이는 MultiHeadAttentionWrapper 클래스
왜 — 헤드 하나는 한 가지 관계(예: 주어–동사)에 집중한다. 헤드 여러 개를 두면 서로 다른 관계를 동시에 본다. 실제 GPT-2 소형은 헤드 12개
맥락 — step07의 클래스를 부품으로 쓴다. 출력의 앞 두 열이 step07의 결과와 같아야 한다(첫 헤드 = 시드 123의 첫 인스턴스). step09가 같은 일을 더 효율적으로 한다
기대 결과 — 문맥 벡터 모양 torch.Size([2, 6, 4]) (헤드 2개 × d_out 2), 첫 행 [-0.4519, 0.2216, 0.4772, 0.1063]
hermes에게 전달되는 사양 (prompts/ch03/step08-multihead-wrapper.md)
step08_multihead_wrapper.py 라는 파이썬 파일을 만들어줘.

만들 것: 인과 어텐션 헤드 여러 개를 나란히 돌려 결과를 이어 붙이는 멀티헤드 어텐션 래퍼 클래스.

접점 (직전 스텝 재사용):
- 같은 디렉토리의 step01_simple_attention.py 에서 get_inputs() 를, step07_causal_attention.py 에서 CausalAttention 을 import 한다. 어텐션을 새로 구현하지 않는다.
- 클래스 MultiHeadAttentionWrapper(d_in, d_out, context_length, dropout, num_heads) — torch.nn.Module 을 상속한다.
  생성자에서 CausalAttention(d_in, d_out, context_length, dropout) 을 num_heads 개 **순서대로** 만들어 torch.nn.ModuleList 에 담는다
  (파이토치가 파라미터로 관리하도록).
  - forward(x) — 각 헤드에 x 를 통과시킨 결과를 **마지막 차원으로 이어 붙여** (배치, 토큰 수, d_out × num_heads) 를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 입력 (6, 3) 을 같은 것 두 개로 쌓아 (2, 6, 3) 배치를 만든다. 파이토치 시드 123 고정 직후
MultiHeadAttentionWrapper(d_in=3, d_out=2, context_length=6, dropout=0.0, num_heads=2) 를 만들어 배치를 통과시킨다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 문맥 벡터 모양: torch.Size([2, 6, 4]) 처럼 결과의 모양
- 첫 배치 문맥 벡터: 한 줄 뒤 다음 줄부터 결과의 첫 배치 (6, 4) 텐서

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
문맥 벡터 모양: torch.Size([2, 6, 4])
첫 배치 문맥 벡터:
tensor([[-0.4519,  0.2216,  0.4772,  0.1063],
        [-0.5874,  0.0058,  0.5891,  0.3257],
        [-0.6300, -0.0632,  0.6202,  0.3860],
        [-0.5675, -0.0843,  0.5478,  0.3589],
        [-0.5526, -0.0981,  0.5321,  0.3428],
        [-0.5299, -0.1081,  0.5077,  0.3493]], grad_fn=<SelectBackward0>)
[OK] 7줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch03/step08_multihead_wrapper.py, 핵심):

self.heads = nn.ModuleList([CausalAttention(d_in, d_out, context_length, dropout) for _ in range(num_heads)])

def forward(self, x):
    return torch.cat([head(x) for head in self.heads], dim=-1)

nn.ModuleList에 담아야 헤드들의 파라미터가 parameters()에 잡힙니다 — 그냥 파이썬 리스트에 담으면 파이토치가 모릅니다. 사양이 ModuleList를 못 박은 이유입니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)의 코드는 기준 구현과 사실상 같았습니다 — 헤드 출력을 리스트 변수에 모았다가 cat하는 정도의 차이. 앞 두 열이 step07과 같다는 교차 검산까지 첫 시도에 통과.

step09. 효율적인 멀티헤드 어텐션

무엇을 — 질의·키·값 투영을 각각 하나의 큰 선형층(d_in → d_out)으로 만들고, 결과를 헤드 수만큼 쪼개 한 번에 어텐션을 계산한 뒤 다시 합치고 출력 투영을 거치는 MultiHeadAttention 클래스
왜 — 래퍼는 헤드마다 어텐션을 따로 계산해 느리다. 큰 행렬 곱 하나로 모든 헤드를 동시에 계산하는 이 구조가 GPT-2가 실제로 쓰는 것이고, 4장 모델에 그대로 들어간다
맥락 — step08 래퍼와 같은 일을 하지만 가중치를 나누는 방식이 달라 값은 다르다. 3장의 최종 부품이며 4장 TransformerBlock이 이것을 import 한다
기대 결과 — 문맥 벡터 모양 torch.Size([2, 6, 2]), 첫 행 [0.3190, 0.4858], 파라미터 개수 24 — 원서와 같은 값
hermes에게 전달되는 사양 (prompts/ch03/step09-multihead-attention.md)
step09_multihead_attention.py 라는 파이썬 파일을 만들어줘.

만들 것: 질의·키·값 투영 하나씩을 헤드 수만큼 쪼개어 한 번에 계산하는 효율적인 멀티헤드 어텐션 클래스.

접점 (직전 스텝 재사용 + 4장이 import 한다):
- 같은 디렉토리의 step01_simple_attention.py 에서 get_inputs() 를 import 한다.
- 클래스 MultiHeadAttention(d_in, d_out, context_length, dropout, num_heads, qkv_bias=False) — torch.nn.Module 을 상속한다. d_out 은 num_heads 로
  나누어떨어져야 하며, 헤드 하나의 차원 head_dim = d_out ÷ num_heads. 생성자에서 **이 순서로**:
  - 선형층 W_query, W_key, W_value (d_in → d_out, 편향은 qkv_bias 가 True 일 때만 — 기본값 False 면 편향 없음.
    5장이 GPT-2 의 공개 가중치를 넣을 때 True 로 만든다),
  - 선형층 out_proj (d_out → d_out, **편향 있음**) — 헤드를 합친 결과를 한 번 더 섞는 출력 투영,
  - 비율 dropout 의 드롭아웃 층,
  - (context_length, context_length) 인과 마스크(대각선 위가 1)를 버퍼로 등록.
  - forward(x) — (배치, 토큰 수, d_in) 입력을 받아: 세 투영으로 (배치, 토큰, d_out) 을 만들고, 각각을 (배치, 토큰, num_heads, head_dim) 으로
    모양을 바꾼 뒤 헤드 차원이 두 번째가 되게 옮겨 (배치, num_heads, 토큰, head_dim) 으로 만든다. 헤드마다 점수 = 질의 @ 키ᵀ (마지막 두 차원),
    마스크의 **앞 토큰 수 × 토큰 수 부분**이 1 인 자리를 음의 무한대로 채우고(입력 토큰 수가 context_length 보다 짧아도 동작해야 한다 —
    4장이 문맥 길이 1024 에 토큰 4개짜리 입력을 넣는다), head_dim 의 제곱근으로 나눠 마지막 차원에 소프트맥스, 드롭아웃, 값과 곱한다.
    결과 (배치, num_heads, 토큰, head_dim) 을 다시 (배치, 토큰, d_out) 으로 합친 뒤 out_proj 를 통과시켜 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 입력 (6, 3) 을 같은 것 두 개로 쌓아 (2, 6, 3) 배치를 만든다. 파이토치 시드 123 고정 직후
MultiHeadAttention(d_in=3, d_out=2, context_length=6, dropout=0.0, num_heads=2) 를 만들어 배치를 통과시킨다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 문맥 벡터 모양: torch.Size([2, 6, 2]) 처럼 결과의 모양
- 첫 배치 문맥 벡터: 한 줄 뒤 다음 줄부터 결과의 첫 배치 (6, 2) 텐서
- 파라미터 개수: 24 처럼 모듈의 모든 파라미터 원소 수의 합 (parameters() 의 numel 합)

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
문맥 벡터 모양: torch.Size([2, 6, 2])
첫 배치 문맥 벡터:
tensor([[0.3190, 0.4858],
        [0.2943, 0.3897],
        [0.2856, 0.3593],
        [0.2693, 0.3873],
        [0.2639, 0.3928],
        [0.2575, 0.4028]], grad_fn=<SelectBackward0>)
파라미터 개수: 24
[OK] 8줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch03/step09_multihead_attention.py, 핵심):

queries = queries.view(batch_size, num_tokens, self.num_heads, self.head_dim).transpose(1, 2)
# keys, values 도 같은 모양으로
scores = queries @ keys.transpose(2, 3)                               # (배치, 헤드, 토큰, 토큰)
scores.masked_fill_(self.mask.bool()[:num_tokens, :num_tokens], float("-inf"))
weights = torch.softmax(scores / keys.shape[-1] ** 0.5, dim=-1)
context = (weights @ values).transpose(1, 2)                          # (배치, 토큰, 헤드, head_dim)
context = context.contiguous().view(batch_size, num_tokens, self.d_out)   # 헤드를 이어 붙인다
return self.out_proj(context)

view는 숫자를 그대로 두고 모양만 바꾸고, transpose(1, 2)는 토큰 차원과 헤드 차원을 맞바꿉니다. 마지막의 contiguous()는 transpose 뒤에 메모리가 뒤섞여 view가 안 되는 것을 풀어 주는 호출입니다. 이 여덟 줄이 GPT-2의 어텐션 그 자체이고, 4장은 이것을 12개 층에 끼워 넣습니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)의 forward 는 기준 구현과 같은 여덟 단계였고 값 12개와 파라미터 24가 일치했습니다. 차이 하나: 마스크를 토큰 수만큼 자르지 않고 self.mask == 1 로 통째로 썼습니다 — 문맥 길이(6)와 입력 길이가 같은 이 실습에서는 결과가 같지만, 더 짧은 입력을 넣으면 모양이 안 맞아 실패할 코드입니다. 사양이 "앞 토큰 수 × 토큰 수 부분" 을 step07 에서는 못 박고 step09 에서는 빠뜨린 탓입니다. 검증기는 사양대로만 재므로 통과했지만, 4장 step02에서 짧은 입력(문맥 1,024에 토큰 4개)이 들어가자 실제로 RuntimeError로 드러났습니다. 처방은 이 사양에 조건 한 줄(위 사양의 "앞 토큰 수 × 토큰 수 부분" — 4장 제작 중 추가)을 넣고 이 스텝을 다시 생성하는 것이었고, 재생성본은 self.mask[:tokens, :tokens]로 잘라 3장 검증과 4장 step02를 모두 통과했습니다. 전말은 4장 4.2절과 실습 step02에 있습니다.

이 사양은 5장 제작 중(2026-08-30) 한 번 더 자랐습니다 — GPT-2 공개 가중치의 질의·키·값 편향을 받으려면 옵션이 필요해, 클래스 접점에 기본값 False인 키워드 인자 qkv_bias를 추가하고 이 스텝을 다시 생성했습니다(3분, 첫 시도). 재생성본은 bias=qkv_bias로 세 층을 만들고 헤드를 view + permute로 나눠 permute + reshape로 합쳤으며, 기본값이 False라 위 출력 12개 값과 파라미터 24는 그대로입니다. 전말은 5장 5.8절, 기록은 logs/runs/20260830-150614-ch03-step09.md.

아홉 스텝을 다 통과했다면 verify.sh ch03 stepNN --solution과 diff로 기준 구현과 비교해 보세요. 이번 장은 클래스가 많아 "같아야 하는 것"이 늘었습니다 — 클래스 이름, 속성 이름, 층 생성 순서, forward의 입출력 모양. 그 밖의 것(중간 변수, 전치 방법, 마스크를 만드는 식)은 달라도 됩니다.

용어 정리

용어원어(약어)한 줄 정의
어텐션attention각 토큰이 문맥의 다른 토큰들을 얼마나 참고할지 계산해 벡터를 고쳐 쓰는 장치
셀프 어텐션self-attention같은 문장 안의 토큰들끼리 하는 어텐션 (번역기의 인코더-디코더 어텐션과 구분)
순환 신경망recurrent neural network (RNN)문장을 순서대로 읽으며 상태 벡터 하나에 누적하던 어텐션 이전의 구조
병목bottleneck문장 전체를 벡터 하나에 눌러 담아 앞부분이 지워지는 문제
내적dot product두 벡터의 같은 자리끼리 곱해 더한 값. 닮음의 척도
어텐션 점수attention score소프트맥스 전의 원시 점수 (내적)
소프트맥스softmax점수들의 지수를 합으로 나눠 합이 1인 비율로 만드는 함수
어텐션 가중치attention weight소프트맥스 후의 비율. 각 행의 합이 1
문맥 벡터context vector가중치로 토큰 벡터들을 가중합한 새 벡터
질의 / 키 / 값query / key / value (Q/K/V)입력을 세 역할로 투영한 것 — 찾는 것 / 이름표 / 내용
투영projection가중치 행렬을 곱해 다른 차원·역할의 벡터로 바꾸는 것
스케일드 닷 프로덕트 어텐션scaled dot-product attention점수를 √d_k로 나눈 어텐션. 소프트맥스가 극단으로 치우치는 것을 막는다
nn.Linearnn.Linear가중치 행렬(과 선택적 편향)을 가진 파이토치 선형층. 층(x) = x @ Wᵀ + b
nn.Modulenn.Module파이토치 모듈의 기반 클래스. 층을 속성으로 두면 파라미터를 자동 관리
파라미터parameter훈련으로 갱신되는 텐서. model.parameters()로 모인다
인과 마스크causal mask대각선 위(미래 토큰)를 가리는 마스크. GPT가 미래를 못 보게 한다
하삼각 / 상삼각tril / triu대각선 아래를 남기는 / 위를 남기는 행렬 연산
−∞ 마스킹-inf masking소프트맥스 전에 점수를 −∞로 바꿔 가중치를 정확히 0으로 만드는 방법
드롭아웃dropout훈련 중 값 일부를 무작위로 0으로 만들고 나머지를 1/(1−p)배 하는 규제 기법
과적합overfitting훈련 데이터만 외워 새 데이터에 약해지는 현상
버퍼buffer (register_buffer)파라미터가 아니지만 모델과 함께 저장·이동되는 텐서 (마스크)
배치 차원batch dimension(배치, 토큰, 차원)의 첫 축. 여러 문장을 한 번에 처리한다
헤드head어텐션 한 벌. 각자 다른 관계를 배운다
멀티헤드 어텐션multi-head attention (MHA)헤드 여러 개를 나란히 두고 결과를 합치는 구조
head_dimhead dimensiond_out ÷ num_heads. 헤드 하나가 쓰는 차원
출력 투영output projection (out_proj)헤드들을 합친 결과를 한 번 더 섞는 선형층
view / transpose / contiguousview / transpose / contiguous모양 바꾸기 / 축 맞바꾸기 / 메모리 정렬 — 헤드를 나누고 합치는 도구
ModuleListnn.ModuleList모듈들을 담아 파라미터를 함께 관리하게 하는 리스트

정리

  • 어텐션은 "내적 → 소프트맥스 → 가중합" 세 단계로, 각 토큰이 문맥의 모든 토큰을 얼마나 볼지 정해 벡터를 고쳐 쓴다. 1장 바이그램의 "문맥 1단어" 한계가 여기서 풀린다.
  • 훈련하려면 질의·키·값 투영이 필요하고, √d_k 스케일링이 소프트맥스의 극단화를 막는다. nn.Linear + nn.Module로 만들면 파라미터가 된다.
  • GPT는 미래를 보면 안 되므로 인과 마스크(−∞ → 소프트맥스)를 쓰고, 훈련 중 드롭아웃으로 과적합을 줄인다. 마스크는 버퍼다.
  • 멀티헤드는 헤드 여러 개로 여러 관계를 동시에 본다. GPT-2의 실제 구현은 큰 투영을 쪼개는 효율적 방식이며, 파라미터는 손으로 셀 수 있다(24개).
  • 사양 설계: 층 생성 순서·편향 유무·속성 이름은 값을 결정하므로 접점으로 못 박았고, 드롭아웃 패턴처럼 버전에 따라 흔들리는 것은 불변식으로 검증했다.

다음 장 예고

4장에서 드디어 GPT 모델을 조립합니다. 이번 장의 MultiHeadAttention에 층 정규화, GELU 활성화를 쓰는 피드포워드 신경망, 잔차 연결(shortcut)을 더해 트랜스포머 블록 하나를 만들고, 그것을 12개 쌓고 2장의 임베딩과 출력층을 붙이면 1억 2,400만 파라미터의 GPT-2 소형이 됩니다. 훈련은 아직입니다 — 4장 끝에서 이 모델은 아무 뜻 없는 토큰을 생성하지만, 구조는 완성됩니다.


Chapter 04 전 스텝 검증 완료 (8/8, 4장 사양 수정 0회 · 3장 사양 보완 1회)

4장. GPT 모델 조립 — 부품을 쌓아 1억 6,300만 개의 나사로

이 장에서 배우는 것

  • GPT-2 소형(124M)의 설계 수치 여섯 개와, 그 수치로 결정되는 모델의 전체 구조
  • 3장에서 만든 MultiHeadAttention을 챕터 경계를 넘어 import 하는 법 — 그리고 그때 드러나는 사양의 빈틈
  • 층 정규화(평균 0·분산 1로 눈금 맞추기), GELU 활성화와 넓혔다 좁히는 피드포워드, 기울기를 살리는 지름길 연결
  • 부품 네 개를 트랜스포머 블록 하나로 조립하고, 12개를 쌓아 GPTModel을 완성하는 과정
  • 파라미터 1억 6,300만 개를 손으로 세는 법 — "124M"이라는 이름의 유래
  • 실습 8스텝: 접점으로 이어진 사양 8개, 기준 구현 8개, 4장 끝에는 (아직 엉터리지만) 글을 생성하는 GPT가 손에 있다

들어가며 — 3장에서 4장으로

3장 끝에서 여러분은 MultiHeadAttention을 검증까지 마쳤습니다. 2장에서는 토큰 ID를 (배치, 토큰, 768) 입력 임베딩으로 바꾸는 법을 배웠지요. 그 둘 사이와 그 뒤에 무엇이 더 있어야 GPT가 될까요? 정답은 세 가지 부품 — 층 정규화, 피드포워드 신경망, 지름길 연결 — 과 조립 순서입니다. 이 장은 부품 세 개를 하나씩 만들고, 어텐션과 함께 트랜스포머 블록으로 묶은 다음, 그 블록을 12개 쌓고 2장의 임베딩과 출력층을 앞뒤에 붙여 GPT-2 소형 모델 전체를 완성합니다. 1장 로드맵의 스테이지 1이 여기서 끝납니다.

이번 장의 특별한 순간은 두 번째 스텝에 있습니다. 3장에서 hermes가 만들고 여러분이 검증한 파일(workspace/ch03/step09_multihead_attention.py)을 챕터 경계를 넘어 그대로 import 합니다. 직접 만든 부품이 정말 부품으로 기능하는지 확인하는 순간이고, 이 교안을 만들 때는 그 순간에 3장 사양의 빈틈이 실제로 드러났습니다 (4.2절). 2판의 원칙 — 코드를 고치지 않고 사양을 고친다 — 이 실전에서 어떻게 작동하는지 보게 됩니다.

마지막 스텝에서는 완성된 모델로 글을 생성합니다. 아직 훈련하지 않았으니 결과는 엉터리입니다. 그 엉터리를 직접 보는 것이 5장(사전훈련)이 왜 필요한지를 가장 정직하게 설명해 줍니다.

4.1 설계도 — 여섯 개의 수치가 모델을 결정한다 (step01)

건물을 짓기 전에 층수·기둥 간격·바닥 넓이를 정하듯, 모델을 조립하기 전에 크기를 정합니다. 우리가 만들 GPT-2 소형의 설계 수치는 여섯 개이고, 전부 앞 장에서 이미 만난 값입니다.

키값뜻어디서 만났나
vocab_size50,257어휘 크기 — 출력층이 내는 점수의 개수2장 BPE 토크나이저
context_length1,024한 번에 볼 수 있는 최대 토큰 수 — 위치 임베딩의 개수, 마스크의 크기2장 슬라이딩 윈도우, 3장 마스크
emb_dim768토큰 벡터의 차원 — 모든 부품의 입출력 폭2장 임베딩
n_heads12어텐션 헤드 수 — 768 ÷ 12 = 64 차원짜리 헤드 12개3장 멀티헤드
n_layers12트랜스포머 블록을 쌓는 층수이번 장
drop_rate0.1드롭아웃 비율3장 드롭아웃

이 여섯 개를 딕셔너리 하나(GPT_CONFIG_124M)에 모아 두면 모든 부품이 같은 설계도를 읽으므로 어긋날 일이 없습니다. 2판에서는 키 이름이 접점입니다 — 이후 사양이 "cfg의 emb_dim"처럼 키 이름으로 값을 지목하기 때문입니다.

시험용 입력도 여기서 준비합니다. Every effort moves you와 Every day holds a는 GPT-2 BPE로 정확히 4토큰씩이라 (2, 4) 직사각형 배치가 됩니다. 2장 step04에서 배운 토크나이저가 내는 ID 그대로입니다 — Every는 6109.

flowchart TB
    subgraph GPT["GPTModel — step07"]
        direction TB
        IDS["토큰 ID (배치, 토큰)"] --> EMB["토큰 임베딩 + 위치 임베딩
2장 step08 과 같은 계산"] EMB --> DROP["드롭아웃"] DROP --> BLK["TransformerBlock × 12
step06"] BLK --> FN["최종 LayerNorm
step03"] FN --> OUT["출력층 Linear 768 → 50257"] OUT --> LOG["로짓 (배치, 토큰, 50257)"] end subgraph TB1["TransformerBlock 내부 — step06"] direction TB N1["LayerNorm — step03"] --> ATT["MultiHeadAttention — 3장, step02 로 생성"] ATT --> D1["드롭아웃"] --> P1["+ 지름길 — step05"] P1 --> N2["LayerNorm"] --> FF["FeedForward — step04"] FF --> D2["드롭아웃"] --> P2["+ 지름길"] end BLK -.-> TB1
그림 4-1. 4장 조립도. 스텝 하나가 부품 하나이고, step06이 부품 네 개를 블록으로, step07이 블록 12개를 모델로 묶는다. 화살표를 따라가면 토큰 ID가 로짓이 되기까지의 전 과정이다.

4.2 3장 부품을 꺼내 쓰다 — 챕터 경계와 사양의 빈틈 (step02)

조립에 앞서 3장 부품이 GPT-2 크기에서도 동작하는지 점검합니다. 3장 실습은 d_in 3, d_out 2, 헤드 2개, 문맥 길이 6의 장난감이었습니다. 이번에는 768차원, 헤드 12개, 문맥 길이 1,024 — 그리고 토큰 4개짜리 입력입니다. 이 마지막 조건이 핵심입니다. 3장 실습은 문맥 길이와 입력 길이가 6으로 같았기 때문에, 마스크를 통째로 쓰는 구현과 앞 (토큰 수 × 토큰 수)만 잘라 쓰는 구현이 같은 결과를 냈습니다. 문맥 길이 1,024에 토큰 4개를 넣으면 두 구현이 갈립니다 — 통째로 쓰는 쪽은 (1024, 1024) 마스크를 (4, 4) 점수에 씌우려다 모양이 안 맞아 죽습니다.

flowchart LR
    subgraph C3["workspace/ch03"]
        S1["step01_simple_attention.py
get_inputs"] S9["step09_multihead_attention.py
MultiHeadAttention"] S1 --> S9 end subgraph C4["workspace/ch04"] P["sys.path 에 ../ch03 추가"] --> R["step02_reuse_attention.py
build_attention(cfg)"] R --> T["step06 TransformerBlock
att = build_attention(cfg)"] end S9 ==>|"import"| R
그림 4-2. 챕터 경계를 넘는 import. 4장의 step02가 3장 디렉토리를 파이썬의 import 검색 경로(sys.path)에 추가하고 MultiHeadAttention을 가져온다. 3장 step09가 같은 디렉토리의 step01을 import 하므로 경로 추가가 꼭 필요하다.

import 검색 경로(sys.path)는 파이썬이 import 이름을 만났을 때 그 이름의 파일을 찾아보는 디렉토리 목록입니다. 기본값은 "지금 실행 중인 파일의 디렉토리 + 설치된 패키지들"이라 옆 디렉토리는 보이지 않습니다. 목록에 ../ch03을 덧붙이면 3장 파일들이 보입니다. 사양은 "그 디렉토리를 import 검색 경로에 추가한 뒤 import 한다"까지만 말하고, 경로를 어떻게 만들지(실행 위치 기준으로 할지 파일 위치 기준으로 할지)는 에이전트의 몫입니다.

손계산 ① 어텐션 파라미터 — 24개에서 236만 개로

3장 step09에서 세어 본 파라미터는 24개였습니다. 같은 셈을 GPT-2 크기로 하면:

질의·키·값 투영: 768 × 768 짜리 행렬 세 개, 편향 없음  = 3 × 589,824 = 1,769,472
출력 투영:       768 × 768 행렬 하나 + 편향 768           = 589,824 + 768 = 590,592
합계                                                      = 2,360,064
헤드 차원:       768 ÷ 12 = 64  (헤드마다 64차원짜리 질의·키·값)
마스크:          (1024, 1024) 짜리 버퍼 — 파라미터가 아니므로 세지 않는다

step02의 출력 어텐션 파라미터 개수: 2,360,064가 이 셈과 같으면, 3장 부품의 편향 유무(질의·키·값 없음, 출력 투영 있음)까지 그대로 살아 있다는 뜻입니다.

실측 — 3장 사양의 빈틈이 4장에서 드러나다

이 교안을 만들 때 3장 step09의 hermes 생성본은 마스크를 self.mask == 1로 통째로 썼습니다. 3장 검증(문맥 6, 입력 6)은 통과했지만 4장 step02 조건(문맥 1,024, 입력 4)에서는 다음 오류로 죽었습니다.

RuntimeError: The size of tensor a (1024) must match the size of tensor b (4) at non-singleton dimension 3

원인은 코드가 아니라 사양이었습니다. 3장 step07 사양은 "마스크의 앞 토큰 수 × 토큰 수 부분"이라고 못 박았는데, step09 사양은 "마스크 자리를 음의 무한대로 채우고"라고만 써서 그 조건을 빠뜨렸습니다. 검증기는 사양대로만 재므로 통과했고, 빈틈은 다음 장에서 드러났습니다. 처방은 2판 원칙 그대로입니다 — 4장 코드를 우회하거나 3장 파일을 손으로 고치지 않고, 3장 step09 사양에 조건 한 줄을 추가하고(입력 토큰 수가 context_length 보다 짧아도 동작해야 한다) 3장 step09를 다시 생성해 3장 검증과 4장 step02를 모두 통과시켰습니다. 그 과정의 실측 기록은 이 장 실습 step02에 있습니다. 교훈: 접점의 조건은 그 접점을 쓰는 쪽의 입력까지 내다보고 적어야 한다.

4.3 층 정규화 — 벡터마다 눈금을 다시 맞춘다 (step03)

12층을 쌓으면 생기는 첫 번째 문제는 값의 크기입니다. 층을 지날 때마다 값이 곱해지고 더해지므로, 어떤 벡터는 수백으로 커지고 어떤 벡터는 0.001로 쪼그라듭니다. 다음 층은 이 들쭉날쭉한 입력을 받아 또 흔들리고, 훈련은 불안정해집니다.

층 정규화(layer normalization)는 벡터 하나하나를 평균 0, 분산 1로 다시 맞춥니다. 시험 점수를 과목마다 표준점수로 바꾸는 것과 같은 발상입니다 — 원점수의 눈금이 과목마다 달라도 표준점수로 바꾸면 같은 눈금에서 비교할 수 있습니다. 핵심은 "벡터 하나 안에서" 한다는 점입니다. (배치, 토큰, 768) 텐서라면 토큰마다 768개 값의 평균과 분산을 구해 그 토큰 벡터만 정규화합니다 — 배치 안의 다른 문장이나 다른 토큰은 전혀 보지 않으므로 배치 크기와 무관합니다.

flowchart LR
    X["층 출력 벡터
[0.5, 0.0, 0.1, 0.0, 0.2, 0.0]
평균 0.13, 분산 0.03"] --> M["평균을 빼고"] --> V["√(분산 + eps) 로 나누고"] --> N["정규화 벡터
평균 0, 분산 ≈ 1"] N --> S["× scale + shift
(훈련으로 바뀌는 두 파라미터)"] --> Y["출력"]
그림 4-3. 층 정규화의 네 단계. 앞의 두 단계가 눈금을 맞추고, 뒤의 scale·shift가 "필요하면 되돌릴 자유"를 모델에 준다.

수식으로는 다음과 같습니다 (x는 벡터 하나, μ는 평균, σ²은 분산):

x̂ = x − μ√(σ² + ε),    y = scale · x̂ + shift

세 가지가 값을 결정하므로 사양이 못 박습니다. ① eps(ε) — 분산이 0이면 0으로 나누게 되므로 분모에 더하는 아주 작은 수, 1e-5. ② 편향 분산(biased variance) — 편차 제곱의 합을 n으로 나눕니다(통계학의 표본분산은 n − 1로 나누지만, GPT-2는 n으로 나눕니다. 이걸 바꾸면 5장에서 가져올 GPT-2 가중치와 계산이 어긋납니다). ③ scale(초기값 1)과 shift(초기값 0)라는 속성 이름 — 5장의 가중치 적재가 이 이름으로 값을 채웁니다.

손계산 ② [1, 3, 5]를 정규화하면

평균 μ   = (1 + 3 + 5) ÷ 3 = 3
편향 분산 σ² = ((1−3)² + (3−3)² + (5−3)²) ÷ 3 = (4 + 0 + 4) ÷ 3 = 2.6667
표준편차  = √2.6667 = 1.6330
정규화    = [(1−3)/1.633, (3−3)/1.633, (5−3)/1.633] = [−1.2247, 0, 1.2247]
검산      = 평균 0 ✓, 편향 분산 (1.5 + 0 + 1.5) ÷ 3 = 1 ✓

(eps는 무시했습니다. 다음 손계산에서 eps가 값에 미치는 영향을 봅니다.)

손계산 ③ 왜 정규화 후 분산이 정확히 1이 아닌가 — eps의 흔적

step03의 실험은 (2, 6) 텐서를 정규화한 뒤 분산을 다시 재는데, 결과가 0.9995, 0.9997입니다. 1이 아닌 이유:

정규화 = (x − μ) ÷ √(σ² + ε)
정규화된 값의 분산 = σ² ÷ (σ² + ε)      ← (x − μ) 의 분산이 σ² 이므로

첫째 행: σ² = 0.0192, ε = 0.00001  →  0.0192 ÷ 0.01921 = 0.99948 ≈ 0.9995 ✓
둘째 행: σ² = 0.0332              →  0.0332 ÷ 0.03321 = 0.99970 ≈ 0.9997 ✓

분산이 작을수록 ε의 몫이 커져 1에서 더 멀어집니다. 768차원 실제 벡터에서는 분산이 이보다 크므로 1.0000으로 보입니다. 실험에서 굳이 작은 층의 출력(분산 0.02 안팎)을 쓴 덕에 ε의 흔적이 보이는 것이고, 이것이 "정확히 1" 대신 0.9995를 기대 출력에 적은 이유입니다 — 흔들리는 값이 아니라 계산이 정한 값이기 때문입니다.

4.4 GELU와 피드포워드 — 넓혔다가 좁힌다 (step04)

활성화 함수(activation function)는 층과 층 사이에 끼우는 비선형 함수입니다. 이것이 없으면 선형층을 아무리 쌓아도 수학적으로 선형층 하나와 같아집니다(행렬 곱의 곱은 행렬 곱 하나). 예전 표준 ReLU는 음수를 전부 0으로 잘라 버리는 꺾인 함수인데, GPT-2는 더 매끄러운 GELU(Gaussian Error Linear Unit)를 씁니다. GELU는 음수 입력도 완전히 죽이지 않고 조금 통과시키며(GELU(−1) ≈ −0.16), 모든 지점에서 부드럽게 미분됩니다 — 훈련 신호가 끊기는 지점이 없습니다.

xReLU(x)GELU(x)차이
−30−0.0036거의 0이지만 정확히 0은 아니다
−10−0.1588음수를 약하게 통과시킨다
000둘 다 원점을 지난다
110.8412양수는 거의 그대로
332.9964큰 양수는 ReLU와 같아진다

표 4-1. ReLU와 GELU를 다섯 점에서 비교. GELU는 −1 근처에서 살짝 아래로 내려갔다가 올라오는 곡선이다.

GELU의 정확한 정의는 x에 정규분포 누적함수를 곱한 것인데, GPT-2 원본은 계산이 싼 tanh 근사식을 씁니다:

GELU(x) ≈ 0.5 · x · (1 + tanh[ √(2/π) · (x + 0.044715 · x³) ])

계수 0.044715와 √(2/π)가 값을 결정하므로 사양이 이 식을 적습니다. 이것은 코드를 받아쓰게 하는 것이 아니라 수학을 지정하는 것입니다 — 어떤 식으로 구현하든(torch.tanh든 math.tanh를 원소마다 돌리든) 같은 값이 나옵니다.

손계산 ④ GELU(1)을 tanh 근사식으로

√(2/π) = √0.6366 = 0.7979
x + 0.044715·x³ = 1 + 0.044715 = 1.044715
곱 = 0.7979 × 1.044715 = 0.8336
tanh(0.8336) = 0.6823
GELU(1) = 0.5 × 1 × (1 + 0.6823) = 0.8412 ✓   (step04 출력 0.8412)

GELU(−1): 곱 = 0.7979 × (−1.044715) = −0.8336, tanh = −0.6823
          = 0.5 × (−1) × (1 − 0.6823) = −0.1588 ✓

피드포워드 신경망(feed forward network, FFN)은 GELU를 가운데 끼운 2층 신경망입니다. 768차원을 4배인 3,072차원으로 넓혔다가 다시 768로 좁힙니다. 어텐션이 "어느 토큰을 볼지"(토큰 사이의 정보 이동)를 맡는다면, 피드포워드는 토큰마다 독립적으로 모은 정보를 "소화"합니다 — 좁은 통로에서 넓은 작업대로 펼쳐 조합을 만든 뒤 다시 압축해 돌아오는 구조입니다.

flowchart LR
    I["입력
768"] --> L1["선형층
768 → 3072"] --> G["GELU"] --> L2["선형층
3072 → 768"] --> O["출력
768"]
그림 4-4. 피드포워드의 넓혔다 좁히기. 가운데 3,072는 768의 4배 — GPT-2의 설계 수치이고, 파라미터의 3분의 2가 여기 몰린다.

손계산 ⑤ 피드포워드 파라미터 — 어텐션의 두 배

선형층 1: 768 × 3,072 = 2,359,296  + 편향 3,072
선형층 2: 3,072 × 768 = 2,359,296  + 편향   768
합계     = 2,359,296 × 2 + 3,072 + 768 = 4,722,432
비교     : 어텐션 2,360,064 의 정확히 2.0009배

속성 이름 layers와 그 안의 순서(선형 → GELU → 선형)는 접점입니다 — 5장 가중치 적재가 layers[0], layers[2]로 두 선형층을 지목합니다.

4.5 지름길 연결 — 기울기가 사라지지 않게 (step05)

12층을 쌓으면 생기는 두 번째 문제는 훈련 신호의 소멸입니다. 훈련은 "출력의 오차를 뒤에서 앞으로 전파하며 층마다 파라미터를 조금씩 조절하는" 과정입니다(역전파, backpropagation). 그 신호(기울기, gradient)는 층을 거슬러 올라갈 때마다 그 층의 미분값과 곱해집니다. 곱하는 값이 1보다 작으면 곱할수록 작아져서, 앞층에 도착할 즈음엔 거의 0입니다 — 기울기 소실(vanishing gradient). 앞층은 배우지 못합니다.

손계산 ⑥ 곱셈 경로는 사라지고 덧셈 경로는 살아남는다

층마다 신호가 0.3 배로 줄어든다고 하자 (GELU 의 기울기는 대체로 1 보다 작다).
곱셈 경로 5층:  0.3 × 0.3 × 0.3 × 0.3 × 0.3 = 0.00243    ← 앞층에 도착한 신호
지름길이 있으면 각 층의 출력이 (입력 + 층의 계산) 이라 기울기가 (1 + 0.3) 배:
덧셈 경로 5층:  1.3 × 1.3 × 1.3 × 1.3 × 1.3 = 3.71        ← 오히려 커진다

지름길 연결(shortcut connection) — 잔차 연결(residual connection)이라고도 합니다 — 은 층의 출력에 그 층의 입력을 그대로 더합니다: 출력 = 입력 + 층(입력). 덧셈에는 미분값이 1인 경로가 생기므로 기울기가 감쇠 없이 앞층까지 갑니다. 2015년 이미지 인식에서 152층 신경망을 처음 훈련해 낸 기법이고, 이후 GPT를 포함한 모든 깊은 신경망의 표준이 됐습니다.

flowchart LR
    subgraph NO["지름길 없이"]
        direction LR
        a1["x"] --> l1["층 1"] --> l2["층 2"] --> l3["층 3"] --> o1["출력"]
    end
    subgraph YES["지름길 있이"]
        direction LR
        a2["x"] --> m1["층 1"] --> p1["+"] --> m2["층 2"] --> p2["+"] --> o2["출력"]
        a2 -->|"그대로 더한다"| p1
        p1 -->|"그대로 더한다"| p2
    end
그림 4-5. 지름길 연결. 오른쪽은 층을 건너뛰는 덧셈 경로가 있어 기울기가 감쇠 없이 앞층까지 간다. 층의 계산은 "입력을 얼마나 바꿀지"만 배우면 된다.

step05는 5층 미니 신경망(입력 3차원 → 3 → 3 → 3 → 3 → 1)을 지름길 없이·있이 만들어 층별 가중치 기울기의 크기를 비교합니다. 같은 시드로 같은 초기 가중치에서 출발하므로 차이는 지름길 하나뿐입니다.

층지름길 없이지름길 있이배율
층 0 (맨 앞)0.0002020.221698약 1,100배
층 10.0001200.206941약 1,700배
층 20.0007150.328970약 460배
층 30.0013990.266573약 190배
층 4 (맨 뒤)0.0050501.325854약 260배

표 4-2. step05 실측. 지름길 없이는 앞으로 갈수록 기울기가 0.0002까지 사라지고, 지름길이 있으면 맨 앞층도 0.22로 살아 있다.

이 스텝은 다음 스텝이 import 하는 것이 없어 접점이 거의 없습니다. 그런데도 값이 소수점 여섯째 자리까지 같은 이유는 사양이 동작을 완전히 정했기 때문입니다 — 층 구성과 순서, 편향 유무, 입력, 손실(출력을 0에 맞추는 평균제곱오차), 시드 위치, 어떤 기울기를 어떻게 평균낼지. 구현(클래스 이름, 루프 방식)은 자유입니다.

4.6 트랜스포머 블록 — 부품 네 개를 하나로 (step06)

부품이 다 모였습니다. 트랜스포머 블록(transformer block)은 두 구간으로 데이터를 통과시키고, 구간마다 지름길이 있습니다.

flowchart TB
    X["입력 x (배치, 토큰, 768)"] --> N1["norm1 — LayerNorm"]
    N1 --> ATT["att — MultiHeadAttention (3장)"]
    ATT --> D1["drop_shortcut — 드롭아웃"]
    D1 --> P1(("+"))
    X -->|"지름길"| P1
    P1 --> N2["norm2 — LayerNorm"]
    N2 --> FF["ff — FeedForward"]
    FF --> D2["drop_shortcut — 드롭아웃"]
    D2 --> P2(("+"))
    P1 -->|"지름길"| P2
    P2 --> OUT["출력 (배치, 토큰, 768) — 입력과 같은 모양"]
그림 4-6. 트랜스포머 블록 내부. 정규화를 부품 앞에 두는 Pre-LayerNorm 배치가 GPT-2 방식이다. 상자 안의 이름이 곧 속성 이름(접점)이다.

정규화를 어텐션·피드포워드 앞에 두는 것을 Pre-LayerNorm이라 합니다(원조 트랜스포머는 뒤에 두었습니다 — Post-LayerNorm). 앞에 두면 지름길 경로에 정규화가 끼지 않아 기울기가 더 깨끗하게 흐르고, 훈련이 안정적입니다. 핵심 성질은 입력과 출력의 모양이 같다는 것 — 그래서 이 블록은 레고처럼 몇 번이든 이어 쌓을 수 있습니다.

손계산 ⑦ 블록 하나의 파라미터

어텐션 (손계산 ①)      2,360,064
피드포워드 (손계산 ⑤)   4,722,432
norm1: scale 768 + shift 768 =  1,536
norm2:                          1,536
드롭아웃·마스크:                    0
합계                          7,085,568   ← step06 출력과 일치

속성 이름(att, ff, norm1, norm2, drop_shortcut)과 생성 순서는 접점입니다. 이름은 5장이 가중치를 채울 때 씁니다. 순서는 시드 때문입니다 — 어텐션과 피드포워드는 만들 때 난수를 소비하므로, 어느 쪽을 먼저 만드느냐에 따라 step07·08의 값이 달라집니다. 정규화(1과 0으로 초기화)와 드롭아웃은 난수를 소비하지 않습니다.

4.7 GPTModel — 블록 12개를 쌓고 세어 본다 (step07)

마지막 조립입니다. GPTModel의 forward는 지금까지의 강좌를 한 줄로 꿴 것입니다:

flowchart LR
    A["토큰 ID
(2, 4)"] --> B["tok_emb + pos_emb
(2, 4, 768)"] --> C["drop_emb"] --> D["trf_blocks
블록 12개 순서대로"] --> E["final_norm"] --> F["out_head
768 → 50257"] --> G["로짓
(2, 4, 50257)"]
그림 4-7. GPTModel의 forward. 2장(임베딩) → 3장(어텐션이 든 블록) → 이번 장(정규화·출력층) 순서로 앞 장의 결과가 그대로 흐른다.

출력 모양의 마지막 차원 50,257이 어휘 크기입니다. 즉 모델은 위치마다 "다음 토큰이 어휘의 각 후보일 점수" 50,257개를 내놓습니다. 이 정규화 전 점수를 로짓(logit)이라 부릅니다. 소프트맥스를 씌우면 1장 손계산에서 본 확률 분포가 되고, 가장 큰 값을 고르면 다음 토큰입니다 — step08이 그 일을 합니다. 출력층 out_head는 편향이 없습니다 — GPT-2 원본과 같고, 5장 가중치 적재에서 모양이 맞아야 합니다.

손계산 ⑧ 파라미터 1억 6,300만 개를 한 줄씩

토큰 임베딩  tok_emb:   50,257 × 768              =  38,597,376
위치 임베딩  pos_emb:    1,024 × 768              =     786,432
블록 12개:   7,085,568 × 12 (손계산 ⑦)           =  85,026,816
최종 정규화  final_norm: 768 + 768                =       1,536
출력층       out_head:   768 × 50,257 (편향 없음)  =  38,597,376
합계                                              = 163,009,536   ← step07 출력과 일치

손계산 ⑨ "124M"의 유래와 메모리

토큰 임베딩(50,257 × 768)과 출력층(768 × 50,257)은 원소 수가 같다.
원조 GPT-2 는 이 둘에 같은 행렬 하나를 썼다 — 가중치 묶기(weight tying).
묶으면:   163,009,536 − 38,597,376 = 124,412,160  ≈ 1억 2,400만 = "124M"

메모리:   파라미터 하나가 32비트 실수 = 4바이트
          163,009,536 × 4 = 652,038,144 바이트 ÷ (1024 × 1024) = 621.83 MB

우리는 묶지 않습니다 — 분리하면 훈련이 조금 더 잘 되고, 코드가 단순합니다. 대신 이름은 관례대로 124M이라 부릅니다.

pie showData
    title GPT-2 소형 파라미터 163,009,536 개의 구성
    "트랜스포머 블록 12개" : 85026816
    "토큰 임베딩" : 38597376
    "출력층" : 38597376
    "위치 임베딩" : 786432
    "최종 정규화" : 1536
그림 4-8. 파라미터의 구성. 블록 12개가 52%, 토큰 임베딩과 출력층이 각각 24% — 이 둘이 같은 크기라서 가중치 묶기가 가능하다. 위치 임베딩과 정규화는 합쳐도 0.5%다.

4.8 첫 텍스트 생성 — 자기회귀 루프 (step08)

완성된 모델로 글을 씁니다. 1장에서 본 자기회귀(autoregressive) 생성 — "예측 → 뒤에 붙임 → 다시 예측" — 을 직접 구현합니다.

flowchart TB
    S["Hello, I am
[15496, 11, 314, 716]"] --> M["모델 → 로짓 (1, 4, 50257)"] M --> L["마지막 위치의 로짓만
(1, 50257)"] L --> A["가장 큰 값의 토큰 ID
argmax"] A --> C["끝에 붙인다
[15496, 11, 314, 716, 27018]"] C -->|"6번 반복"| M C --> E["길이 10 → 디코딩"]
그림 4-9. 자기회귀 생성 루프. 한 바퀴에 토큰 하나. 모델은 매번 지금까지의 전체 시퀀스를 다시 읽는다 — 그래서 뒤로 갈수록 느려진다.

한 바퀴의 네 단계: ① 지금까지의 토큰(문맥 길이를 넘으면 뒤쪽 context_size개만)을 모델에 넣어 로짓을 얻는다. ② 마지막 위치의 로짓만 취한다 — 다음 토큰을 예측하는 자리는 거기뿐이다. ③ 가장 큰 값의 토큰을 고른다(탐욕적 디코딩, greedy decoding). ④ 끝에 붙인다.

두 가지를 잊으면 결과가 달라집니다. 평가 모드(eval())로 전환해 드롭아웃을 꺼야 같은 입력에 같은 출력이 나옵니다. 기울기 기록 끄기(torch.no_grad())는 값에는 영향이 없지만, 훈련이 아닌 생성에서 메모리와 시간을 아낍니다. 로짓에 소프트맥스를 씌워 확률로 바꿔도 최댓값의 위치는 같으므로, 확률로 바꿀지는 에이전트의 몫입니다.

손계산 ⑩ 왜 소프트맥스를 생략해도 같은 토큰이 나오는가

로짓 세 개: [2.0, 5.0, 1.0]   → 가장 큰 자리: 두 번째
소프트맥스: e² = 7.39, e⁵ = 148.41, e¹ = 2.72, 합 158.52
          → [0.047, 0.936, 0.017]  → 가장 큰 자리: 두 번째 (같다)
지수 함수는 단조 증가 — 순서를 바꾸지 않는다. 소프트맥스는 확률이 필요할 때(5장 온도·top-k 샘플링)만 쓴다.

결과는 Hello, I am Featureiman Byeswickattribute argue — 의미 없는 낱말 나열입니다. 시드 123의 무작위 가중치이니 당연하고, 같은 시드에 같은 순서로 층을 만들었으니 모두가 정확히 이 엉터리를 봅니다(원서도 같은 값). 이 엉터리를 말이 되게 만드는 것이 5장의 일입니다.

실습 — 루프 여덟 바퀴

여덟 스텝은 접점으로 이어져 있습니다. 순서대로 진행하세요. 3장 실습을 끝낸 상태에서 시작해야 합니다 — step02가 workspace/ch03의 파일을 import 합니다.

flowchart LR
    S1["step01
GPT_CONFIG_124M
make_batch"] --> S2["step02
build_attention"] C3["3장 step09
MultiHeadAttention"] ==> S2 S1 --> S4["step04
GELU, FeedForward"] S4 --> S5["step05
지름길 실험"] S3["step03
LayerNorm"] --> S6["step06
TransformerBlock"] S2 --> S6 S4 --> S6 S1 --> S6 S6 --> S7["step07
GPTModel"] S3 --> S7 S1 --> S7 S7 --> S8["step08
generate_text_simple"] S1 --> S8
그림 4-10. 4장 실습의 접점 흐름. step01의 설정을 거의 전부가 쓰고, step02·03·04가 부품을, step06이 블록을, step07이 모델을, step08이 생성 함수를 만든다. 굵은 화살표가 챕터 경계를 넘는 import 다.

각 스텝은 bash harness/run_loop.sh ch04 prompts/ch04/stepNN-*.md → bash harness/verify.sh ch04 stepNN → bash harness/verify.sh ch04 stepNN --solution의 세 명령입니다. 이번 장 사양의 특징은 속성 이름이 접점이라는 점입니다 — 3장에서는 "다음 스텝이 import 하니까" 이름을 못 박았지만, 이번 장은 여기에 "5장이 가중치를 채울 때 이 이름으로 찾는다"가 더해집니다(scale·shift, layers, att·ff·norm1·norm2, tok_emb·pos_emb·trf_blocks·final_norm·out_head). 파라미터 개수를 출력하게 한 것도 계약 설계입니다 — 편향 유무와 층 구성이 틀리면 개수가 달라져 검증기가 잡아냅니다.

step01. 설계도 — GPT_CONFIG_124M과 시험용 배치

무엇을 — GPT-2 소형(124M)의 설계 수치 여섯 개를 담은 설정 딕셔너리 GPT_CONFIG_124M과, 조립 내내 쓸 (2, 4) 시험용 토큰 배치를 만드는 make_batch()
왜 — 이 장의 모든 부품(어텐션·정규화·피드포워드·블록·모델)이 같은 수치를 참조해야 어긋나지 않는다. 설계도를 한 곳에 둔다
맥락 — 4장 조립의 출발점. 이후 일곱 스텝이 전부 이 파일을 import 한다. 수치는 2장(어휘 50,257·문맥 1,024·임베딩 768)과 3장(헤드 12·드롭아웃 0.1)에서 이미 만난 것들이다
기대 결과 — 설정 여섯 줄, 그리고 두 문장이 BPE로 각각 4토큰이 되어 쌓인 torch.Size([2, 4]) 배치 — 2장에서 배운 토큰 ID가 GPT의 입력이 된다
hermes에게 전달되는 사양 (prompts/ch04/step01-gpt-config.md)
step01_gpt_config.py 라는 파이썬 파일을 만들어줘.

만들 것: GPT-2 소형(124M)의 설계 수치를 담은 설정 딕셔너리와, 조립 내내 쓸 시험용 입력 배치.

접점 (이후 모든 스텝이 import 한다):
- 모듈 최상위의 딕셔너리 GPT_CONFIG_124M — 키와 값은 정확히 이 여섯 개, 이 순서로: vocab_size 는 50257, context_length 는 1024,
  emb_dim 은 768, n_heads 는 12, n_layers 는 12, drop_rate 는 0.1.
- 함수 make_batch() — 문장 Every effort moves you 와 Every day holds a 를 tiktoken 의 gpt2 인코딩으로 각각 토큰 ID 로 바꿔(각 4개)
  두 행으로 쌓은 (2, 4) 정수 텐서를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 설정의 항목마다 vocab_size: 50257 처럼 키: 값 한 줄씩, 딕셔너리 순서대로 여섯 줄
- 배치: 한 줄 뒤 다음 줄부터 make_batch() 의 결과 텐서
- 배치 모양: torch.Size([2, 4]) 처럼 배치의 모양

지켜야 할 것:
- torch, tiktoken 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
vocab_size: 50257
context_length: 1024
emb_dim: 768
n_heads: 12
n_layers: 12
drop_rate: 0.1
배치:
tensor([[6109, 3626, 6100,  345],
        [6109, 1110, 6622,  257]])
배치 모양: torch.Size([2, 4])
[OK] 9줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch04/step01_gpt_config.py, 핵심):

GPT_CONFIG_124M = {
    "vocab_size": 50257,       # 2장 BPE 어휘 크기
    "context_length": 1024,    # 한 번에 볼 수 있는 최대 토큰 수
    "emb_dim": 768,            # 임베딩(토큰 벡터) 차원
    "n_heads": 12,             # 어텐션 헤드 수 (3장)
    "n_layers": 12,            # 트랜스포머 블록 층수
    "drop_rate": 0.1,          # 드롭아웃 비율 (3장)
}

def make_batch():
    tokenizer = tiktoken.get_encoding("gpt2")
    rows = [torch.tensor(tokenizer.encode(text)) for text in SAMPLE_TEXTS]
    return torch.stack(rows, dim=0)

딕셔너리가 곧 설계도입니다. make_batch는 문장마다 텐서를 만들고 torch.stack으로 쌓습니다 — 두 문장이 같은 길이(4)라서 쌓입니다. 길이가 달랐다면 2장 step06의 데이터로더처럼 창 크기를 맞춰야 했을 겁니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 torch.stack 대신 인코딩 결과 리스트 둘을 torch.tensor에 한 번에 넣어 (2, 4)를 만들었고 — 길이가 같으니 이것도 맞습니다 — 변수명을 한글(배치_목록, 키, 값)로 썼습니다. 출력 9줄은 동일. 응답 전에 임시 검증 스크립트를 스스로 만들어 돌린 뒤 지웠고, 4분 안에 끝났습니다. 첫 시도에 통과.

step02. 3장 부품 점검 — 챕터 경계를 넘는 첫 import

무엇을 — 3장 step09의 MultiHeadAttention을 ../ch03에서 import 해 GPT-2 소형 크기(768차원·12헤드·문맥 1,024)로 만들고, 토큰 4개짜리 짧은 입력을 통과시키는 build_attention(cfg)
왜 — 직접 만든 부품이 정말 부품으로 기능하는지 확인하는 순간이다. 특히 "문맥 길이보다 짧은 입력"은 3장 실습에서 한 번도 넣어 보지 않은 조건이다
맥락 — 3장에서 검증한 산출물을 처음으로 챕터 경계를 넘어 재사용한다. step06 트랜스포머 블록이 이 파일의 build_attention으로 어텐션을 만든다
기대 결과 — 출력 모양 torch.Size([2, 4, 768]), 헤드 차원 64, 파라미터 2,360,064개 — 손으로 셀 수 있는 값
hermes에게 전달되는 사양 (prompts/ch04/step02-reuse-attention.md)
step02_reuse_attention.py 라는 파이썬 파일을 만들어줘.

만들 것: 3장에서 만든 멀티헤드 어텐션을 챕터 경계를 넘어 import 하고, GPT-2 소형 크기로 만들어 짧은 입력에서 동작하는지 점검하는 스크립트.

접점 (3장 산출물 재사용 + step06 이 import 한다):
- 3장 작업 디렉토리는 이 파일이 있는 디렉토리의 옆 디렉토리 ../ch03 이다. 그 디렉토리를 import 검색 경로에 추가한 뒤,
  거기 있는 step09_multihead_attention.py 의 클래스 MultiHeadAttention(d_in, d_out, context_length, dropout, num_heads, qkv_bias=False) 를 import 한다
  (그 파일이 같은 디렉토리의 다른 3장 파일을 import 하므로 경로 추가가 꼭 필요하다). 어텐션을 새로 구현하지 않는다.
- 같은 디렉토리의 step01_gpt_config.py 에서 GPT_CONFIG_124M 을 import 한다.
- 함수 build_attention(cfg) — 설정 딕셔너리를 받아 MultiHeadAttention 을 d_in 과 d_out 은 둘 다 cfg 의 emb_dim, context_length 는
  context_length, dropout 은 drop_rate, num_heads 는 n_heads,
  qkv_bias 는 설정에 qkv_bias 키가 있으면 그 값(없으면 False)으로 만들어 반환한다 — 5장이 이 키를 True 로 넣는다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 파이토치 시드 123 고정 직후 (2, 4, 768) 균등 난수 입력을 만들고, 그 다음 build_attention(GPT_CONFIG_124M) 으로 어텐션을 만들어
입력을 통과시킨다. 토큰 수 4 는 문맥 길이 1024 보다 훨씬 짧다 — 이 조건에서 동작해야 한다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 입력 모양: torch.Size([2, 4, 768]) 처럼 입력의 모양
- 어텐션 출력 모양: torch.Size([2, 4, 768]) 처럼 결과의 모양
- 헤드 차원: 64 처럼 emb_dim 을 n_heads 로 나눈 값
- 어텐션 파라미터 개수: 2,360,064 처럼 어텐션의 모든 파라미터 원소 수의 합을 천 단위 콤마로

지켜야 할 것:
- torch 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 3장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측, 3장 step09 재생성 후) ---
입력 모양: torch.Size([2, 4, 768])
어텐션 출력 모양: torch.Size([2, 4, 768])
헤드 차원: 64
어텐션 파라미터 개수: 2,360,064
[OK] 4줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch04/step02_reuse_attention.py, 핵심):

CH03_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "ch03")
sys.path.append(CH03_DIR)                                   # 3장 디렉토리를 import 검색 경로에 추가

from step09_multihead_attention import MultiHeadAttention   # 3장 step09 접점

def build_attention(cfg):
    return MultiHeadAttention(d_in=cfg["emb_dim"], d_out=cfg["emb_dim"], context_length=cfg["context_length"],
                              dropout=cfg["drop_rate"], num_heads=cfg["n_heads"],
                              qkv_bias=cfg.get("qkv_bias", False))   # 5장이 True 로 넣는다 (5장 제작 중 추가)

경로를 파일 위치 기준(__file__)으로 만든 것은 어느 디렉토리에서 실행해도 같은 곳을 가리키게 하려는 것입니다. sys.path.append 뒤의 import는 평범한 import입니다 — 3장 파일이 step01_simple_attention을 import 하는 것도 같은 경로 덕에 됩니다.

실측 — 이 장에서 가장 많은 일이 벌어진 스텝

hermes(qwen3.8:27b)가 만든 4장 파일은 기준 구현과 사실상 같았습니다(파일 위치 기준 ../ch03, os.pardir 사용). 그런데 첫 실행은 3장 파일 안에서 RuntimeError: The size of tensor a (1024) must match the size of tensor b (4)로 죽었습니다 — 4.2절에서 설명한 마스크 슬라이싱 빈틈입니다. hermes는 파일을 1분 만에 만든 뒤 자체 점검에서 이 오류를 만나자, 사양의 제약 "3장 디렉토리의 파일은 고치지 않는다"를 어기고 3장 파일의 마스크 줄을 직접 패치했고(12분 뒤), 15분 상한에 걸려 종료됐습니다.

제작자는 그 패치를 되돌리고 원칙대로 갔습니다: 3장 step09 사양에 조건 한 줄을 추가 → 3장 step09 재생성(2분, self.mask[:tokens, :tokens]로 자름, 3장 검증 8/8 유지) → 4장 step02 재검증 통과. 재생성 때 hermes는 자체 점검에 "4장 시나리오(문맥 1024, 토큰 4)"를 스스로 넣어 시험했습니다 — 사양의 괄호 설명을 읽은 것입니다. 이 실측은 하네스 두 가지를 고치게 했습니다: ① run_loop.sh가 set -e 아래에서 상한 초과(124)를 처리하지 못하고 죽던 버그, ② prompt-guide 문제 해결 사다리 6번 "다른 장 파일 안에서 죽으면 그 장의 사양을 고친다 + hermes가 그 파일을 패치해 놓았는지 확인한다".

그리고 5장 제작 중(2026-08-30) 이 스텝은 한 번 더 재생성됐습니다 — 3장 step09에 qkv_bias 옵션이 추가되면서 build_attention(cfg)가 "설정에 qkv_bias 키가 있으면 그 값(없으면 False)으로 넘긴다"를 맡게 됐기 때문입니다. hermes는 qkv_bias=cfg.get("qkv_bias", False) 한 줄을 더했고(3분, 첫 시도), 4장 설정에는 이 키가 없으므로 위 출력과 step06·07·08 검증은 그대로 통과했습니다. 기록은 logs/runs/20260830-151355-ch04-step02.md.

step03. 층 정규화 — 벡터마다 눈금을 다시 맞춘다

무엇을 — 벡터의 마지막 차원을 평균 0·분산 1로 다시 맞추고, 훈련 가능한 scale·shift로 되돌릴 여지를 두는 LayerNorm(emb_dim) 클래스
왜 — 12층을 쌓으면 층을 지날수록 값의 크기가 제멋대로 커지거나 작아져 훈련이 불안정해진다. 층마다 눈금을 다시 맞춰야 한다
맥락 — 트랜스포머 블록(step06)에 두 번, 모델 끝(step07)에 한 번 들어가는 기초 부품. 5장이 scale·shift라는 이름으로 가중치를 채운다
기대 결과 — 작은 층을 지난 결과의 행별 평균이 0.1324·0.2170으로 제각각이던 것이, 통과 후 평균 0.0000·분산 0.9995·0.9997이 된다
hermes에게 전달되는 사양 (prompts/ch04/step03-layernorm.md)
step03_layernorm.py 라는 파이썬 파일을 만들어줘.

만들 것: 벡터마다 평균 0·분산 1 로 눈금을 다시 맞추는 층 정규화 클래스와, 그것이 정말 그렇게 되는지 보이는 실험.

접점 (step06·step07 이 import 하고, 5장이 속성 이름으로 접근한다):
- 클래스 LayerNorm(emb_dim) — torch.nn.Module 을 상속한다. 훈련 가능한 파라미터 두 개를 속성 이름 scale(초기값 전부 1)과
  shift(초기값 전부 0)로 갖는다 — 둘 다 길이 emb_dim.
  - forward(x) — 마지막 차원을 따라 평균과 분산을 구해 (x − 평균) ÷ √(분산 + eps) 로 정규화한 뒤 scale 을 곱하고 shift 를 더해
    반환한다. eps 는 1e-5. 분산은 편향 분산(편차 제곱의 합을 n 으로 나눈 것 — n−1 이 아니다).
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 파이토치 시드 123 고정 직후 (2, 5) 표준정규 난수 입력을 만들고, 그 다음 선형층(5 → 6, 편향 있음)과 ReLU 를 이은 작은 층을 만들어
입력을 통과시킨다 — 결과 (2, 6) 은 행마다 평균과 분산이 제각각이다. 이 결과를 LayerNorm(6) 에 통과시킨다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 정규화 전 평균: tensor([0.1324, 0.2170]) 처럼 정규화 전 결과의 행별 평균 (길이 2 텐서를 print("라벨:", 텐서) 꼴로 — 뒤에 grad_fn 이 붙어도 된다)
- 정규화 전 분산: tensor([0.0192, 0.0332]) 처럼 정규화 전 결과의 행별 편향 분산
- 정규화 후 평균의 절댓값 최대: 0.0000 처럼 정규화 후 행별 평균 가운데 절댓값이 가장 큰 값을 소수점 넷째 자리까지
- 정규화 후 분산: tensor([0.9995, 0.9997]) 처럼 정규화 후 행별 편향 분산
- 파라미터 개수: 12 처럼 LayerNorm(6) 의 파라미터 원소 수의 합

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
정규화 전 평균: tensor([0.1324, 0.2170], grad_fn=<MeanBackward1>)
정규화 전 분산: tensor([0.0192, 0.0332], grad_fn=<VarBackward0>)
정규화 후 평균의 절댓값 최대: 0.0000
정규화 후 분산: tensor([0.9995, 0.9997], grad_fn=<VarBackward0>)
파라미터 개수: 12
[OK] 5줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch04/step03_layernorm.py, 핵심):

class LayerNorm(nn.Module):
    def __init__(self, emb_dim):
        super().__init__()
        self.eps = 1e-5                                   # 0 으로 나누는 것을 막는 아주 작은 수
        self.scale = nn.Parameter(torch.ones(emb_dim))    # 정규화 뒤 다시 늘리는 배율 (훈련으로 바뀐다)
        self.shift = nn.Parameter(torch.zeros(emb_dim))   # 정규화 뒤 다시 옮기는 이동량

    def forward(self, x):
        mean = x.mean(dim=-1, keepdim=True)
        var = x.var(dim=-1, keepdim=True, unbiased=False)  # 분모 n 인 편향 분산 — GPT-2 와 같다
        normalized = (x - mean) / torch.sqrt(var + self.eps)
        return self.scale * normalized + self.shift

nn.Parameter로 감싼 텐서만 parameters()에 잡혀 훈련됩니다 — 그래서 파라미터 개수가 12(scale 6 + shift 6)입니다. keepdim=True는 평균의 모양을 (2, 1)로 유지해 (2, 6)에서 뺄 때 브로드캐스팅(2장 손계산 ⑤)이 되게 합니다. 파이토치에 nn.LayerNorm이 이미 있지만, 직접 만들어야 5장에서 GPT-2 가중치를 scale·shift 이름으로 채워 넣을 수 있습니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 eps를 속성으로 두지 않고 식 안에 (var + 1e-5) ** 0.5로 직접 썼고 torch.nn을 별칭 없이 썼습니다 — 값은 같습니다. 파일을 만드는 데 8분, 그 뒤 자체 점검을 반복하다 15분 상한에 걸렸습니다. step02 뒤에 고친 run_loop.sh가 이번엔 상한 초과를 제대로 기록하고 만들어진 파일로 검증을 진행했습니다. 첫 시도에 통과.

step04. GELU와 피드포워드 — 넓혔다가 좁힌다

무엇을 — 매끄러운 활성화 함수 GELU와, 768 → 3,072 → 768로 넓혔다가 좁히는 2층 신경망 FeedForward(cfg)
왜 — 어텐션이 "어디를 볼지"를 정한다면 피드포워드는 모은 정보를 토큰별로 소화한다. 비선형 활성화가 없으면 층을 아무리 쌓아도 선형 하나와 같다
맥락 — 트랜스포머 블록(step06)의 두 번째 부품. step05 지름길 실험도 이 GELU를 쓴다. 5장이 layers의 번호(0, 2)로 두 선형층에 가중치를 채운다
기대 결과 — GELU(−1) = −0.1588 (음수를 완전히 죽이지 않는다), 출력 모양 유지 torch.Size([2, 3, 768]), 파라미터 4,722,432개
hermes에게 전달되는 사양 (prompts/ch04/step04-feedforward.md)
step04_feedforward.py 라는 파이썬 파일을 만들어줘.

만들 것: 매끄러운 활성화 함수 GELU 클래스와, 임베딩 차원을 4배로 넓혔다가 다시 좁히는 2층 피드포워드 신경망 클래스.

접점 (step05·step06 이 import 하고, 5장이 속성 이름과 번호로 접근한다):
- 같은 디렉토리의 step01_gpt_config.py 에서 GPT_CONFIG_124M 을 import 한다.
- 클래스 GELU() — torch.nn.Module 을 상속한다. forward(x) 는 GELU 의 tanh 근사식 0.5 · x · (1 + tanh(√(2/π) · (x + 0.044715 · x³))) 을
  원소별로 계산해 반환한다. 계수는 정확히 이 값들이다.
- 클래스 FeedForward(cfg) — torch.nn.Module 을 상속한다. 속성 layers 가 torch.nn.Sequential 이고 그 안에 **이 순서로**:
  선형층(emb_dim → 4 × emb_dim, 편향 있음), GELU, 선형층(4 × emb_dim → emb_dim, 편향 있음). emb_dim 은 cfg 의 emb_dim.
  forward(x) 는 layers 를 통과시킨 결과를 반환한다 — 입력과 같은 모양.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: GELU 에 [-1, 0, 1] 을 통과시킨다. 파이토치 시드 123 고정 직후 FeedForward(GPT_CONFIG_124M) 을 만들고, 그 다음 (2, 3, 768) 균등 난수
입력을 만들어 통과시킨다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- GELU([-1, 0, 1]): tensor([-0.1588,  0.0000,  0.8412]) 처럼 세 값의 GELU 결과 텐서
- 입력 모양: torch.Size([2, 3, 768]) 처럼 입력의 모양
- 피드포워드 출력 모양: torch.Size([2, 3, 768]) 처럼 결과의 모양
- 피드포워드 파라미터 개수: 4,722,432 처럼 모든 파라미터 원소 수의 합을 천 단위 콤마로

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
GELU([-1, 0, 1]): tensor([-0.1588,  0.0000,  0.8412])
입력 모양: torch.Size([2, 3, 768])
피드포워드 출력 모양: torch.Size([2, 3, 768])
피드포워드 파라미터 개수: 4,722,432
[OK] 4줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch04/step04_feedforward.py, 핵심):

class GELU(nn.Module):
    def forward(self, x):
        return 0.5 * x * (1 + torch.tanh(
            torch.sqrt(torch.tensor(2.0 / torch.pi)) * (x + 0.044715 * torch.pow(x, 3))
        ))

class FeedForward(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Linear(cfg["emb_dim"], 4 * cfg["emb_dim"]),   # 넓힌다 (768 → 3072)
            GELU(),
            nn.Linear(4 * cfg["emb_dim"], cfg["emb_dim"]),   # 다시 좁힌다 (3072 → 768)
        )

GELU는 파라미터가 없는 모듈입니다 — forward만 있고 __init__이 없어도 됩니다. 그래도 nn.Module로 만드는 이유는 nn.Sequential 안에 다른 층과 나란히 끼우기 위해서입니다. nn.Sequential은 들어온 것을 순서대로 통과시키는 컨테이너이고, layers[0]·layers[2]처럼 번호로 꺼낼 수 있습니다 — 5장이 그렇게 접근합니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 √(2/π)를 파이 리터럴 (2 / 3.141592653589793) ** 0.5로, 세제곱을 x ** 3으로 썼습니다 — 값은 소수점 넷째 자리까지 같습니다(사양의 계수는 그대로). 변수명은 한글, 파라미터 출력은 f-문자열 {:,}. 3분 만에 첫 시도 통과.

step05. 지름길 연결 — 기울기가 사라지지 않게

무엇을 — 5층 미니 신경망을 지름길 연결 없이/있이 만들어 층별 가중치 기울기의 크기를 비교하는 실험
왜 — 12층을 그냥 쌓으면 훈련 신호(기울기)가 앞층으로 갈수록 곱셈으로 줄어 사라진다. 층 출력에 입력을 그대로 더하는 지름길이 신호의 고속도로가 된다
맥락 — 트랜스포머 블록(step06)에 지름길이 두 곳 들어간다. 왜 필요한지를 조립 전에 숫자로 확인한다. step04의 GELU를 활성화로 쓴다
기대 결과 — 지름길 없이 층 0 기울기 0.000202, 지름길 있이 0.221698 — 천 배 차이
hermes에게 전달되는 사양 (prompts/ch04/step05-shortcut.md)
step05_shortcut.py 라는 파이썬 파일을 만들어줘.

만들 것: 지름길 연결이 있을 때와 없을 때 깊은 신경망의 층별 기울기 크기를 비교하는 실험 스크립트.

접점 (직전 스텝 재사용):
- 같은 디렉토리의 step04_feedforward.py 에서 GELU 를 import 한다. 활성화를 새로 구현하지 않는다.
- 다음 스텝이 import 하는 것은 없다 — 클래스·함수 이름은 자유.

동작:
- 층 크기 목록 [3, 3, 3, 3, 3, 1] 로 5층 신경망을 만든다. 층 i 는 선형층(크기[i] → 크기[i+1], 편향 있음) 뒤에 GELU 를 붙인 것이며,
  선형층은 층 0 부터 층 4 까지 순서대로 만든다. 지름길 옵션이 켜져 있으면 각 층에서 입력과 층 출력의 모양이 같을 때 입력을 출력에
  더한 것을 다음 층에 넘긴다 (마지막 층은 3 → 1 이라 모양이 달라 더하지 않는다). 꺼져 있으면 층 출력을 그대로 넘긴다.
- 입력은 [[1.0, 0.0, -1.0]] (모양 (1, 3)). 신경망 출력을 0 에 맞추는 평균제곱오차(MSE) 손실을 역전파한 뒤, 층마다 선형층 가중치(weight)
  기울기의 절댓값 평균을 구한다.
- 파이토치 시드 123 을 지름길 없는 신경망을 만들기 직전에 한 번, 지름길 있는 신경망을 만들기 직전에 또 한 번 고정한다 (같은 초기 가중치로 비교).

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 지름길 없이: 한 줄, 이어서 층 0 기울기 평균: 0.000202 처럼 층 0 부터 층 4 까지 다섯 줄 (소수점 여섯째 자리까지)
- 지름길 있이: 한 줄, 이어서 같은 형식으로 다섯 줄

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
지름길 없이:
층 0 기울기 평균: 0.000202
층 1 기울기 평균: 0.000120
층 2 기울기 평균: 0.000715
층 3 기울기 평균: 0.001399
층 4 기울기 평균: 0.005050
지름길 있이:
층 0 기울기 평균: 0.221698
층 1 기울기 평균: 0.206941
층 2 기울기 평균: 0.328970
층 3 기울기 평균: 0.266573
층 4 기울기 평균: 1.325854
[OK] 6줄 전부 일치 (기대 출력은 양 끝 층만)   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch04/step05_shortcut.py, 핵심):

def forward(self, x):
    for layer in self.layers:
        out = layer(x)
        if self.use_shortcut and x.shape == out.shape:
            x = x + out          # 지름길: 입력을 그대로 더한다
        else:
            x = out
    return x

def layer_gradients(model, x):
    output = model(x)
    loss = nn.MSELoss()(output, torch.zeros_like(output))
    loss.backward()
    return [layer[0].weight.grad.abs().mean().item() for layer in model.layers]

지름길은 조건문 한 줄입니다 — x = x + out. loss.backward()가 역전파를 실행해 모든 파라미터의 .grad를 채우고, 우리는 선형층 가중치의 기울기만 꺼내 절댓값 평균을 냅니다. 같은 시드로 두 번 만들었으니 초기 가중치는 같고, 차이는 지름길 하나입니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 클래스를 전혀 쓰지 않았습니다 — 선형층 리스트와 GELU 리스트를 만드는 함수, 통과시키는 함수, 이름은 전부 한글(신경망_만들기, 전달). MSE 손실도 nn.MSELoss 대신 (출력 ** 2).mean()으로 직접 계산했습니다(목표가 0이니 같은 식). 기준 구현과 구조가 가장 다른 스텝인데 값 10개는 소수점 여섯째 자리까지 같았습니다 — 사양이 동작을 완전히 정한 덕입니다. 약 5분, 첫 시도 통과.

step06. 트랜스포머 블록 — 부품 네 개를 하나로

무엇을 — 층 정규화(step03) + 멀티헤드 어텐션(3장, step02로 생성) + 피드포워드(step04) + 지름길 연결(step05)을 조립한 TransformerBlock(cfg)
왜 — 이 블록이 GPT의 반복 단위다. GPT-2 소형은 이 블록을 정확히 12번 쌓은 것이다. 입력과 출력의 모양이 같아야 계속 쌓을 수 있다
맥락 — 4장 부품이 여기서 처음 한 몸이 된다. step07이 이 클래스를 12개 쌓는다. 5장이 속성 이름(att·ff·norm1·norm2)으로 가중치를 채운다
기대 결과 — 입력 [2, 4, 768] → 출력 [2, 4, 768], 블록 파라미터 7,085,568개 — 어텐션 + 피드포워드 + 정규화 두 개의 합
hermes에게 전달되는 사양 (prompts/ch04/step06-transformer-block.md)
step06_transformer_block.py 라는 파이썬 파일을 만들어줘.

만들 것: 층 정규화·멀티헤드 어텐션·피드포워드·지름길 연결을 하나로 조립한 트랜스포머 블록 클래스.

접점 (직전 스텝 재사용 + step07 이 import 하고, 5장이 속성 이름으로 접근한다):
- 같은 디렉토리의 step01_gpt_config.py 에서 GPT_CONFIG_124M 을, step02_reuse_attention.py 에서 build_attention 을,
  step03_layernorm.py 에서 LayerNorm 을, step04_feedforward.py 에서 FeedForward 를 import 한다. 부품을 새로 구현하지 않는다.
- 클래스 TransformerBlock(cfg) — torch.nn.Module 을 상속한다. 생성자에서 **이 순서로** 속성을 만든다:
  att 는 build_attention(cfg), ff 는 FeedForward(cfg), norm1 과 norm2 는 각각 LayerNorm(cfg 의 emb_dim),
  drop_shortcut 은 비율 cfg 의 drop_rate 인 드롭아웃 층.
  - forward(x) — (배치, 토큰 수, emb_dim) 입력. 구간 1: norm1 → att → drop_shortcut 을 거친 결과에 구간 시작 시점의 입력을
    더한다. 구간 2: 그 결과를 norm2 → ff → drop_shortcut 에 통과시킨 것을 구간 2 시작 시점의 값에 더한다. 반환 모양은 입력과 같다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 파이토치 시드 123 고정 직후 (2, 4, 768) 균등 난수 입력을 만들고, 그 다음 TransformerBlock(GPT_CONFIG_124M) 을 만들어 통과시킨다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 입력 모양: torch.Size([2, 4, 768]) 처럼 입력의 모양
- 출력 모양: torch.Size([2, 4, 768]) 처럼 결과의 모양
- 블록 파라미터 개수: 7,085,568 처럼 블록의 모든 파라미터 원소 수의 합을 천 단위 콤마로

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
입력 모양: torch.Size([2, 4, 768])
출력 모양: torch.Size([2, 4, 768])
블록 파라미터 개수: 7,085,568
[OK] 3줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch04/step06_transformer_block.py, 핵심):

def forward(self, x):
    # 구간 1: 정규화 → 어텐션 → 드롭아웃 → 입력을 더한다 (Pre-LayerNorm + 지름길)
    shortcut = x
    x = self.norm1(x)
    x = self.att(x)
    x = self.drop_shortcut(x)
    x = x + shortcut
    # 구간 2: 정규화 → 피드포워드 → 드롭아웃 → 구간 1 의 결과를 더한다
    shortcut = x
    x = self.norm2(x)
    x = self.ff(x)
    x = self.drop_shortcut(x)
    x = x + shortcut
    return x

그림 4-6을 그대로 코드로 옮긴 것입니다. shortcut = x로 구간 시작 시점의 값을 붙잡아 두었다가 끝에서 더합니다. 드롭아웃 층 하나를 두 구간이 같이 씁니다 — 파라미터가 없으니 나눠 쓸 이유가 없습니다. 어텐션은 3장 부품이라 이 파일에는 어텐션 코드가 한 줄도 없습니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)는 forward를 구간마다 한 줄로 접었습니다 — x = x + self.drop_shortcut(self.att(self.norm1(x))). 기준 구현의 다섯 줄과 같은 계산이고, 어느 쪽이 읽기 쉬운지는 취향입니다. 속성 이름과 생성 순서는 사양대로였고 3장 파일은 건드리지 않았습니다. 약 6분, 첫 시도 통과.

step07. GPTModel — 블록 12개를 쌓고 파라미터를 센다

무엇을 — 토큰·위치 임베딩(2장) + 트랜스포머 블록 12층(step06) + 최종 정규화(step03) + 출력층을 조립한 GPTModel(cfg)
왜 — 1장 로드맵의 스테이지 1이 여기서 완성된다. 이 클래스가 5장 사전훈련·6·7장 미세조정의 대상이다
맥락 — forward 흐름이 곧 지금까지의 강좌다: 토큰 ID → 입력 임베딩(2장 step08) → 블록 12층(3장 어텐션 포함) → 정규화 → 어휘 크기의 점수(로짓). 5장이 속성 이름으로 가중치를 채운다
기대 결과 — 출력 torch.Size([2, 4, 50257]), 총 파라미터 163,009,536개, 가중치 묶기를 가정하면 124,412,160개 — 이름 "124M"의 유래, 메모리 621.83 MB
hermes에게 전달되는 사양 (prompts/ch04/step07-gpt-model.md)
step07_gpt_model.py 라는 파이썬 파일을 만들어줘.

만들 것: 임베딩·트랜스포머 블록 12층·최종 정규화·출력층을 조립한 GPT 모델 클래스와 파라미터 세기.

접점 (직전 스텝 재사용 + step08 이 import 하고, 5장이 속성 이름으로 접근한다):
- 같은 디렉토리의 step01_gpt_config.py 에서 GPT_CONFIG_124M 과 make_batch 를, step03_layernorm.py 에서 LayerNorm 을,
  step06_transformer_block.py 에서 TransformerBlock 을 import 한다. 부품을 새로 구현하지 않는다.
- 클래스 GPTModel(cfg) — torch.nn.Module 을 상속한다. 생성자에서 **이 순서로** 속성을 만든다:
  tok_emb 는 임베딩 층(vocab_size 개 × emb_dim), pos_emb 는 임베딩 층(context_length 개 × emb_dim), drop_emb 는 비율 drop_rate 의
  드롭아웃 층, trf_blocks 는 TransformerBlock(cfg) 를 n_layers 개 순서대로 만들어 담은 torch.nn.Sequential, final_norm 은
  LayerNorm(emb_dim), out_head 는 선형층(emb_dim → vocab_size, **편향 없음**). 값은 전부 cfg 에서 꺼낸다.
  - forward(token_ids) — (배치, 토큰 수) 정수 텐서를 받아: 토큰 임베딩에 위치 0, 1, …, 토큰 수 − 1 의 위치 임베딩을 더하고(2장 step08 과
    같은 입력 임베딩) → drop_emb → trf_blocks → final_norm → out_head 를 거쳐 (배치, 토큰 수, vocab_size) 로짓을 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 파이토치 시드 123 고정 직후 GPTModel(GPT_CONFIG_124M) 을 만들고, make_batch() 의 배치를 통과시킨다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 입력 모양: torch.Size([2, 4]) 처럼 배치의 모양
- 출력 모양: torch.Size([2, 4, 50257]) 처럼 로짓의 모양
- 토큰 임베딩 파라미터 개수: 38,597,376 처럼 tok_emb 의 파라미터 원소 수를 천 단위 콤마로
- 총 파라미터 개수: 163,009,536 처럼 모델의 모든 파라미터 원소 수의 합을 천 단위 콤마로
- 가중치 묶기 반영 개수: 124,412,160 처럼 총 개수에서 out_head 의 파라미터 수를 뺀 값을 천 단위 콤마로
- 모델 메모리(MB): 621.83 처럼 파라미터 하나를 4바이트로 쳐서 총 바이트를 1024 × 1024 로 나눈 값을 소수점 둘째 자리까지

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
입력 모양: torch.Size([2, 4])
출력 모양: torch.Size([2, 4, 50257])
토큰 임베딩 파라미터 개수: 38,597,376
총 파라미터 개수: 163,009,536
가중치 묶기 반영 개수: 124,412,160
모델 메모리(MB): 621.83
[OK] 6줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch04/step07_gpt_model.py, 핵심):

def __init__(self, cfg):
    super().__init__()
    self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])        # 2장 토큰 임베딩
    self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])    # 2장 위치 임베딩
    self.drop_emb = nn.Dropout(cfg["drop_rate"])
    self.trf_blocks = nn.Sequential(*[TransformerBlock(cfg) for _ in range(cfg["n_layers"])])
    self.final_norm = LayerNorm(cfg["emb_dim"])
    self.out_head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False)   # 로짓을 내는 출력층

def forward(self, token_ids):
    _, num_tokens = token_ids.shape
    positions = torch.arange(num_tokens, device=token_ids.device)
    x = self.tok_emb(token_ids) + self.pos_emb(positions)   # 2장 step08 과 같은 입력 임베딩
    x = self.drop_emb(x)
    x = self.trf_blocks(x)
    x = self.final_norm(x)
    return self.out_head(x)

nn.Sequential(*[...])의 별표는 리스트를 풀어 인자로 넘기는 파이썬 문법입니다 — 블록 12개가 순서대로 들어갑니다. forward의 여섯 줄이 그림 4-7입니다. 2장 step08에서 torch.arange(4)로 위치 임베딩을 더하던 그 계산이 첫 두 줄에 그대로 있습니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)의 코드는 기준 구현과 거의 같았습니다 — 생성 순서 여섯 개와 forward 흐름이 사양대로. 주석에 "생성 순서대로 속성을 만든다 (RNG 소모 순서도 이것이 된다)"라고 스스로 적었습니다 — 사양이 순서를 못 박은 이유를 이해한 흔적입니다. 약 4분, 첫 시도 통과. 자체 점검 스크립트의 첫 실패는 자기 스크립트의 버그였다고 보고했습니다.

step08. 첫 텍스트 생성 — 자기회귀 루프

무엇을 — 완성된 GPT로 토큰을 하나씩 이어 붙이는 함수 generate_text_simple(model, idx, max_new_tokens, context_size)
왜 — 1장에서 본 자기회귀 생성을 직접 구현한다. "예측 → 뒤에 붙임 → 다시 예측" 루프가 챗봇이 글을 쓰는 방식의 뼈대다
맥락 — 4장의 마무리이자 스테이지 1의 완성. 5장이 이 함수를 import 해 훈련 중 생성 결과를 지켜본다. 아직 훈련 전이라 출력은 엉터리다 — 그 엉터리가 5장이 필요한 이유다
기대 결과 — Hello, I am 뒤에 6토큰이 붙어 길이 10이 되고, 디코딩하면 Hello, I am Featureiman Byeswickattribute argue — 시드 123의 무작위 가중치가 내는, 원서와 같은 엉터리
hermes에게 전달되는 사양 (prompts/ch04/step08-generate.md)
step08_generate.py 라는 파이썬 파일을 만들어줘.

만들 것: 완성된 GPT 모델로 토큰을 하나씩 이어 붙이는 자기회귀 텍스트 생성 함수와 시험 실행.

접점 (직전 스텝 재사용 + 5장이 import 한다):
- 같은 디렉토리의 step01_gpt_config.py 에서 GPT_CONFIG_124M 을, step07_gpt_model.py 에서 GPTModel 을 import 한다.
- 함수 generate_text_simple(model, idx, max_new_tokens, context_size) — (배치, 길이) 토큰 ID 텐서 idx 에 새 토큰 max_new_tokens 개를
  이어 붙인 (배치, 길이 + max_new_tokens) 텐서를 반환한다. 한 바퀴: idx 의 마지막 context_size 개 토큰만 모델에 넣어 로짓을 얻고
  (기울기 기록 없이), 마지막 위치의 로짓에서 값이 가장 큰 토큰 ID 를 골라(탐욕적 디코딩) idx 의 끝에 붙인다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 문장 Hello, I am 을 tiktoken 의 gpt2 인코딩으로 토큰 ID 목록으로 바꾸고, 배치 차원을 붙여 (1, 4) 텐서로 만든다.
파이토치 시드 123 고정 직후 GPTModel(GPT_CONFIG_124M) 을 만들고 평가 모드로 전환한다(드롭아웃 끄기). 이 모델로 새 토큰 6개를
생성한다 — context_size 는 설정의 context_length. 결과의 첫 행 전체를 같은 토크나이저로 디코딩한다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 인코딩된 ID: [15496, 11, 314, 716] 처럼 토큰 ID 목록
- 출력 텐서: 한 줄 뒤 다음 줄부터 생성 결과 텐서
- 출력 길이: 10 처럼 결과 첫 행의 토큰 수
- 디코딩 결과: Hello, I am Featureiman Byeswickattribute argue 처럼 디코딩한 문자열

지켜야 할 것:
- torch, tiktoken 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 검증 결과 (실측) ---
인코딩된 ID: [15496, 11, 314, 716]
출력 텐서:
tensor([[15496,    11,   314,   716, 27018, 24086, 47843, 30961, 42348,  7267]])
출력 길이: 10
디코딩 결과: Hello, I am Featureiman Byeswickattribute argue
[OK] 4줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch04/step08_generate.py, 핵심):

def generate_text_simple(model, idx, max_new_tokens, context_size):
    for _ in range(max_new_tokens):
        idx_cond = idx[:, -context_size:]                  # 문맥 길이를 넘는 앞부분은 잘라 낸다
        with torch.no_grad():                              # 생성에는 기울기 기록이 필요 없다
            logits = model(idx_cond)
        last_logits = logits[:, -1, :]                     # 마지막 위치 = 다음 토큰 예측
        next_id = torch.argmax(last_logits, dim=-1, keepdim=True)   # 가장 점수 높은 토큰 (탐욕적)
        idx = torch.cat((idx, next_id), dim=1)
    return idx

루프 한 바퀴가 그림 4-9의 한 바퀴입니다. idx[:, -context_size:]는 "뒤에서 context_size개" — 지금은 10 < 1024라 잘리는 것이 없지만, 5장에서 긴 문장을 생성할 때 이 줄이 문맥 길이를 지켜 줍니다. keepdim=True는 argmax 결과를 (배치, 1)로 유지해 cat이 되게 합니다.

실측 — 이 교안을 만들 때 hermes가 만든 코드

hermes(qwen3.8:27b)도 소프트맥스 없이 로짓에서 바로 argmax를 골랐고(손계산 ⑩), keepdim 대신 unsqueeze(1)로 차원을 맞췄습니다. 시드 위치와 평가 모드 전환은 사양대로였고, 생성된 토큰 열 개와 디코딩 문자열이 원서·기준 구현과 정확히 같았습니다 — step07의 생성 순서 계약이 지켜졌다는 뜻입니다. 약 6분, 첫 시도 통과.

이로써 여덟 스텝이 끝났습니다. verify.sh ch04 stepNN --solution과 diff로 기준 구현과 비교해 보세요. 이번 장에서 "같아야 하는 것"은 속성 이름, 생성 순서, 편향 유무, 파라미터 개수, 그리고 step08의 토큰 열 개입니다. 그 밖의 것 — 클래스를 쓸지 말지(step05), forward를 몇 줄로 쓸지(step06), 변수명을 한글로 쓸지 — 은 전부 달라도 됩니다. 5장은 workspace/ch04의 GPTModel·generate_text_simple과 workspace/ch02의 데이터로더를 import 해서 시작합니다.

용어 정리

용어원어(약어)한 줄 정의
설정 딕셔너리configuration (config)모델의 설계 수치를 키 이름으로 모아 둔 딕셔너리. 키 이름이 접점이다
어휘 크기vocab_size토크나이저가 아는 토큰 수(50,257). 출력층이 내는 점수의 개수
문맥 길이context_length한 번에 볼 수 있는 최대 토큰 수(1,024). 위치 임베딩 수이자 마스크 크기
임베딩 차원emb_dim토큰 벡터의 길이(768). 모든 부품의 입출력 폭
import 검색 경로sys.path파이썬이 import 할 파일을 찾는 디렉토리 목록. 옆 디렉토리를 붙이면 다른 장의 파일이 보인다
층 정규화layer normalization (LayerNorm)벡터 하나의 값들을 평균 0·분산 1로 다시 맞추는 층
편향 분산biased variance편차 제곱의 합을 n으로 나눈 분산 (n − 1이 아님). GPT-2와 같아야 한다
엡실론epsilon (eps)0으로 나누는 것을 막으려 분모에 더하는 아주 작은 수(1e-5)
scale / shiftscale / shift정규화 뒤 다시 늘리고 옮기는 훈련 가능한 두 파라미터 (초기값 1 / 0)
활성화 함수activation function층 사이에 끼우는 비선형 함수. 없으면 층을 쌓아도 선형 하나와 같다
ReLUrectified linear unit음수를 0으로 잘라 버리는 꺾인 활성화 함수
GELUGaussian Error Linear Unit음수를 약하게 통과시키는 매끄러운 활성화 함수. GPT-2의 선택
tanh 근사tanh approximationGELU를 tanh로 근사한 식. 계수 0.044715, √(2/π)
피드포워드 신경망feed forward network (FFN)선형 → GELU → 선형의 2층 신경망. 768 → 3,072 → 768
은닉 차원hidden dimension피드포워드 가운데의 넓은 차원(3,072 = 4 × 768)
역전파backpropagation출력의 오차를 뒤에서 앞으로 전파하며 파라미터별 기울기를 구하는 알고리즘
기울기gradient손실을 파라미터로 미분한 값. 어느 방향으로 얼마나 고칠지 알려 준다
기울기 소실vanishing gradient층을 거슬러 올라갈수록 기울기가 곱셈으로 줄어 앞층이 배우지 못하는 현상
지름길 연결shortcut connection (residual connection, 잔차 연결)층 출력에 입력을 그대로 더하는 연결. 기울기의 고속도로
평균제곱오차mean squared error (MSE)출력과 목표의 차이를 제곱해 평균한 손실
트랜스포머 블록transformer block정규화·어텐션·정규화·피드포워드를 지름길 둘로 묶은 GPT의 반복 단위
Pre-LayerNormpre-layer normalization정규화를 어텐션·피드포워드 앞에 두는 배치. GPT-2 방식
nn.Sequentialnn.Sequential모듈들을 순서대로 통과시키는 컨테이너. 블록 12개를 쌓는 데 쓴다
nn.Embeddingnn.Embedding정수 ID로 행을 꺼내는 조회표 층. 토큰·위치 임베딩
로짓logit소프트맥스 전의 원시 점수. 모델 출력의 마지막 차원(50,257)
출력층output head (out_head)768차원을 어휘 크기 점수로 바꾸는 선형층. 편향 없음
가중치 묶기weight tying토큰 임베딩과 출력층에 같은 행렬을 쓰는 기법. "124M"의 유래
파라미터 수number of parametersparameters()의 원소 수 합. 편향 유무·층 구성을 검증하는 계약
자기회귀autoregressive자기 출력을 다시 입력에 붙여 다음을 예측하는 생성 방식
탐욕적 디코딩greedy decoding매 단계 가장 큰 값의 토큰을 고르는 생성 방식 (argmax)
평가 모드eval modemodel.eval(). 드롭아웃을 꺼서 결과를 결정적으로 만든다
기울기 기록 끄기torch.no_grad역전파용 기록을 하지 않는 문맥. 생성·평가에서 메모리·시간 절약

정리

  • GPT-2 소형은 설계 수치 여섯 개로 결정된다. 키 이름이 접점이라 모든 부품이 같은 설계도를 읽는다.
  • 3장 부품을 챕터 경계를 넘어 import 했고, 그때 3장 step09 사양의 빈틈(마스크 슬라이싱)이 드러났다. 코드가 아니라 사양을 고쳐 다시 생성하는 것이 2판의 처방이다.
  • 층 정규화는 벡터마다 평균 0·분산 1로 눈금을 맞추고, GELU 피드포워드는 넓혔다 좁히며 소화하고, 지름길 연결은 기울기가 사라지지 않게 한다 — 실험에서 천 배 차이.
  • 트랜스포머 블록은 이 부품들을 Pre-LayerNorm과 지름길 둘로 묶은 것이고, 입력과 출력의 모양이 같아 12개를 쌓을 수 있다. 파라미터는 손으로 셀 수 있다(블록 7,085,568, 모델 163,009,536).
  • 완성된 모델은 자기회귀 루프로 글을 생성하지만 훈련 전이라 엉터리다. 같은 시드·같은 생성 순서면 모두가 같은 엉터리를 본다 — 5장이 이것을 고친다.
  • 사양 설계: 속성 이름·생성 순서·편향 유무를 접점으로, 파라미터 개수를 계약으로 못 박았다. 다음 스텝이 import 하지 않는 실험(step05)도 동작을 완전히 정하면 소수점 여섯째 자리까지 같다.

다음 장 예고

5장에서 이 모델을 훈련합니다. 2장의 데이터로더로 텍스트를 배치로 흘려 넣고, 로짓과 정답 토큰의 차이(교차 엔트로피 손실)를 재고, 역전파로 1억 6,300만 개의 파라미터를 조금씩 고치는 루프를 돕니다. 훈련 중간중간 이번 장의 generate_text_simple로 문장을 뽑아 보면 엉터리가 조금씩 말이 되어 가는 것을 볼 수 있습니다. 온도와 top-k로 생성에 무작위성을 주는 법, 모델을 저장하고 불러오는 법, 그리고 OpenAI가 공개한 GPT-2 가중치를 이번 장의 속성 이름(tok_emb, att.W_query, ff.layers[0], final_norm.scale …)에 맞춰 채워 넣는 법까지 다룹니다.

Chapter 05 전 스텝 검증 완료 (9/9 첫 시도, 5장 사양 수정 0회 · 3장·4장 사양 보완 각 1회)

5장. 사전훈련 — 엉터리를 문장으로, 그리고 남의 1억 2,400만 개를 빌리기

이 장에서 배우는 것

  • 교차 엔트로피 손실이 정확히 무엇인지 — 정답 토큰의 확률 여섯 개로 손으로 계산해 파이토치 값과 맞춘다
  • 소설 한 편을 훈련·검증으로 나누고 "배치 손실 → 역전파 → 갱신"을 90번 도는 훈련 루프 — 손실이 9.78 에서 0.39 로 떨어지고 샘플이 문장이 되는 과정
  • 훈련 손실과 검증 손실이 갈라지는 순간(외우기)을 숫자로 보는 법
  • 온도와 top-k — 늘 같은 문장만 내는 argmax 를 확률적 샘플링으로 바꾸는 두 손잡이
  • 모델과 옵티마이저를 저장하고 되살려 멈춘 자리에서 이어 훈련하기
  • OpenAI 가 공개한 GPT-2 가중치를 4장에서 못 박은 속성 이름에 맞춰 옮겨 넣기 — 그리고 그때 다시 고친 3장 사양
  • 실습 9스텝: 기준 구현 9개, 사양 9개, 5장 끝에는 진짜 GPT-2 가 여러분의 코드 안에서 말을 한다

들어가며 — 4장에서 5장으로

4장 끝에서 여러분은 파라미터 1억 6,300만 개짜리 GPTModel 을 완성했고, Hello, I am 뒤에 Featureiman Byeswickattribute argue 를 붙이는 것을 봤습니다. 구조는 GPT-2 와 똑같은데 왜 엉터리일까요? 파라미터가 전부 무작위 초기값이기 때문입니다. 1억 6,300만 개의 나사가 아무렇게나 조여져 있는 셈이지요. 이 장은 그 나사를 하나씩 조입니다 — 정확히는, 2장 데이터로더가 흘려 주는 배치마다 "다음 토큰을 얼마나 못 맞혔나"(손실)를 재고, 4장에서 실험한 역전파로 모든 파라미터의 기울기를 구해, 손실이 줄어드는 방향으로 조금씩 고치는 일을 90번 반복합니다. 이것이 사전훈련(pretraining)입니다.

이 장에는 두 번의 반전이 있습니다. 첫째, 훈련은 놀랍도록 잘 됩니다 — 3분 만에 훈련 손실이 9.78 에서 0.39 로 떨어지고, 쉼표만 나열하던 샘플이 소설의 문장을 그대로 읊습니다. 그런데 검증 손실은 6 근처에서 멈춥니다. 모델이 소설을 배운 게 아니라 외운 것입니다. 둘째, 그 한계를 넘는 방법은 더 훈련하는 것이 아니라 남이 훈련한 가중치를 빌리는 것입니다. OpenAI 가 공개한 GPT-2 124M 의 가중치를 4장 모델에 옮겨 넣으면, 같은 코드가 갑자기 Every effort moves you forward. The first step is to understand the importance of your work. 라고 말합니다. 그 이식이 가능한 이유는 4장 사양이 속성 이름을 못 박아 두었기 때문이고, 이식에 필요한 조건 하나(질의·키·값 편향)가 3장 사양에 빠져 있어서 이 교안을 만들며 3장 사양을 한 번 더 고쳤습니다(5.8절). 4장에서 본 "사양은 살아 있다"가 이번 장에서 두 번째로 실증됩니다.

flowchart LR
    subgraph A["훈련 (5.1 ~ 5.4)"]
        direction TB
        A1["step01 설정·변환"] --> A2["step02 손실 손계산"] --> A3["step03 분할·손실 함수"] --> A4["step04 훈련 루프"]
    end
    subgraph B["생성 (5.5 ~ 5.7)"]
        direction TB
        B1["step05 온도·top-k 실험"] --> B3["step07 generate"]
        B2["step06 저장·적재"] --> B3
    end
    subgraph C["이식 (5.8)"]
        direction TB
        C1["step08 가중치 내려받기"] --> C2["step09 옮겨 넣기"]
    end
    A --> B --> C
    CH4["4장 GPTModel
generate_text_simple"] ==> A CH2["2장 create_dataloader"] ==> A CH3["3장 MultiHeadAttention
+ qkv_bias (5장 보완)"] ==> C
그림 5-1. 5장 로드맵. 앞 절반은 훈련, 뒤 절반은 생성과 이식이다. 굵은 화살표는 챕터 경계를 넘는 import — 2·3·4장의 산출물이 전부 이 장에서 만난다.

5.1 훈련 준비 — 문맥 256 과 변환 함수 두 개 (step01)

4장 모델은 문맥 길이 1,024 였습니다. 훈련에서는 배치 하나의 계산량이 문맥 길이에 비례해서(어텐션 점수 행렬은 제곱으로) 늘어나므로, 이 장은 문맥을 256 으로 줄인 설정 복사본으로 훈련합니다. 다른 다섯 수치는 4장과 같습니다. 그래서 파라미터는 위치 임베딩만큼만 줄어듭니다.

손계산 ① 문맥을 1,024 에서 256 으로 줄이면 파라미터는

위치 임베딩은 (문맥 길이, 768) 행렬입니다. 줄어드는 행은 1,024 − 256 = 768 행, 행마다 768 개 → 768 × 768 = 589,824 개. 4장의 163,009,536 − 589,824 = 162,419,712. step01 이 출력하는 값이고, 다른 부품은 하나도 안 바뀌었다는 증거입니다.

훈련 내내 "문장 → 토큰 ID 텐서 → 모델 → 토큰 ID 텐서 → 문장" 변환을 반복하므로 함수 두 개를 접점으로 만들어 둡니다. text_to_token_ids 는 2장 BPE 인코딩에 배치 차원을 붙여 (1, 토큰 수) 로 만들고 — 모델은 언제나 (배치, 토큰) 을 받습니다 — token_ids_to_text 는 배치 차원을 떼고 디코딩합니다. 비유하자면 우체국의 봉투 넣기와 봉투 뜯기입니다.

시드 123 으로 만든 훈련 전 모델에 Every effort moves you 를 넣으면 rentingetic wasnم refres RexMeCHicular stren 이 붙습니다 — 원서와 같은 엉터리입니다. 4장 step07 사양이 층 생성 순서를 못 박은 덕에, 여러분의 hermes 가 만든 모델도 같은 엉터리를 냅니다. 이 문자열을 기억해 두세요. 5.4절 끝에서 같은 모델이 전혀 다른 말을 합니다.

5.2 손실 — 정답 토큰의 확률을 얼마나 높였나 (step02)

훈련은 "손실을 줄이는 일"이므로, 손실이 정확히 무엇인지 알아야 훈련 로그의 숫자가 읽힙니다. 4장 모델은 위치마다 어휘 50,257 개에 대한 로짓을 냅니다. 정답(타깃)은 2장 슬라이딩 윈도우가 만든 "한 칸 민 토큰" 입니다. 손실은 정답 토큰에 배정된 확률만 보고 계산합니다. 여섯 단계입니다.

flowchart LR
    L["로짓
(2, 3, 50257)"] --> S["① 소프트맥스
확률 (2, 3, 50257)"] S --> P["② 정답 토큰의 확률만
여섯 개"] P --> G["③ 자연로그
ln p"] G --> M["④ 평균"] M --> N["⑤ 부호 반전
손실 = 10.794"] N --> X["⑥ e^손실
퍼플렉시티 = 48,726"] S -. "argmax 로 예측 토큰을 보면
Armed heNetflix" .-> S
그림 5-2. 손실 계산 여섯 단계. 파이토치의 cross_entropy 는 ①~⑤ 를 한 번에(수치적으로 더 안정한 방법으로) 계산한다. 퍼플렉시티는 손실을 사람이 읽기 좋게 바꾼 것이다.

수식으로는 이렇습니다. N 개 위치의 정답 토큰 t₁…t_N 에 대해

손실(교차 엔트로피) = − (1/N) × Σ ln p(tᵢ)

정답 확률이 1 이면 ln 1 = 0 이라 손실 0, 정답 확률이 작을수록 ln 은 큰 음수라 손실이 커집니다. "정답에 확률을 몰아 주라"는 요구를 숫자 하나로 만든 것입니다.

손계산 ② 여섯 확률에서 10.794 까지

step02 가 뽑은 정답 확률 여섯 개는 7.4541e-05, 3.1061e-05, 1.1563e-05 와 1.0337e-05, 5.6776e-05, 4.7559e-06 입니다. 자연로그를 하나씩 취하면 (ln(a × 10⁻⁵) = ln a − 11.5129 를 쓰면 편합니다)

확률ln
7.4541e-052.0088 − 11.5129 = −9.5041
3.1061e-051.1334 − 11.5129 = −10.3795
1.1563e-050.1452 − 11.5129 = −11.3677
1.0337e-050.0331 − 11.5129 = −11.4798
5.6776e-051.7365 − 11.5129 = −9.7764
4.7559e-061.5593 − 13.8155 = −12.2562

합 = −64.7637, 평균 = −10.7940, 부호를 바꾸면 10.794. step02 의 cross_entropy 값과 소수점 셋째 자리까지 같습니다. 참고로 어휘 50,257 개에 확률을 균등하게 나눠 주는 "아무것도 모르는 모델"의 손실은 ln 50,257 = 10.825 입니다 — 훈련 전 모델은 그것과 다름없습니다.

손계산 ③ 퍼플렉시티 — 몇 개 사이에서 헤매는가

퍼플렉시티 = e^손실

e^10.794 = e^10 × e^0.794 = 22,026.5 × 2.212 ≈ 48,72x (step02 출력 48,726 — 손실의 넷째 자리 아래가 곱셈으로 증폭되어 정수 끝자리가 흔들리므로 계약은 정수까지만 봅니다). 읽는 법: "모델이 다음 토큰을 약 48,726 개 후보 사이에서 헤매고 있다". 어휘 50,257 개의 거의 전부입니다. 훈련이 잘 되면 이 수가 수십 이하로 떨어집니다.

왜 굳이 파이토치 함수를 쓰는가? 손계산은 확률을 먼저 구하고 로그를 취하는데, 확률이 1e-40 처럼 작아지면 float32 에서 0 이 되어 ln 0 = −∞ 가 됩니다. cross_entropy 는 로짓에서 로그-소프트맥스를 직접 계산해 이 문제를 피합니다. 값은 같고 안전만 다릅니다 — 그래서 step03 부터는 이 함수 한 줄을 씁니다.

5.3 훈련·검증 분할 — 외웠는지, 배웠는지 (step03)

시험 공부에 비유하면, 훈련 데이터는 문제집이고 검증 데이터는 풀어 보지 않은 모의고사입니다. 문제집 점수만 보면 답을 외운 학생과 이해한 학생을 구별할 수 없습니다. 그래서 소설의 앞 90% 문자를 훈련에, 뒤 10% 를 검증에 씁니다. 검증 데이터로는 절대 갱신하지 않고 손실만 잽니다.

flowchart TB
    T["the-verdict.txt — 20,479 문자, 5,145 토큰"] --> SP["문자 기준 90% 지점 = 18,431 번째 문자"]
    SP --> TR["훈련 텍스트 (앞 90%)
창 256 · 보폭 256 → 창 18개
배치 2 → 9 배치 = 4,608 토큰 · 섞음"] SP --> VA["검증 텍스트 (뒤 10%)
창 2개 → 1 배치 = 512 토큰 · 안 섞음"] TR --> L1["calc_loss_loader → 10.988"] VA --> L2["calc_loss_loader → 10.981"]
그림 5-3. 훈련·검증 분할. 2장 create_dataloader 가 그대로 쓰인다 — 창 길이 = 보폭 = 256 이라 창이 겹치지 않고, 자투리는 버린다.

손계산 ④ 배치가 9개와 1개인 이유

훈련 텍스트의 토큰 수를 n 이라 하면 2장 데이터셋은 시작 위치 0, 256, 512, … 중 시작 + 256 < n 인 것만 창으로 만듭니다. 훈련 쪽은 창이 18개 → 배치 2 로 묶으면 9 배치, 토큰 18 × 256 = 4,608. 검증 쪽은 약 520 토큰이라 창이 정확히 2개(시작 0, 256) → 1 배치, 512 토큰. 2장 create_dataloader 는 자투리 배치를 버리므로(drop_last) 검증 창이 하나였다면 배치가 0개가 됐을 것입니다 — 분할 비율 0.9 가 우연히 안전한 값입니다.

calc_loss_batch 는 5.2절의 여섯 단계를 cross_entropy 한 줄로 하고, calc_loss_loader 는 로더의 배치 손실을 평균합니다. 훈련 로더는 섞이지만 9개 전부의 평균은 순서와 무관합니다 — 그래서 훈련 전 손실 10.988 / 10.981 이 어떤 구현이든 같은 값입니다(부동소수 덧셈 순서 차이는 넷째 자리 아래). num_batches 인자는 5.4절의 훈련 루프가 "앞 5개 배치만 재는" 빠른 평가에 씁니다.

5.4 훈련 루프 — 손실을 줄이는 90번의 갱신 (step04)

이제 1~4장의 모든 부품이 처음으로 목적대로 움직입니다. 루프 한 바퀴는 네 줄입니다.

flowchart TB
    subgraph EP["에포크 (10번)"]
        direction TB
        B["훈련 로더에서 배치 하나
(입력, 타깃) 각 (2, 256)"] --> Z["기울기 초기화
optimizer.zero_grad()"] Z --> L["배치 손실
calc_loss_batch"] L --> BW["역전파
loss.backward()"] BW --> ST["갱신
optimizer.step() — AdamW"] ST --> C{"스텝이 5의 배수?"} C -- 예 --> EV["evaluate_model
훈련·검증 앞 5배치 손실 기록·출력"] C -- 아니오 --> B EV --> B end EP --> S["에포크 끝: generate_and_print_sample
Every effort moves you + 50 토큰 (탐욕적)"] S --> EP
그림 5-4. 훈련 루프. 9 배치 × 10 에포크 = 90 스텝. 평가와 샘플 생성은 평가 모드(드롭아웃 끔)·기울기 기록 없이 하고 다시 훈련 모드로 돌아온다.

AdamW 는 갱신 규칙입니다. 가장 단순한 규칙(경사 하강)은 "기울기 × 학습률만큼 반대로 움직인다" 인데, AdamW 는 파라미터마다 기울기의 최근 평균(1차 모멘트)과 최근 제곱 평균(2차 모멘트)을 기억해 걸음 크기를 자동으로 조절합니다 — 내리막이 가파른 곳은 조심스럽게, 완만한 곳은 성큼. 학습률 0.0004 는 그 걸음의 기본 크기, 가중치 감쇠 0.1 은 파라미터가 너무 커지지 않게 매 스텝 조금씩 줄이는 브레이크입니다. 모멘트 두 벌이 파라미터 텐서마다 붙으므로 옵티마이저 상태는 모델 크기의 두 배 — 5.6절의 체크포인트가 1.95GB 인 이유입니다.

손계산 ⑤ 스텝 90, 평가 18, 토큰 44,032

9 배치 × 10 에포크 = 90 스텝(0 ~ 89). 5 의 배수인 스텝 0, 5, …, 85 에서 평가 → 18 번. 마지막 평가(스텝 85)까지 모델에 넣은 배치는 86개, 배치마다 2 × 256 = 512 토큰 → 86 × 512 = 44,032. (90 스텝 전부면 46,080 이지만 마지막 네 스텝 뒤에는 평가가 없어 기록에 남지 않습니다.) 이 세 수는 구현과 무관하므로 step04 의 계약입니다. 손실 값 자체는 로더가 섞는 순서와 하드웨어에 따라 셋째 자리가 흔들려 계약에서 뺐습니다.

기준 구현의 실측(CPU, i9-12900K 24스레드, 2분 47초)은 이렇습니다.

xychart-beta
    title "훈련 손실(위 → 아래)과 검증 손실(6 근처에서 멈춤) — 기준 구현 실측"
    x-axis "전역 스텝 (5 마다 평가)" [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85]
    y-axis "손실" 0 --> 11
    line [9.781, 8.111, 6.661, 5.961, 5.726, 5.201, 4.417, 4.069, 3.732, 2.850, 2.427, 2.104, 1.882, 1.320, 0.985, 0.717, 0.541, 0.391]
    line [9.933, 8.339, 7.048, 6.616, 6.600, 6.348, 6.278, 6.226, 6.160, 6.179, 6.141, 6.134, 6.233, 6.238, 6.242, 6.293, 6.393, 6.452]
그림 5-5. 손실 곡선. 훈련 손실은 9.78 → 0.39 로 곤두박질치지만 검증 손실은 스텝 55 의 6.134 를 바닥으로 다시 올라간다 — 원서와 같은 값(첫 평가 9.781 / 9.933, 마지막 0.391 / 6.452).
에포크훈련 / 검증 손실 (마지막 평가)샘플 (Every effort moves you 뒤)
18.111 / 8.339,,,,,,,,,,,,. — 가장 흔한 토큰(쉼표)만
25.961 / 6.616, and, and, and, and, … — 흔한 단어 두 개를 번갈아
35.201 / 6.348, and I had been. — 짧지만 문장
53.732 / 6.160know it was not that the picture--I had the fact by the last I had been--his, …
80.985 / 6.242know," was one of the axioms he had been the tips of a self-confident moustache, …
100.391 / 6.452know," was one of the axioms he laid down across the Sevres and silver of an exquisitely appointed luncheon-table, …

표 5-1. 에포크별 샘플의 변화. 10 에포크의 샘플은 소설 첫 문단 그대로다 — 모델이 4,608 토큰을 외웠다.

두 곡선이 갈라지는 지점이 이 장의 첫 번째 반전입니다. 훈련 손실 0.39 는 "다음 토큰을 거의 확실히 맞힌다"는 뜻이지만, 검증 손실 6.45 는 "안 본 문장에서는 퍼플렉시티 e^6.45 ≈ 630 개 사이에서 헤맨다"는 뜻입니다. 데이터가 소설 한 편(4,608 토큰)뿐이라 모델은 일반적인 영어를 배우는 대신 그 소설을 통째로 외우는 쪽을 택했습니다 — 과대적합(overfitting)입니다. 처방은 데이터를 수만 배 늘리는 것인데 그것은 이 강좌 밖의 일이고, 현실적인 처방은 5.8절입니다.

훈련 시간에 대해: 이 스텝의 검증도 약 3분이 걸리고, hermes 는 응답 전에 자기 코드를 몇 번 실행해 보므로 run_loop.sh 의 기본 상한(15분)을 넘길 수 있습니다. 실습에서는 HERMES_TIMEOUT=1800 으로 실행합니다. 상한에 걸려도 하네스는 그때까지 만들어진 파일로 검증을 진행합니다(4장 실측과 같은 처리).

5.5 디코딩 — 온도와 top-k, 다양성의 두 손잡이 (step05)

표 5-1 의 샘플은 에포크 8 과 10 이 같은 문장으로 시작합니다. 4장 generate_text_simple 이 매 단계 argmax — 확률이 가장 큰 토큰 하나 — 를 고르기 때문입니다. 같은 문맥이면 언제나 같은 토큰이니 문장도 늘 같습니다. 챗봇이 같은 질문에 매번 조금씩 다르게 답하는 것은 확률에 비례해서 뽑기 때문입니다. 이 절은 모델 없이 어휘 9개와 로짓 벡터 하나로 그 원리를 실험합니다.

어휘는 closer, every, effort, forward, inches, moves, pizza, toward, you, 로짓은 [4.51, 0.89, -1.90, 6.75, 1.63, -1.62, -1.89, 6.28, 1.79] 입니다. 소프트맥스를 씌우면 forward 0.572, toward 0.358, closer 0.061 이고 나머지 여섯은 다 합쳐도 1% 가 안 됩니다.

확률적 샘플링(torch.multinomial)은 이 확률대로 주사위를 던집니다. 1,000 번 던지면 forward 가 약 572 번, toward 가 약 358 번 나와야 하고, 실측은 582 / 343 / 73 (closer) / 2 (inches) 였습니다. 주사위라서 시드로 고정해야 재현되고, "한 번에 하나씩 1,000 번" 이라는 난수 소비 규칙까지 사양에 적어야 어떤 구현이든 같은 횟수가 나옵니다(한 번에 1,000 개를 뽑으면 다른 난수가 쓰입니다) — 2판 원칙 3 의 실전 사례입니다.

온도(temperature) T 는 소프트맥스 전에 로짓을 나누는 수입니다.

온도 스케일링 pᵢ = exp(zᵢ / T) ÷ Σⱼ exp(zⱼ / T)

T = 1 이면 보통 소프트맥스, T < 1 이면 큰 로짓이 더 커져 분포가 뾰족해지고(T → 0 은 argmax), T > 1 이면 평평해져 무작위에 가까워집니다.

손계산 ⑥ 온도 1 — 소프트맥스를 손으로

exp 값: e^6.75 = 854.1, e^6.28 = 534.4, e^4.51 = 90.9, 나머지 여섯은 e^0.89 = 2.44, e^1.63 = 5.10, e^1.79 = 5.99, e^−1.62 = 0.20, e^−1.89 = 0.15, e^−1.90 = 0.15. 합 = 1,493.4. forward = 854.1 ÷ 1,493.4 = 0.572, toward = 0.358, closer = 0.061. step05 출력 0.5721, 0.3576, 0.0609 와 같습니다.

손계산 ⑦ 온도 0.1 — 뾰족해진다

로짓을 0.1 로 나누면 10 배: forward 67.5, toward 62.8, closer 45.1. 차이가 4.7 과 22.4 로 벌어지므로 toward ÷ forward = e^−4.7 = 0.0091, closer ÷ forward = e^−22.4 ≈ 2 × 10⁻¹⁰. forward ≈ 1 ÷ (1 + 0.0091) = 0.991, toward 0.009, closer 0.000. 사실상 argmax 입니다.

손계산 ⑧ 온도 5 — 평평해진다

로짓을 5 로 나누면 forward 1.35, toward 1.256, closer 0.902, 나머지는 0.178 / 0.326 / 0.358 / −0.324 / −0.378 / −0.38. exp 는 3.86, 3.51, 2.46, 1.19, 1.39, 1.43, 0.72, 0.69, 0.68 → 합 15.93. forward = 3.86 ÷ 15.93 = 0.242, toward 0.220, closer 0.155. 원래 0.01% 이던 pizza 도 0.72 ÷ 15.93 = 4.5% 가 됩니다 — 온도를 올리면 엉뚱한 단어가 섞입니다.

xychart-beta
    title "온도에 따른 확률 분포 — 앞: T=0.1, 가운데: T=1, 뒤: T=5"
    x-axis ["closer", "every", "effort", "forward", "inches", "moves", "pizza", "toward", "you"]
    y-axis "확률" 0 --> 1
    bar [0.0000, 0.0000, 0.0000, 0.9910, 0.0000, 0.0000, 0.0000, 0.0090, 0.0000]
    bar [0.0609, 0.0016, 0.0001, 0.5721, 0.0034, 0.0001, 0.0001, 0.3576, 0.0040]
    bar [0.1546, 0.0426, 0.0247, 0.2421, 0.0498, 0.0261, 0.0248, 0.2203, 0.0514]
그림 5-6. 같은 로짓, 세 가지 온도. 0.1 은 forward 하나에 몰리고, 5 는 아홉 단어가 고르게 퍼진다. 실습에서 쓰는 1.4 는 그 사이다.

top-k 는 다른 종류의 손잡이입니다. 온도를 올리면 pizza 같은 엉뚱한 후보에도 확률이 생기는데, top-k 는 로짓이 큰 k 개만 남기고 나머지를 음의 무한대로 바꿔 소프트맥스 확률을 정확히 0 으로 만듭니다. 온도로 다양성을 주되 top-k 로 엉뚱함을 막는 조합이 실전의 기본입니다.

손계산 ⑨ top-3 — 남은 셋이 확률을 나눠 갖는다

상위 3개는 forward 6.75, toward 6.28, closer 4.51 (위치 3, 7, 0). 나머지 여섯을 −∞ 로 바꾸면 exp 가 0 이라 분모에서 사라집니다: 854.1 + 534.4 + 90.9 = 1,479.4. forward = 854.1 ÷ 1,479.4 = 0.577, toward 0.361, closer 0.061, 나머지 0.000. 합은 정확히 1. step05 의 0.5775, 0.3610, 0.0615 와 같습니다. 손계산 ⑥ 보다 조금씩 커진 이유는 여섯 단어가 갖던 0.9% 를 셋이 나눠 가졌기 때문입니다.

기준 구현을 만들 때 keep_top_k 에서 실제로 실수가 하나 있었습니다 — 1차원 로짓에서는 잘 되던 코드가 generate 가 넘기는 (배치, 어휘) 2차원 로짓에서 "k 번째 값"의 모양이 안 맞아 죽었습니다. 그래서 사양에 "입력은 1차원일 수도, (배치, 어휘) 2차원일 수도 있다"를 못 박았습니다 — 접점의 입력 조건은 그 접점을 쓰는 쪽(step07)까지 내다보고 적어야 한다는 4장의 교훈 그대로입니다.

5.6 저장과 적재 — 멈춘 자리에서 다시 (step06)

3분짜리 훈련도 매번 다시 할 수는 없고, 6·7장은 훈련한 모델을 이어서 씁니다. 파이토치 모델의 state_dict 는 "파라미터 이름 → 텐서" 딕셔너리이고 torch.save 로 파일에 씁니다. 되살릴 때는 모델을 새로 만든 뒤 load_state_dict 로 값만 채웁니다 — 설계도(4장 클래스)는 코드에, 값은 파일에 있는 셈입니다.

flowchart LR
    subgraph SAVE["step04 끝 — torch.save"]
        M1["model.state_dict()
161 텐서 · 649.7 MB"] --> F["model_and_optimizer.pth
1.95 GB"] O1["optimizer.state_dict()
1차·2차 모멘트 161 × 2"] --> F end subgraph LOAD["step06 — load_checkpoint"] F --> M2["GPTModel(cfg) 새로 만들고
load_state_dict"] F --> O2["AdamW 새로 만들고
load_state_dict"] M2 --> G["생성: step04 마지막 샘플과
글자까지 같음"] O2 --> T["이어 훈련 1 에포크
손실 0.298 → 0.204"] end
그림 5-7. 체크포인트의 왕복. 모델만 저장하면 생성은 되지만, 옵티마이저 상태가 없으면 이어 훈련은 처음처럼 큰 걸음으로 시작해 흔들린다.

손계산 ⑩ 체크포인트가 1.95GB 인 이유

모델 파라미터 162,419,712 개 × 4 바이트 = 649,678,848 바이트 ≈ 650 MB. AdamW 는 텐서마다 1차·2차 모멘트를 같은 크기로 갖고 있으므로 × 2 = 1,300 MB. 합 1,949 MB — 실측 파일 크기 1,952,382,887 바이트(이름·메타데이터 포함)와 맞습니다. 저장소에 커밋하지 않는 이유입니다(.gitignore 의 *.pth).

손계산 ⑪ 옵티마이저 상태 항목 161 개

AdamW 의 상태는 파라미터 텐서마다 하나씩입니다. 텐서 수를 세면: 토큰·위치 임베딩 2 + 블록 12 × (질의·키·값 3 + 출력 투영 가중치·편향 2 + 피드포워드 가중치·편향 4 + 정규화 scale·shift 4 = 13) = 156 + 최종 정규화 2 + 출력층 1 = 161. 4장 사양이 편향 유무를 못 박았으므로 어떤 구현이든 161 이고, 그래서 계약이 됩니다.

되살린 모델의 첫 샘플은 step04 의 마지막 샘플과 글자까지 같았습니다 — 저장·적재가 값을 바꾸지 않는다는 증거입니다. 적재 후 훈련 손실 0.298 은 step04 의 마지막 평가 0.391 보다 낮은데, 0.391 은 스텝 85 에서 앞 5 배치만 잰 값이고 0.298 은 훈련을 끝낸(스텝 89) 모델로 9 배치 전부를 잰 값이라 다릅니다.

5.7 generate — 세 손잡이를 단 생성 함수 (step07)

5.5절의 두 함수를 진짜 모델에 답니다. generate 는 4장 generate_text_simple 과 같은 루프에 분기 세 개를 더한 것입니다.

flowchart TB
    S["idx 의 마지막 context_size 개 → 모델 → 마지막 위치 로짓"] --> K{"top_k 있음?"}
    K -- 예 --> KK["keep_top_k: 상위 k 개 외 −∞"] --> T
    K -- 아니오 --> T{"temperature > 0?"}
    T -- 예 --> TS["softmax_with_temperature
→ multinomial 로 1개"] --> E T -- 아니오 --> AM["argmax (4장과 동일)"] --> E E{"eos_id 와 같음?"} -- 예 --> STOP["붙이지 않고 즉시 종료"] E -- 아니오 --> CAT["idx 끝에 붙이기"] --> S
그림 5-8. generate 한 바퀴. 온도 0 이고 top_k 가 없으면 4장 함수와 토큰까지 같은 경로다 — step07 의 계약 중 하나.

세 가지 실험의 계약은 전부 불변식입니다. ① 온도 1.4·top-k 25 로 15 토큰 → 문장은 구현마다 다르지만 길이는 19. ② 온도 0 의 결과는 4장 함수와 텐서로 같다. ③ 탐욕적으로 첫 번째로 뽑힐 토큰을 eos_id 로 주면 첫 바퀴에서 그 토큰이 뽑히고, 규칙("붙이지 않고 멈춘다")대로 길이 4 에서 멈춥니다. 종료 토큰의 규칙을 사양에 적은 이유는 "붙이고 멈춘다"와 "붙이지 않고 멈춘다"가 둘 다 그럴듯한 구현이라 길이가 5 와 4 로 갈리기 때문입니다 — 동작을 정해야 계약이 됩니다.

기준 구현의 ① 실측은 Every effort moves you stand to work on surprise, a one of us had gone with random- 이었습니다. 원서의 같은 실험은 stand to work on surprised to a One of us had gone with random- — 훈련 결과가 원서와 사실상 같다는 뜻입니다. 외운 소설의 조각들이 온도 때문에 낯선 순서로 이어집니다.

5.8 공개 GPT-2 가중치 — 내려받고(step08) 옮겨 넣기(step09)

이 장의 두 번째 반전입니다. 우리 모델의 한계는 코드가 아니라 데이터(소설 한 편)입니다. GPT-2 는 같은 구조를 웹 텍스트 수백억 토큰으로 훈련했고, 그 결과인 파라미터 1억 2,400만 개를 OpenAI 가 공개했습니다. 4장 사양이 속성 이름을 못 박아 둔 덕에, 그 숫자들을 우리 모델에 그대로 옮겨 넣을 수 있습니다.

원서는 OpenAI 의 텐서플로 체크포인트(파일 7개, 텐서플로 설치 필요)를 쓰지만, 2판은 허깅페이스에 올라온 safetensors 파일 하나(548,105,171 바이트)를 씁니다. safetensors 는 요즘 모델 배포의 표준 포맷으로, 실행 코드가 섞일 수 없는 순수 데이터라 여는 데 경량 라이브러리 하나면 됩니다. step08 은 이 파일과 설정 파일을 내려받되, 이미 있고 크기가 정확하면 건너뜁니다 — 다시 실행해도 안전한(멱등) 스크립트입니다. 크기를 검사하는 이유는 중간에 끊긴 파일로 step09 를 망치지 않기 위해서입니다.

손계산 ⑫ 텐서 160 개, 그중 옮길 것은 148 개

설정 파일의 다섯 값(n_ctx 1024, n_embd 768, n_head 12, n_layer 12, vocab_size 50257)은 4장 GPT_CONFIG_124M 과 정확히 같습니다 — 같은 설계입니다. 텐서 수 160 = 임베딩 2 + 블록 12 × 13 + 최종 정규화 2. 블록의 13 은 정규화 4, 어텐션 투영 가중치·편향 2, 마스크 버퍼 1, 출력 투영 2, 피드포워드 4. 마스크는 우리 모델도 스스로 만드는 버퍼라 옮기지 않으므로 옮길 텐서는 160 − 12 = 148 개입니다.

옮길 때 두 가지 변환이 필요합니다. 첫째, 허깅페이스 GPT-2 의 선형층 가중치는 (입력, 출력) 모양이고 파이토치 nn.Linear 는 (출력, 입력) 이라 전치합니다. 둘째, 질의·키·값 세 투영이 c_attn 하나(768, 2304 = 3 × 768)에 가로로 붙어 있어 마지막 차원에서 셋으로 등분합니다.

GPT-2 (safetensors) 이름모양우리 모델 속성 (4장 접점)변환
wte.weight(50257, 768)tok_emb.weight 그리고 out_head.weight그대로 (가중치 묶기)
wpe.weight(1024, 768)pos_emb.weight그대로
h.{b}.attn.c_attn.weight(768, 2304)trf_blocks[b].att.W_query/W_key/W_value.weight셋으로 등분 후 전치 → 각 (768, 768)
h.{b}.attn.c_attn.bias(2304,)위 세 층의 bias셋으로 등분 — qkv_bias=True 필요
h.{b}.attn.c_proj.weight / .bias(768, 768) / (768,)att.out_proj.weight / .bias가중치만 전치
h.{b}.mlp.c_fc.weight / .bias(768, 3072) / (3072,)ff.layers[0].weight / .bias가중치만 전치
h.{b}.mlp.c_proj.weight / .bias(3072, 768) / (768,)ff.layers[2].weight / .bias가중치만 전치
h.{b}.ln_1.weight / .bias(768,)norm1.scale / norm1.shift그대로
h.{b}.ln_2.weight / .bias(768,)norm2.scale / norm2.shift그대로
ln_f.weight / .bias(768,)final_norm.scale / final_norm.shift그대로

표 5-2. 가중치 대응표. 오른쪽 열의 이름이 전부 4장 사양이 못 박은 접점이다 — 이름 하나라도 달랐다면 이 표는 성립하지 않는다.

flowchart LR
    subgraph HF["model.safetensors (허깅페이스 이름)"]
        W1["wte.weight"]
        W2["h.0.attn.c_attn.weight
(768, 2304)"] W3["h.0.mlp.c_fc.weight
(768, 3072)"] W4["h.0.ln_1.weight"] end subgraph OURS["GPTModel (4장 접점 이름)"] P1["tok_emb.weight
out_head.weight"] P2["trf_blocks[0].att
.W_query / .W_key / .W_value .weight
각 (768, 768)"] P3["trf_blocks[0].ff.layers[0].weight
(3072, 768)"] P4["trf_blocks[0].norm1.scale"] end W1 -- "그대로 (두 곳)" --> P1 W2 -- "3등분 + 전치" --> P2 W3 -- "전치" --> P3 W4 -- "그대로" --> P4
그림 5-9. 대응의 네 가지 유형(블록 0 만 표시). 모양이 다른 텐서를 넣으려 하면 assign 이 오류를 낸다 — 잘못 짝지은 가중치가 조용히 들어가는 사고를 막는 안전장치.

실측 — 3장 사양을 두 번째로 고치다: qkv_bias

표 5-2 의 넷째 줄이 문제였습니다. GPT-2 의 질의·키·값 투영에는 편향이 있는데, 2판 3장 step09 사양은 W_query·W_key·W_value 를 "편향 없음"으로 못 박았습니다. 3장과 4장의 값(파라미터 24 개, 2,360,064 개)이 그 조건 위에 서 있으니 사양을 바꾸면 그 값들이 바뀔까요? 아닙니다 — 기본값이 False 인 키워드 인자로 추가하면 기존 계약은 그대로이고, 5장만 True 로 켭니다. 그래서 이 교안을 만들며 다음 두 사양을 고치고 두 스텝을 hermes 로 다시 생성했습니다.

  • 3장 step09: 클래스 접점을 MultiHeadAttention(d_in, d_out, context_length, dropout, num_heads, qkv_bias=False) 로, 세 투영의 편향은 "qkv_bias 가 True 일 때만" 으로.
  • 4장 step02: build_attention(cfg) 가 "설정에 qkv_bias 키가 있으면 그 값(없으면 False)으로" 넘기도록.

두 재생성 모두 첫 시도에 3분 안에 끝났고, 3장 step09 검증(8/8)과 4장 학생 코드 step02·06·07·08 검증이 전부 그대로 통과했습니다(4장 설정에는 이 키가 없으므로 값이 바뀔 이유가 없습니다). step09 는 dict(GPT_CONFIG_124M, qkv_bias=True) 로 모델을 만듭니다 — 파라미터가 12 블록 × 3 층 × 768 = 27,648 개 늘어 163,037,184 개. 4장에서 한 번, 5장에서 또 한 번: 사양은 완성본이 아니라 다음 장이 요구하는 만큼 자라는 문서입니다. 기록은 logs/runs/20260830-150614-ch03-step09.md 와 logs/runs/20260830-151355-ch04-step02.md 에 있습니다.

이식한 모델의 탐욕적 생성은 Every effort moves you forward. The first step is to understand the importance of your work. The second 입니다. 진짜 GPT-2 의 결정적 출력이라 어떤 구현이든 같습니다 — 원서에는 없는 이 강좌의 계약입니다. 온도 1.5·top-k 50 실측은 toward finding an ideal new way to practice something! What makes us want to be on top of that? 로 원서와 글자까지 같았습니다. 손실을 재 보면 훈련 전·우리 훈련본·GPT-2 의 차이가 숫자로 드러납니다.

모델훈련 손실 (소설 앞 90%)검증 손실 (뒤 10%)읽기
훈련 전 (시드 123)10.98810.981아무것도 모름 — 퍼플렉시티 5만
우리가 훈련한 모델 (10 에포크)0.2986.452훈련 데이터는 외웠고, 안 본 문장은 퍼플렉시티 630
공개 GPT-2 124M3.7553.560어느 쪽도 본 적 없는데 퍼플렉시티 35 — 영어를 안다

표 5-3. 세 모델의 손실. GPT-2 는 이 소설로 훈련한 적이 없는데도 검증 손실이 우리 모델의 절반 근처다 — 데이터의 양이 만드는 차이. 원서 연습문제의 답 3.754 / 3.559 와 0.001 차이는 로더 구현 차이다.

GPT-2 의 훈련 손실(3.755)이 검증 손실(3.560)보다 높다는 점도 눈여겨보세요. 우리 모델은 훈련 쪽을 외워서 0.298 인데, GPT-2 는 두 쪽을 똑같이 처음 보므로 차이가 없습니다 — 검증 쪽 문장이 조금 더 쉬웠을 뿐입니다. 이것이 "배운 모델"의 손실 모양입니다.

실습 — 루프 아홉 바퀴

아홉 스텝은 접점으로 이어져 있습니다. 순서대로 진행하세요. 2·3·4장 실습을 끝낸 상태에서 시작해야 합니다 — step01 이 workspace/ch04 를, step03 이 workspace/ch02 를 import 하고, step09 는 5장 제작 중 보완한 3장 step09(qkv_bias)와 4장 step02 의 재생성본이 필요합니다. 이 교안보다 먼저 3·4장 실습을 마쳤다면 3장 step09 와 4장 step02 두 스텝만 run_loop.sh 로 다시 생성한 뒤 4장 검증을 다시 통과시키고 오세요(사양 파일은 이미 보완되어 있습니다).

flowchart LR
    C4["4장 GPTModel
generate_text_simple"] ==> S1["step01
GPT_CONFIG_256
text↔ids"] C2["2장 load_text
create_dataloader"] ==> S3 S1 --> S2["step02
손실 손계산"] S1 --> S3["step03
make_loaders
calc_loss_*"] S3 --> S4["step04
train_model_simple
CHECKPOINT"] S1 --> S4 S5["step05
softmax_with_temperature
keep_top_k"] --> S7 S4 --> S6["step06
load_checkpoint"] S6 --> S7["step07
generate"] S8["step08
ensure_gpt2_files"] --> S9["step09
load_weights_into_gpt"] S7 --> S9 S3 --> S9 C3["3장 MultiHeadAttention
qkv_bias (재생성)"] ==> S9
그림 5-10. 5장 실습의 접점 흐름. step01 과 step03 을 거의 전부가 쓰고, step04 의 체크포인트를 step06·07 이, step08 의 파일을 step09 가 쓴다. 굵은 화살표가 챕터 경계를 넘는 import 다.

각 스텝은 bash harness/run_loop.sh ch05 prompts/ch05/stepNN-*.md → bash harness/verify.sh ch05 stepNN → bash harness/verify.sh ch05 stepNN --solution 의 세 명령입니다(step04 만 HERMES_TIMEOUT=1800 을 앞에 붙입니다). 이번 장 사양의 특징은 계약의 절반이 불변식이라는 점입니다. 훈련이 개입하는 스텝(04·06·07)은 손실 값과 생성 문장이 구현마다 다르므로, 평가 횟수·토큰 수·텐서 수·"온도 0 = 4장 결과"· "종료 토큰에서 길이 4" 같은 어떤 올바른 구현이든 같은 값만 검증합니다. 반면 진짜 GPT-2 의 탐욕적 생성(step09)은 결정적이라 문장 전체가 계약입니다.

step01. 훈련 준비 — 문맥 256 설정과 텍스트↔토큰 ID 변환

무엇을 — 4장 설정을 복사해 문맥 길이만 256 으로 줄인 GPT_CONFIG_256, 문자열과 (1, 토큰 수) ID 텐서를 오가는 text_to_token_ids·token_ids_to_text, 그리고 훈련 전 모델의 생성 결과 확인
왜 — 5장 내내 "문장 → 토큰 ID → 모델 → 토큰 ID → 문장"을 반복한다. 변환 함수 두 개가 손발이 되고, 문맥 256 은 CPU 로 몇 분 안에 훈련을 끝내기 위한 타협이다
맥락 — 4장 step07·step08 의 GPTModel·generate_text_simple 을 챕터 경계 너머(../ch04)에서 import 한다. 4장 step02 가 3장을 가져온 것과 같은 방식이다
기대 결과 — 파라미터 162,419,712 개(4장보다 위치 임베딩 768 행만큼 적다), 생성 결과 Every effort moves you rentingetic wasnم refres RexMeCHicular stren — 원서와 같은 엉터리
hermes에게 전달되는 사양 (prompts/ch05/step01-text-utils.md)
step01_text_utils.py 라는 파이썬 파일을 만들어줘.

만들 것: 5장 훈련용 설정과 텍스트↔토큰 ID 변환 함수, 그리고 훈련 전 모델이 내는 생성 결과를 확인하는 스크립트.

접점 (4장 산출물 재사용 + 이후 스텝이 import 한다):
- 4장 작업 디렉토리는 이 파일이 있는 디렉토리의 옆 디렉토리 ../ch04 다. 그 디렉토리를 import 검색 경로에 추가한 뒤, 거기 있는
  step01_gpt_config.py 의 GPT_CONFIG_124M, step07_gpt_model.py 의 GPTModel, step08_generate.py 의 generate_text_simple 을 import 한다
  (4장 파일들이 ../ch03 을 스스로 경로에 추가하므로 4장 경로만 추가하면 된다). 모델과 생성 함수를 새로 구현하지 않는다.
- 딕셔너리 GPT_CONFIG_256 — GPT_CONFIG_124M 의 복사본에서 context_length 만 256 으로 바꾼 것. 원본 딕셔너리는 바꾸지 않는다.
- 함수 text_to_token_ids(text, tokenizer) — 문자열을 tiktoken 토크나이저로 인코딩해(문서 경계 특수 토큰 <| + endoftext + |> 는 허용)
  배치 차원을 붙인 (1, 토큰 수) 정수 텐서를 반환한다.
- 함수 token_ids_to_text(token_ids, tokenizer) — (1, 토큰 수) 정수 텐서의 배치 차원을 떼고 디코딩한 문자열을 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: tiktoken 의 gpt2 토크나이저를 만든다. 파이토치 시드 123 고정 직후 GPTModel(GPT_CONFIG_256) 을 만들고 평가 모드로 전환한다.
Every effort moves you 를 토큰 ID 텐서로 바꿔 새 토큰 10개를 생성한다(context_size 는 설정의 context_length). 결과 전체를 문자열로 되돌린다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 문맥 길이: 256 처럼 설정의 문맥 길이
- 총 파라미터 개수: 162,419,712 처럼 모델의 모든 파라미터 원소 수의 합을 천 단위 콤마로
- 입력 토큰 ID: tensor([[6109, 3626, 6100,  345]]) 처럼 입력 텐서 (print("라벨:", 텐서) 꼴)
- 출력 토큰 수: 14 처럼 생성 결과의 토큰 수
- 생성 결과: Every effort moves you rentingetic wasnم refres RexMeCHicular stren 처럼 생성 결과를 디코딩한 문자열

지켜야 할 것:
- torch, tiktoken 과 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 4장·3장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
문맥 길이: 256
총 파라미터 개수: 162,419,712
입력 토큰 ID: tensor([[6109, 3626, 6100,  345]])
출력 토큰 수: 14
생성 결과: Every effort moves you rentingetic wasnم refres RexMeCHicular stren
[OK] 5줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch05/step01_text_utils.py, 핵심):

CH04_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "ch04")
sys.path.append(CH04_DIR)
from step01_gpt_config import GPT_CONFIG_124M
from step07_gpt_model import GPTModel
from step08_generate import generate_text_simple

GPT_CONFIG_256 = dict(GPT_CONFIG_124M, context_length=256)   # 복사본 — 원본은 그대로

def text_to_token_ids(text, tokenizer):
    ids = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
    return torch.tensor(ids).unsqueeze(0)          # (토큰 수,) → (1, 토큰 수)

def token_ids_to_text(token_ids, tokenizer):
    return tokenizer.decode(token_ids.squeeze(0).tolist())   # (1, 토큰 수) → (토큰 수,) → 문자열

4장 step02 와 같은 다섯 줄로 ../ch04 를 경로에 붙입니다. 4장 파일들이 ../ch03 을 스스로 붙이므로 3장까지 연쇄로 보입니다. dict(원본, 키=값) 은 원본을 두고 한 키만 바꾼 복사본을 만드는 관용구입니다 — GPT_CONFIG_124M["context_length"] = 256 으로 원본을 고치면 step09 가 문맥 1024 로 모델을 만들 때 틀어집니다. 여러분의 코드는 이것과 달라도 됩니다 — {**원본, "context_length": 256} 도 같은 뜻입니다.

실측

hermes(qwen3.8:27b)는 바로 그 {**GPT_CONFIG_124M, "context_length": 256} 을 썼고, 경로는 pathlib 로 만들어 sys.path.insert(0, …) 했으며, 배치 차원은 torch.tensor([token_ids]) 로 붙였습니다 — 리스트를 한 겹 더 싸는 것도 (1, 토큰 수) 입니다. 변수명 하나가 한글(총_개수). 1분 30초, 첫 시도 통과. 다른 장 파일은 건드리지 않았습니다.

step02. 손실을 손으로 — 정답 토큰의 확률에서 교차 엔트로피까지

무엇을 — 훈련 전 모델이 정답 토큰에 배정한 확률을 뽑아 로그·평균·부호 반전으로 교차 엔트로피 손실을 손으로 계산하고, 파이토치 함수 값·퍼플렉시티와 맞춰 보는 스크립트
왜 — 훈련은 "손실을 줄이는 일"이다. 손실이 정확히 무엇인지 여섯 개 확률로 직접 계산해 봐야 훈련 로그의 숫자가 읽힌다
맥락 — step01 의 설정·변환 함수와 4장 GPTModel 을 쓴다. step03 이 이 계산을 cross_entropy 한 줄로 배치·로더 단위 함수로 만든다
기대 결과 — 여섯 확률이 모두 1/10,000 근처, 손실 10.794, 퍼플렉시티 48,726 — "5만 개 중 아무거나"와 다름없는 상태
hermes에게 전달되는 사양 (prompts/ch05/step02-loss-by-hand.md)
step02_loss_by_hand.py 라는 파이썬 파일을 만들어줘.

만들 것: 훈련 전 모델이 정답 토큰에 배정한 확률에서 교차 엔트로피 손실과 퍼플렉시티를 손으로 계산하고, 파이토치의 교차 엔트로피 함수 값과 맞춰 보는 스크립트.

접점 (직전 스텝 재사용):
- 같은 디렉토리의 step01_text_utils.py 에서 GPT_CONFIG_256 과 token_ids_to_text 를 import 한다. GPTModel 은 그 파일이 ../ch04 를
  경로에 추가해 두었으므로 step07_gpt_model 에서 import 할 수 있다 (직접 ../ch04 를 경로에 추가해도 된다). 모델을 새로 구현하지 않는다.
- 이 스텝의 함수를 다음 스텝이 import 하지는 않는다.

동작: 입력 배치 [[16833, 3626, 6100], [40, 1107, 588]] ("every effort moves" 와 "I really like") 과 타깃 배치 [[3626, 6100, 345], [1107, 588, 11311]]
(각각 한 칸 민 것) 을 정수 텐서로 만든다. 파이토치 시드 123 고정 직후 GPTModel(GPT_CONFIG_256) 을 만들어 평가 모드로 전환하고,
기울기 기록 없이 입력 배치의 로짓을 얻는다. 그 다음:
① 로짓의 마지막 차원에 소프트맥스를 적용해 확률로 바꾼다.
② 위치마다 확률이 가장 큰 토큰 ID 를 고르고, 첫 배치의 타깃 토큰 열과 첫 배치의 예측 토큰 열을 각각 문자열로 디코딩한다.
③ 배치 0 의 세 위치에서 "그 위치의 타깃 토큰이 받은 확률" 세 개를 1차원 텐서로 뽑고, 배치 1 도 같은 방식으로 뽑는다.
④ 여섯 확률의 자연로그를 평균하고 부호를 바꾼다 — 손으로 계산한 교차 엔트로피 손실.
⑤ 로짓을 (배치×토큰, 어휘) 로, 타깃을 (배치×토큰) 으로 펼쳐 torch.nn.functional.cross_entropy 로 손실을 구한다.
⑥ 그 손실의 지수(e 의 거듭제곱)를 퍼플렉시티로 구한다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다. 텐서는 print("라벨:", 텐서) 꼴로, f-문자열 보간은 쓰지 않는다):
- 확률 모양: torch.Size([2, 3, 50257]) 처럼 ① 의 모양
- 타깃 배치 1: effort moves you 처럼 첫 배치 타깃의 디코딩 결과 (앞뒤 공백을 뗀다)
- 출력 배치 1: Armed heNetflix 처럼 첫 배치 예측의 디코딩 결과 (앞뒤 공백을 뗀다)
- 텍스트 1 타깃 확률: tensor([7.4541e-05, 3.1061e-05, 1.1563e-05]) 처럼 ③ 의 첫 텐서
- 텍스트 2 타깃 확률: tensor([1.0337e-05, 5.6776e-05, 4.7559e-06]) 처럼 ③ 의 둘째 텐서
- 평균 로그 확률: -10.794 처럼 ④ 의 평균을 소수점 셋째 자리까지
- 음의 평균 로그 확률: 10.794 처럼 부호를 바꾼 값을 소수점 셋째 자리까지
- 교차 엔트로피 손실: 10.794 처럼 ⑤ 를 소수점 셋째 자리까지
- 퍼플렉시티: 48726 처럼 ⑥ 을 정수로 반올림

지켜야 할 것:
- torch, tiktoken 과 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
확률 모양: torch.Size([2, 3, 50257])
타깃 배치 1: effort moves you
출력 배치 1: Armed heNetflix
텍스트 1 타깃 확률: tensor([7.4541e-05, 3.1061e-05, 1.1563e-05])
텍스트 2 타깃 확률: tensor([1.0337e-05, 5.6776e-05, 4.7559e-06])
평균 로그 확률: -10.794
음의 평균 로그 확률: 10.794
교차 엔트로피 손실: 10.794
퍼플렉시티: 48726
[OK] 9줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch05/step02_loss_by_hand.py, 핵심):

probas = torch.softmax(logits, dim=-1)                      # ① (2, 3, 50257)
predicted = torch.argmax(probas, dim=-1)                    # ② 위치마다 가장 확률 높은 토큰
positions = torch.arange(targets.shape[1])                  # [0, 1, 2]
target_probas_1 = probas[0, positions, targets[0]]          # ③ 배치 0: 위치 i 에서 targets[0][i] 번 토큰의 확률
target_probas_2 = probas[1, positions, targets[1]]
log_probas = torch.log(torch.cat((target_probas_1, target_probas_2)))
avg_log = torch.mean(log_probas)                            # ④ 평균 → 부호 반전이 손실
loss = torch.nn.functional.cross_entropy(logits.flatten(0, 1), targets.flatten())   # ⑤ (6, 50257) vs (6,)
print("퍼플렉시티:", f"{torch.exp(loss).item():.0f}")       # ⑥

③ 의 probas[0, positions, targets[0]] 은 고급 인덱싱입니다 — 세 위치 [0, 1, 2] 와 세 타깃 ID 를 짝지어 확률 세 개를 한 번에 꺼냅니다. ⑤ 의 flatten(0, 1) 은 (2, 3, 50257) 을 (6, 50257) 로, flatten() 은 (2, 3) 을 (6,) 으로 펼칩니다 — cross_entropy 는 "예측 행 하나에 정답 하나" 꼴을 받습니다. 여러분의 코드는 이것과 달라도 됩니다 — torch.gather 나 파이썬 루프로 확률을 꺼내도 같은 값입니다.

실측

hermes(qwen3.8:27b)는 ③ 을 기준 구현과 같은 고급 인덱싱 probs[0, [0, 1, 2], target_batch[0]] 으로, ⑤ 를 reshape(배치_크기 * 토큰_수, -1) 로 썼습니다 — flatten(0, 1) 과 같은 모양입니다. 눈여겨볼 점 하나: 소수점 셋째 자리를 f-문자열 대신 round(float(x), 3) 으로 만들었습니다. 이번 값(10.794)에서는 같지만, 값이 10.790 이었다면 10.79 로 찍혀 검증에 실패했을 방식입니다 — 출력 형식은 :.3f 가 안전합니다. 다음 스텝이 import 하지 않는 스텝이라 __main__ 가드 없이 모듈 최상위에 썼고, 응답 전에 임시 검증 스크립트로 아홉 줄을 스스로 대조했습니다. 4분에 파일, 6분에 종료, 첫 시도 통과.

step03. 훈련·검증 분할과 손실 함수 — 2장 데이터 로더를 훈련에 연결한다

무엇을 — 소설을 앞 90%/뒤 10% 문자로 나눠 2장 create_dataloader 로 훈련·검증 로더를 만드는 make_loaders, 배치 하나와 로더 전체의 손실을 재는 calc_loss_batch·calc_loss_loader
왜 — 훈련 루프는 "배치 꺼내기 → 손실 → 갱신"의 반복이다. 이 스텝이 그 앞 두 조각을 함수로 만든다. 검증 데이터를 따로 떼는 이유는 "외웠는지, 배웠는지"를 구별하기 위해서다
맥락 — 2장 step06 의 데이터 로더가 처음으로 본래 목적(훈련)에 쓰인다. step02 의 손계산이 cross_entropy 한 줄이 된다. step04 훈련 루프가 세 함수를 import 한다
기대 결과 — 훈련 배치 9개·검증 배치 1개(각 2×256), 훈련 전 손실 10.988 / 10.981 — step02 의 10.794 와 같은 수준의 "아무것도 모름"
hermes에게 전달되는 사양 (prompts/ch05/step03-data-split.md)
step03_data_split.py 라는 파이썬 파일을 만들어줘.

만들 것: 소설을 훈련용·검증용으로 나눠 2장 데이터 로더로 만드는 함수와, 배치 하나·로더 전체의 교차 엔트로피 손실을 재는 함수, 그리고 훈련 전 모델의 손실 확인.

접점 (2장·4장 산출물 재사용 + step04 가 import 한다):
- 같은 디렉토리의 step01_text_utils.py 에서 GPT_CONFIG_256 을 import 한다. GPTModel 은 그 파일이 ../ch04 를 경로에 추가해 두었으므로
  step07_gpt_model 에서 import 할 수 있다.
- 2장 작업 디렉토리 ../ch02 를 import 검색 경로에 추가하고, 거기 있는 step01_tokenize.py 의 load_text() (소설 전체 문자열) 와
  step06_dataloader.py 의 create_dataloader(text, batch_size, max_length, stride, shuffle) 를 import 한다. 데이터셋·로더를 새로 구현하지 않는다.
- 함수 make_loaders(text, cfg, batch_size=2) — 문자열의 앞 90% 문자를 훈련, 나머지를 검증으로 나누고(경계 = 전체 문자 수 × 0.9 의 정수 부분),
  create_dataloader 로 두 로더를 만들어 (훈련 로더, 검증 로더) 순서쌍을 반환한다. 창 길이와 보폭은 둘 다 cfg 의 context_length(겹치지 않는 창),
  훈련 로더는 섞고 검증 로더는 섞지 않는다.
- 함수 calc_loss_batch(input_batch, target_batch, model, device) — 두 배치를 device 로 옮기고, 모델 로짓을 (배치×토큰, 어휘) 로, 타깃을 (배치×토큰) 으로
  펼쳐 교차 엔트로피 손실(0차원 텐서)을 반환한다.
- 함수 calc_loss_loader(loader, model, device, num_batches=None) — 로더의 앞 num_batches 개 배치(None 이면 전부, 로더 길이보다 크면 로더 길이만큼)의
  배치 손실을 평균한 파이썬 float 을 반환한다. 로더에 배치가 하나도 없으면 nan 을 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: load_text() 로 소설을 읽어 make_loaders(텍스트, GPT_CONFIG_256) 으로 두 로더를 만든다. 파이토치 시드 123 고정 직후 GPTModel(GPT_CONFIG_256) 을
만들어 평가 모드로 전환하고, 기울기 기록 없이 두 로더 전체의 손실을 잰다. 장치는 CPU.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 문자 수: 20479 처럼 소설의 문자 수
- 훈련 배치 수: 9 처럼 훈련 로더의 배치 수
- 검증 배치 수: 1 처럼 검증 로더의 배치 수
- 배치 모양: torch.Size([2, 256]) torch.Size([2, 256]) 처럼 훈련 로더 첫 배치의 입력 모양과 타깃 모양 (print("라벨:", 입력.shape, 타깃.shape) 꼴)
- 훈련 토큰 수: 4608 처럼 훈련 로더의 모든 입력 배치 원소 수의 합
- 검증 토큰 수: 512 처럼 검증 로더의 모든 입력 배치 원소 수의 합
- 훈련 손실: 10.988 처럼 훈련 로더 손실을 소수점 셋째 자리까지
- 검증 손실: 10.981 처럼 검증 로더 손실을 소수점 셋째 자리까지

지켜야 할 것:
- torch, tiktoken 과 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
문자 수: 20479
훈련 배치 수: 9
검증 배치 수: 1
배치 모양: torch.Size([2, 256]) torch.Size([2, 256])
훈련 토큰 수: 4608
검증 토큰 수: 512
훈련 손실: 10.988
검증 손실: 10.981
[OK] 8줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch05/step03_data_split.py, 핵심):

def make_loaders(text, cfg, batch_size=2):
    split = int(TRAIN_RATIO * len(text))                     # 문자 기준 90% 지점
    length = cfg["context_length"]
    train_loader = create_dataloader(text[:split], batch_size, length, length, shuffle=True)
    val_loader = create_dataloader(text[split:], batch_size, length, length, shuffle=False)
    return train_loader, val_loader

def calc_loss_batch(input_batch, target_batch, model, device):
    input_batch, target_batch = input_batch.to(device), target_batch.to(device)
    logits = model(input_batch)
    return torch.nn.functional.cross_entropy(logits.flatten(0, 1), target_batch.flatten())

def calc_loss_loader(loader, model, device, num_batches=None):
    if len(loader) == 0:
        return float("nan")
    num_batches = len(loader) if num_batches is None else min(num_batches, len(loader))
    total = 0.0
    for i, (input_batch, target_batch) in enumerate(loader):
        if i >= num_batches:
            break
        total += calc_loss_batch(input_batch, target_batch, model, device).item()
    return total / num_batches

make_loaders 는 2장 함수를 두 번 부를 뿐입니다 — 창 길이와 보폭에 같은 값을 넣어 창이 겹치지 않게 합니다. calc_loss_batch 는 step02 의 여섯 단계를 한 줄로 줄인 것이고, calc_loss_loader 의 .item() 은 0차원 텐서를 파이썬 float 으로 바꿔 계산 그래프를 끊습니다 — 안 끊으면 배치 손실 아홉 개가 메모리에 다 남습니다. num_batches 를 로더 길이로 자르는 이유는 step04 가 검증 로더(배치 1개)에 eval_iter=5 를 넘기기 때문입니다.

실측

hermes(qwen3.8:27b)는 세 접점을 사양대로 만들었고, calc_loss_loader 는 배치 손실을 목록에 모아 sum ÷ len 으로 평균했습니다(기준 구현은 누적합 ÷ num_batches — 같은 값). 특이점은 시간입니다 — 파일이 나오기까지 13분으로 이 장에서 가장 오래 걸렸습니다. 응답 기록을 보면 2장 파일 두 개(step06_dataloader.py, step01_tokenize.py)의 접점을 읽고 확인하는 탐색이 길었습니다. 사양이 함수 이름과 인자를 적어 주었는데도 "정말 그런지" 확인한 것인데, 잘못된 습관은 아닙니다 — 그 확인 덕에 첫 시도에 통과했습니다. 변수명은 대부분 한글(경계, 훈련_로더, 손실_목록). 다른 장 파일은 건드리지 않았습니다.

step04. 훈련 루프 — 손실을 줄이는 90번의 갱신

무엇을 — "배치 손실 → 역전파 → AdamW 갱신"을 10 에포크 반복하며 5 스텝마다 훈련·검증 손실을 재고 에포크마다 샘플 문장을 찍는 train_model_simple, 그 보조 함수 evaluate_model·generate_and_print_sample, 끝나면 모델·옵티마이저 상태를 저장
왜 — 1~4장이 만든 모든 부품이 이 루프 하나에서 처음으로 목적대로 움직인다. 훈련이란 결국 이 루프다
맥락 — step03 의 로더·손실 함수, step01 의 변환 함수, 4장 GPTModel·generate_text_simple 을 전부 import 한다. step06·07 이 여기서 저장한 체크포인트를 되살린다
기대 결과 — 훈련 손실 9.78 → 0.39 로 곤두박질, 검증 손실은 9.93 → 6.45 에서 멈춤(외우기 시작), 샘플이 쉼표 나열에서 소설 문장으로 변함. CPU 로 약 3분

실행은 HERMES_TIMEOUT=1800 bash harness/run_loop.sh ch05 prompts/ch05/step04-train.md

hermes에게 전달되는 사양 (prompts/ch05/step04-train.md)
step04_train.py 라는 파이썬 파일을 만들어줘.

만들 것: GPT 모델을 소설로 사전훈련하는 훈련 루프와 보조 함수 두 개, 그리고 훈련을 실행하고 체크포인트를 저장하는 스크립트.

접점 (직전 스텝들 재사용 + step06·step07 이 import 한다):
- 같은 디렉토리의 step01_text_utils.py 에서 GPT_CONFIG_256, text_to_token_ids, token_ids_to_text 를, step03_data_split.py 에서
  make_loaders, calc_loss_batch, calc_loss_loader 를 import 한다. 앞 스텝들이 ../ch02 와 ../ch04 를 경로에 추가해 두었으므로
  step01_tokenize 의 load_text, step07_gpt_model 의 GPTModel, step08_generate 의 generate_text_simple 을 import 할 수 있다.
  로더·손실·모델·생성을 새로 구현하지 않는다.
- 상수 CHECKPOINT = "model_and_optimizer.pth" — 체크포인트 파일 이름.
- 함수 evaluate_model(model, train_loader, val_loader, device, eval_iter) — 모델을 평가 모드로 바꾸고 기울기 기록 없이 훈련 로더·검증 로더 각각의
  앞 eval_iter 개 배치 손실 평균을 잰 뒤, 훈련 모드로 되돌리고 (훈련 손실, 검증 손실) 을 반환한다.
- 함수 generate_and_print_sample(model, tokenizer, device, start_context) — 평가 모드에서 start_context 뒤에 새 토큰 50개를 탐욕적으로
  이어 붙여(context_size 는 모델 위치 임베딩의 행 수) 디코딩하고, 줄바꿈을 공백으로 바꿔 샘플:  뒤에 한 줄로 출력한 뒤 훈련 모드로 되돌린다.
- 함수 train_model_simple(model, train_loader, val_loader, optimizer, device, num_epochs, eval_freq, eval_iter, start_context, tokenizer) —
  에포크마다 훈련 로더의 배치를 차례로 꺼내 "기울기 초기화 → 배치 손실 → 역전파 → 옵티마이저 갱신"을 하고, 지금까지 모델에 넣은 입력 토큰 수
  (각 배치 입력 텐서의 원소 수 누적)를 센다. 전역 스텝 번호는 첫 갱신을 0 으로 해서 1 씩 늘리며, 갱신 직후 스텝 번호가 eval_freq 의 배수이면
  evaluate_model 로 평가해 훈련 손실·검증 손실·그때까지 본 토큰 수를 각각 목록에 기록하고 아래 형식의 한 줄을 출력한다. 에포크가 끝날 때마다
  generate_and_print_sample 을 호출한다. 반환은 (훈련 손실 목록, 검증 손실 목록, 본 토큰 수 목록) — 세 목록의 길이는 평가 횟수와 같다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 장치는 CPU. tiktoken 의 gpt2 토크나이저를 만들고 load_text() 로 소설을 읽는다. 파이토치 시드 123 고정 직후 GPTModel(GPT_CONFIG_256) 을 만들고,
AdamW 옵티마이저(학습률 0.0004, 가중치 감쇠 0.1)와 make_loaders(텍스트, GPT_CONFIG_256) 로 두 로더를 만든다. 10 에포크, eval_freq 5, eval_iter 5,
시작 문맥 Every effort moves you 로 훈련한다. 끝나면 모델의 state_dict 를 키 model_state_dict 로, 옵티마이저의 state_dict 를 키
optimizer_state_dict 로 담은 딕셔너리 하나를 CHECKPOINT 파일에 torch.save 로 저장한다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 평가할 때마다 에포크 1 (스텝 000000): 훈련 손실 9.781, 검증 손실 9.933 처럼 — 에포크는 1 부터, 스텝은 여섯 자리 0 채움, 손실은 소수점 셋째 자리까지
- 에포크가 끝날 때마다 샘플: Every effort moves you ... 처럼 생성 문장 한 줄 (줄바꿈은 공백으로)
- 훈련이 끝나면 평가 횟수: 18 처럼 훈련 손실 목록의 길이
- 마지막 평가까지 본 토큰 수: 44032 처럼 본 토큰 수 목록의 마지막 값
- 첫 훈련 손실: 9.781 처럼 훈련 손실 목록의 첫 값을 소수점 셋째 자리까지
- 마지막 훈련 손실: 0.391 처럼 마지막 값을 소수점 셋째 자리까지
- 마지막 훈련 손실이 첫 훈련 손실의 절반 미만: 예 처럼 참이면 예, 아니면 아니오
- 마지막 검증 손실이 첫 검증 손실보다 작다: 예 처럼 참이면 예, 아니면 아니오
- 저장: model_and_optimizer.pth 처럼 저장한 파일 이름

지켜야 할 것:
- torch, tiktoken 과 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 그래프 저장, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 기준 구현 — 중간 줄은 생략) ---
에포크 1 (스텝 000000): 훈련 손실 9.781, 검증 손실 9.933
에포크 1 (스텝 000005): 훈련 손실 8.111, 검증 손실 8.339
샘플: Every effort moves you,,,,,,,,,,,,.
에포크 2 (스텝 000010): 훈련 손실 6.661, 검증 손실 7.048
...
에포크 10 (스텝 000085): 훈련 손실 0.391, 검증 손실 6.452
샘플: Every effort moves you know," was one of the axioms he laid down across the Sevres and silver of an exquisitely appointed luncheon-table, when, on a later day, I had again run over from Monte Carlo; and Mrs. Gis
평가 횟수: 18
마지막 평가까지 본 토큰 수: 44032
첫 훈련 손실: 9.781
마지막 훈련 손실: 0.391
마지막 훈련 손실이 첫 훈련 손실의 절반 미만: 예
마지막 검증 손실이 첫 검증 손실보다 작다: 예
저장: model_and_optimizer.pth
[OK] 8줄 전부 일치 (손실 값은 대조하지 않음)   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch05/step04_train.py, 핵심):

def train_model_simple(model, train_loader, val_loader, optimizer, device,
                       num_epochs, eval_freq, eval_iter, start_context, tokenizer):
    train_losses, val_losses, tokens_seen_log = [], [], []
    tokens_seen, step = 0, -1
    for epoch in range(num_epochs):
        model.train()
        for input_batch, target_batch in train_loader:
            optimizer.zero_grad()                                          # 지난 배치의 기울기를 지운다
            loss = calc_loss_batch(input_batch, target_batch, model, device)
            loss.backward()                                                # 역전파 — 기울기 계산
            optimizer.step()                                               # 가중치 갱신
            tokens_seen += input_batch.numel()
            step += 1
            if step % eval_freq == 0:
                train_loss, val_loss = evaluate_model(model, train_loader, val_loader, device, eval_iter)
                train_losses.append(train_loss); val_losses.append(val_loss); tokens_seen_log.append(tokens_seen)
                print(f"에포크 {epoch + 1} (스텝 {step:06d}): 훈련 손실 {train_loss:.3f}, 검증 손실 {val_loss:.3f}")
        generate_and_print_sample(model, tokenizer, device, start_context)
    return train_losses, val_losses, tokens_seen_log

안쪽 네 줄이 훈련의 전부입니다. zero_grad 를 빼먹으면 기울기가 배치마다 누적되어 걸음이 점점 커지고, backward 를 빼면 기울기가 없어 step 이 아무것도 안 합니다. evaluate_model 은 model.eval() → 손실 → model.train() 의 샌드위치입니다 — 드롭아웃을 끈 채 재고 다시 켭니다. 샘플 생성도 같은 샌드위치입니다. step 을 −1 에서 시작해 갱신 직후 1 을 더하는 것은 "첫 갱신이 스텝 0" 이라는 사양을 맞추기 위해서입니다 — 여러분의 코드는 0 에서 시작해 갱신 뒤에 더해도 됩니다.

실측

hermes(qwen3.8:27b)는 세 함수를 사양대로 만들었고 — 문맥 길이를 model.pos_emb.num_embeddings 로 읽은 것만 기준 구현(weight.shape[0])과 다릅니다 — 시드 위치와 생성 순서를 지킨 결과 손실 18개와 샘플 10개가 기준 구현과 글자까지 같았습니다(9.781/9.933 → 0.391/6.452). 손실 값을 계약에서 뺐는데도 같은 값이 나온 것은, 사양이 난수가 개입하는 지점(시드 직후 모델 → 옵티마이저 → 로더, 평가는 갱신 직후)을 빠짐없이 정해 두었기 때문입니다. hermes 는 파일을 3분에 만든 뒤 자체 점검으로 훈련을 두 번 돌려 11분 만에 응답했고(상한 30분), 검증은 2분 44초. 첫 시도 통과. 다른 장 파일은 건드리지 않았습니다.

step05. 디코딩 실험실 — 확률적 샘플링·온도·top-k 를 장난감 어휘로

무엇을 — 어휘 9개와 로짓 벡터 하나로 탐욕적 선택 vs 확률적 샘플링, 온도 스케일링, top-k 자르기를 실험하는 스크립트와 두 함수 softmax_with_temperature·keep_top_k
왜 — 훈련한 모델이 매번 같은 문장만 내는 이유는 argmax 때문이다. 다양성을 조절하는 두 손잡이(온도·top-k)를 모델 없이 숫자 9개로 먼저 만져 본다
맥락 — step04 의 샘플이 늘 같은 문장이었던 것에 대한 답. step07 의 generate 가 이 두 함수를 import 해 진짜 모델에 적용한다
기대 결과 — 1,000번 뽑으면 forward 582·toward 343·closer 73(원서와 같은 값), 온도 0.1 이면 forward 0.991, 온도 5 면 0.242 — 뾰족해지고 평평해진다
hermes에게 전달되는 사양 (prompts/ch05/step05-decoding-lab.md)
step05_decoding_lab.py 라는 파이썬 파일을 만들어줘.

만들 것: 장난감 어휘 9개와 로짓 벡터 하나로 확률적 샘플링·온도 스케일링·top-k 를 실험하는 스크립트와, 다음 스텝이 쓸 함수 두 개.

접점 (step07 이 import 한다):
- 함수 softmax_with_temperature(logits, temperature) — 로짓을 temperature 로 나눈 뒤 마지막 차원에 소프트맥스를 적용한 확률 텐서를 반환한다.
- 함수 keep_top_k(logits, k) — 마지막 차원에서 값이 큰 k 개는 그대로 두고 나머지를 음의 무한대로 바꾼, 모양이 같은 로짓 텐서를 반환한다
  (k 번째로 큰 값과 같은 값은 남긴다). 입력은 1차원일 수도, (배치, 어휘) 2차원일 수도 있다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 어휘는 closer, every, effort, forward, inches, moves, pizza, toward, you (이 순서로 ID 0~8), 다음 토큰 로짓은
[4.51, 0.89, -1.90, 6.75, 1.63, -1.62, -1.89, 6.28, 1.79] (실수 텐서).
① 로짓의 소프트맥스 확률에서 가장 큰 토큰의 단어.
② 파이토치 시드 123 고정 직후, 그 확률에서 torch.multinomial 로 토큰 하나를 뽑은 단어.
③ 파이토치 시드 123 을 다시 고정한 직후, 같은 확률에서 토큰을 **한 번에 하나씩** 1000번 뽑아(한 개를 뽑는 호출을 1000번 반복) 단어별 횟수를 어휘 순서로 센다.
④ 온도 1, 0.1, 5 각각에 대해 softmax_with_temperature 결과에서 forward·toward·closer 의 확률.
⑤ 로짓에서 값이 큰 3개의 값과 위치(큰 순서), 그리고 keep_top_k(로짓, 3) 에 소프트맥스를 적용한 확률 텐서와 그 합.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다. 텐서는 print("라벨:", 텐서) 꼴로, f-문자열 보간은 쓰지 않는다):
- 탐욕적 선택: forward 처럼 ①
- 확률적 선택 (1회): forward 처럼 ②
- 확률적 선택 1000회: 한 줄 뒤, 어휘 순서로 아홉 줄을   closer: 73 처럼 — 앞에 공백 두 칸, 단어, 콜론, 공백, 횟수
- 온도마다 온도 1: forward 0.5721, toward 0.3576, closer 0.0609 처럼 한 줄 — 온도는 1, 0.1, 5 라고 그대로 적고 확률은 소수점 넷째 자리까지
- 상위 3 로짓: tensor([6.7500, 6.2800, 4.5100]) 처럼 ⑤ 의 값 텐서
- 상위 3 위치: tensor([3, 7, 0]) 처럼 ⑤ 의 위치 텐서
- top-3 확률: tensor([0.0615, 0.0000, 0.0000, 0.5775, 0.0000, 0.0000, 0.0000, 0.3610, 0.0000]) 처럼 ⑤ 의 확률 텐서
- top-3 확률 합: 1.0000 처럼 그 합을 소수점 넷째 자리까지

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 그래프, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
탐욕적 선택: forward
확률적 선택 (1회): forward
확률적 선택 1000회:
  closer: 73
  every: 0
  effort: 0
  forward: 582
  inches: 2
  moves: 0
  pizza: 0
  toward: 343
  you: 0
온도 1: forward 0.5721, toward 0.3576, closer 0.0609
온도 0.1: forward 0.9910, toward 0.0090, closer 0.0000
온도 5: forward 0.2421, toward 0.2203, closer 0.1546
상위 3 로짓: tensor([6.7500, 6.2800, 4.5100])
상위 3 위치: tensor([3, 7, 0])
top-3 확률: tensor([0.0615, 0.0000, 0.0000, 0.5775, 0.0000, 0.0000, 0.0000, 0.3610, 0.0000])
top-3 확률 합: 1.0000
[OK] 19줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch05/step05_decoding_lab.py, 핵심):

def softmax_with_temperature(logits, temperature):
    return torch.softmax(logits / temperature, dim=-1)

def keep_top_k(logits, k):
    top_values, _ = torch.topk(logits, k)                 # 마지막 차원 기준 상위 k 개 (큰 순서)
    threshold = top_values[..., -1:]                      # k 번째 값 — 1차원이면 (1,), 2차원이면 (배치, 1)
    return torch.where(logits < threshold, torch.tensor(float("-inf")), logits)

def print_sampled_tokens(probas, num_samples=1000):
    samples = [torch.multinomial(probas, num_samples=1).item() for _ in range(num_samples)]   # 한 번에 하나씩
    counts = torch.bincount(torch.tensor(samples), minlength=len(VOCAB))

keep_top_k 의 top_values[..., -1:] 이 5.5절에서 말한 실수의 수정본입니다 — top_values[-1] 은 1차원에서만 k 번째 값이고, (배치, k) 에서는 마지막 배치 행을 뜻해 모양이 어긋납니다. ... 는 "앞 차원은 전부", -1: 은 "마지막 하나를 차원을 유지한 채" 라서 1차원·2차원 모두에서 브로드캐스트됩니다. print_sampled_tokens 의 리스트 내포가 난수 소비 규칙("한 번에 하나씩 1,000 번")의 구현입니다. 여러분의 코드는 이것과 달라도 됩니다 — for 문으로 세어도 같은 횟수입니다.

실측

hermes(qwen3.8:27b)의 keep_top_k 는 torch.topk(logits, k, dim=-1).values[..., -1:] — 기준 구현의 수정본과 같은 꼴입니다. 사양에 "입력은 1차원일 수도, (배치, 어휘) 2차원일 수도 있다"를 못 박아 둔 덕에, 제작자가 처음에 틀렸던 지점을 hermes 는 처음부터 피했습니다. 1000회 샘플링은 for 문으로 한 번에 하나씩 뽑았고(난수 소비 규칙), 횟수 줄의 앞 공백 두 칸은 print(" ", word + ":", c) 로 만들었습니다 — 구분자 공백이 하나 더 붙어 정확히 두 칸이 됩니다. 5분에 파일, 10분에 종료, 첫 시도 통과.

step06. 저장과 적재 — 멈춘 자리에서 이어 훈련하기

무엇을 — step04 가 저장한 체크포인트에서 모델과 옵티마이저를 되살리는 load_checkpoint(path, cfg, device), 되살린 모델의 생성·손실 확인, 그리고 이어서 1 에포크 더 훈련
왜 — 3분짜리 훈련도 매번 다시 할 수는 없다. 가중치만 저장하면 생성은 되지만 이어 훈련은 안 된다 — AdamW 의 모멘트까지 저장해야 멈춘 자리에서 계속된다
맥락 — step04 의 CHECKPOINT·train_model_simple 을 재사용한다. step07 이 load_checkpoint 로 훈련된 모델을 꺼내 쓰고, 6·7장 미세조정도 같은 방식으로 저장·적재한다
기대 결과 — 파라미터 162,419,712 개가 되살아나고, 옵티마이저 상태 항목이 파라미터 텐서 수(161)만큼 있으며, 적재 후 샘플이 step04 의 마지막 샘플과 같다
hermes에게 전달되는 사양 (prompts/ch05/step06-save-load.md)
step06_save_load.py 라는 파이썬 파일을 만들어줘.

만들 것: 체크포인트 파일에서 모델과 옵티마이저를 되살리는 함수와, 되살린 모델을 점검하고 이어서 훈련하는 스크립트.

접점 (직전 스텝들 재사용 + step07 이 import 한다):
- 같은 디렉토리의 step01_text_utils.py 에서 GPT_CONFIG_256, text_to_token_ids, token_ids_to_text 를, step03_data_split.py 에서 make_loaders, calc_loss_loader 를,
  step04_train.py 에서 train_model_simple, CHECKPOINT 를 import 한다. 앞 스텝들이 경로를 추가해 두었으므로 step01_tokenize 의 load_text,
  step07_gpt_model 의 GPTModel, step08_generate 의 generate_text_simple 을 import 할 수 있다. 훈련 루프·모델·생성을 새로 구현하지 않는다.
- 함수 load_checkpoint(path, cfg, device) — torch.load 로 체크포인트 딕셔너리를 읽어(키 model_state_dict, optimizer_state_dict), GPTModel(cfg) 를 새로
  만들어 모델 상태를 넣고 device 로 옮기며, 같은 설정의 AdamW(학습률 0.0004, 가중치 감쇠 0.1)를 새로 만들어 옵티마이저 상태를 넣은 뒤 (모델, 옵티마이저) 를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 장치는 CPU. tiktoken 의 gpt2 토크나이저를 만들고 load_checkpoint(CHECKPOINT, GPT_CONFIG_256, 장치) 로 되살린다. 모델을 평가 모드로 바꿔
Every effort moves you 뒤에 새 토큰 50개를 탐욕적으로 생성해 디코딩한다(context_size 는 설정의 context_length, 줄바꿈은 공백으로). load_text() 와
make_loaders 로 두 로더를 만들고 기울기 기록 없이 훈련 로더 전체의 손실을 잰다. 그 다음 되살린 옵티마이저로 train_model_simple 을 1 에포크,
eval_freq 5, eval_iter 5, 시작 문맥 Every effort moves you 로 실행한다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 적재 파라미터 개수: 162,419,712 처럼 되살린 모델의 모든 파라미터 원소 수의 합을 천 단위 콤마로
- 옵티마이저 상태 항목 수: 161 처럼 옵티마이저 state_dict() 의 state 항목 수
- 적재 후 샘플: Every effort moves you ... 처럼 생성 문장 한 줄
- 적재 후 훈련 손실: 0.298 처럼 훈련 로더 손실을 소수점 셋째 자리까지
- 훈련 전 손실(10.988)보다 작다: 예 처럼 그 손실이 10.988 보다 작으면 예, 아니면 아니오
- 이어 훈련의 출력 줄들 (train_model_simple 이 출력하는 그대로)
- 이어 훈련 평가 횟수: 2 처럼 이어 훈련이 반환한 훈련 손실 목록의 길이

지켜야 할 것:
- torch, tiktoken 과 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다. 체크포인트 파일을 새로 저장하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 기준 구현) ---
적재 파라미터 개수: 162,419,712
옵티마이저 상태 항목 수: 161
적재 후 샘플: Every effort moves you know," was one of the axioms he laid down across the Sevres and silver of an exquisitely appointed luncheon-table, when, on a later day, I had again run over from Monte Carlo; and Mrs. Gis
적재 후 훈련 손실: 0.298
훈련 전 손실(10.988)보다 작다: 예
에포크 1 (스텝 000000): 훈련 손실 0.277, 검증 손실 6.549
에포크 1 (스텝 000005): 훈련 손실 0.204, 검증 손실 6.603
샘플: Every effort moves you?"  "Yes--quite insensible to the irony. She wanted him vindicated--and by me!"  He laughed again, and threw back his head to look up at the sketch of the donkey. "There were days when I
이어 훈련 평가 횟수: 2
[OK] 6줄 전부 일치 (손실 값·문장은 대조하지 않음)   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch05/step06_save_load.py, 핵심):

def load_checkpoint(path, cfg, device):
    checkpoint = torch.load(path, map_location=device)
    model = GPTModel(cfg)                                   # 설계도는 코드에, 값은 파일에
    model.load_state_dict(checkpoint["model_state_dict"])
    model.to(device)
    optimizer = torch.optim.AdamW(model.parameters(), lr=0.0004, weight_decay=0.1)
    optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
    return model, optimizer

map_location=device 는 GPU 에서 저장한 파일을 CPU 에서 열 때(또는 그 반대) 텐서를 옮겨 주는 옵션입니다 — 이 강좌는 CPU 뿐이지만 습관으로 넣습니다. 옵티마이저를 model.parameters() 로 만든 뒤에 상태를 넣는 순서가 중요합니다 — 상태는 파라미터 순서에 붙기 때문입니다. 적재 후 샘플이 step04 의 마지막 샘플과 글자까지 같은 것을 확인하세요. 이어 훈련의 첫 평가(0.277)가 step04 마지막 평가(0.391)에서 자연스럽게 이어집니다 — 옵티마이저 모멘트가 살아 있다는 뜻입니다.

실측

hermes(qwen3.8:27b)의 load_checkpoint 는 기준 구현과 같은 여섯 줄이었고 — 순서까지 같습니다 — 적재 후 샘플과 훈련 손실 0.298 이 기준 구현과 글자·숫자까지 같았습니다. 학생의 체크포인트가 step04 에서 기준 구현과 같은 가중치로 훈련됐다는 뜻입니다. 이어 훈련의 두 손실(0.285 / 6.531)은 기준 구현(0.277 / 6.549)과 조금 다른데, 이 스텝은 시드를 고정하지 않아 이어 훈련 로더의 섞기 순서가 실행마다 다르기 때문입니다 — 계약에서 뺀 값이 왜 계약이 될 수 없는지를 보여 주는 실측입니다. 2분에 파일, 7분에 종료, 첫 시도 통과.

step07. generate — 온도·top-k·종료 토큰을 갖춘 생성 함수

무엇을 — 4장 generate_text_simple 을 확장한 generate(model, idx, max_new_tokens, context_size, temperature=0.0, top_k=None, eos_id=None) 와, 훈련한 모델로 하는 세 가지 실험
왜 — step05 의 두 손잡이를 진짜 모델에 단다. 온도 0 이면 4장과 같은 탐욕적 생성, 0 보다 크면 확률적 샘플링, top-k 로 후보를 자르고, 종료 토큰이 나오면 멈춘다 — 챗봇의 생성기가 이 모양이다
맥락 — step05 의 softmax_with_temperature·keep_top_k 와 step06 의 load_checkpoint 를 import 한다. step09 가 이 함수로 진짜 GPT-2 의 문장을 뽑고, 6·7장도 이 함수를 쓴다
기대 결과 — 온도 1.4·top-k 25 로 소설 문체의 새 문장(구현마다 다름), 온도 0 은 4장 함수와 토큰까지 같음, 종료 토큰을 만나면 길이 4 에서 멈춤
hermes에게 전달되는 사양 (prompts/ch05/step07-generate.md)
step07_generate.py 라는 파이썬 파일을 만들어줘.

만들 것: 온도 스케일링·top-k 샘플링·종료 토큰을 지원하는 텍스트 생성 함수와, 훈련한 모델로 그것을 실험하는 스크립트.

접점 (직전 스텝들 재사용 + step09 와 6·7장이 import 한다):
- 같은 디렉토리의 step01_text_utils.py 에서 GPT_CONFIG_256, text_to_token_ids, token_ids_to_text 를, step05_decoding_lab.py 에서 softmax_with_temperature, keep_top_k 를,
  step06_save_load.py 에서 load_checkpoint 를, step04_train.py 에서 CHECKPOINT 를 import 한다. 앞 스텝들이 경로를 추가해 두었으므로 step08_generate 의
  generate_text_simple 을 import 할 수 있다. 온도·top-k 계산과 모델을 새로 구현하지 않는다.
- 함수 generate(model, idx, max_new_tokens, context_size, temperature=0.0, top_k=None, eos_id=None) — (배치 1, 길이) 토큰 ID 텐서 idx 에 새 토큰을 최대
  max_new_tokens 개 이어 붙인 텐서를 반환한다. 한 바퀴: idx 의 마지막 context_size 개 토큰을 기울기 기록 없이 모델에 넣어 마지막 위치의 로짓을 얻고,
  top_k 가 주어지면 keep_top_k 로 상위 k 개만 남기고, temperature 가 0 보다 크면 softmax_with_temperature 의 확률에서 torch.multinomial 로 토큰 하나를 뽑고
  아니면 가장 큰 로짓의 토큰을 고른다. 뽑힌 토큰이 eos_id 와 같으면 붙이지 않고 즉시 멈추고, 아니면 idx 의 끝에 붙인다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 장치는 CPU. tiktoken 의 gpt2 토크나이저를 만들고 load_checkpoint(CHECKPOINT, GPT_CONFIG_256, 장치) 의 모델을 평가 모드로 바꾼다.
Every effort moves you 를 토큰 ID 텐서로 바꿔(context_size 는 설정의 context_length):
① 파이토치 시드 123 고정 직후 온도 1.4, top-k 25 로 새 토큰 15개를 생성해 디코딩한다(줄바꿈은 공백으로).
② 온도 0(기본값)으로 새 토큰 15개를 생성한 결과와 4장 generate_text_simple 로 15개를 생성한 결과가 텐서로 같은지 비교한다.
③ ② 의 탐욕적 결과에서 처음 새로 붙은 토큰 ID 를 eos_id 로 지정해 온도 0 으로 다시 15개를 생성한다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 온도 1.4, top-k 25: Every effort moves you ... 처럼 ① 의 문장 한 줄
- 출력 토큰 수: 19 처럼 ① 결과의 토큰 수
- 온도 0 결과가 4장 탐욕적 결과와 같다: 예 처럼 ② 가 같으면 예, 아니면 아니오
- 종료 토큰에서 멈춘 출력 토큰 수: 4 처럼 ③ 결과의 토큰 수

지켜야 할 것:
- torch, tiktoken 과 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 기준 구현) ---
온도 1.4, top-k 25: Every effort moves you stand to work on surprise, a one of us had gone with random-
출력 토큰 수: 19
온도 0 결과가 4장 탐욕적 결과와 같다: 예
종료 토큰에서 멈춘 출력 토큰 수: 4
[OK] 4줄 전부 일치 (첫 줄은 시작 부분만)   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch05/step07_generate.py, 핵심):

def generate(model, idx, max_new_tokens, context_size, temperature=0.0, top_k=None, eos_id=None):
    for _ in range(max_new_tokens):
        idx_cond = idx[:, -context_size:]
        with torch.no_grad():
            logits = model(idx_cond)
        logits = logits[:, -1, :]                                   # (1, 50257) — 마지막 위치
        if top_k is not None:
            logits = keep_top_k(logits, top_k)                      # 상위 k 개 외 −∞
        if temperature > 0.0:
            probas = softmax_with_temperature(logits, temperature)
            next_id = torch.multinomial(probas, num_samples=1)      # 확률에 비례해 하나
        else:
            next_id = torch.argmax(logits, dim=-1, keepdim=True)    # 4장과 같은 경로
        if eos_id is not None and next_id.item() == eos_id:
            break                                                   # 붙이지 않고 멈춘다
        idx = torch.cat((idx, next_id), dim=1)
    return idx

4장 함수에 if 세 개가 더해졌을 뿐입니다. top-k 를 온도보다 먼저 적용하는 순서에 주의하세요 — 온도로 평평해진 뒤에 자르는 것과 자른 뒤 평평하게 하는 것은 남은 k 개 안에서는 같은 분포지만, 순서를 바꾸면 keep_top_k 가 −∞ 를 온도로 나누는 일이 생깁니다(결과는 같아도 읽기 나쁩니다). eos_id 검사가 cat 앞에 있는 것이 "붙이지 않고 멈춘다"의 구현입니다.

실측

hermes(qwen3.8:27b)의 generate 는 기준 구현과 같은 분기 세 개를 같은 순서(top-k → 온도 → 종료 토큰)로 두었습니다. 온도 1.4·top-k 25 의 샘플 문장이 기준 구현과 글자까지 같았습니다 — 체크포인트가 같고(step04·06 실측), 한 바퀴에 multinomial 을 한 번 부르는 난수 소비도 같기 때문입니다. 시작 부분만 대조하는 계약이었지만 실제로는 문장 전체가 같았던 셈입니다. 4분에 파일, 8분에 종료, 첫 시도 통과. 다른 장 파일은 건드리지 않았습니다.

step08. 진짜 GPT-2 가중치 내려받기 — safetensors 한 파일

무엇을 — OpenAI 가 공개한 GPT-2 124M 가중치(허깅페이스 model.safetensors, 548MB)와 설정 파일을 내려받고 크기·텐서 개수·주요 텐서 모양을 확인하는 ensure_gpt2_files()
왜 — 우리 훈련 데이터는 단편소설 한 편이다. 진짜 언어 능력은 수백억 토큰과 수십만 달러가 든다. 이미 훈련된 공개 가중치를 빌리면 그 비용이 0 이 된다
맥락 — step04 에서 "외우는 모델"의 한계를 봤다. 해법은 큰 데이터로 훈련된 가중치의 이식(step09) — 이 스텝은 그 재료 준비다. 6·7장 미세조정도 이 파일을 쓴다
기대 결과 — 548,105,171 바이트 정확히 일치, 텐서 160개, 토큰 임베딩 (50257, 768), 위치 임베딩 (1024, 768), 블록 0 어텐션 투영 (768, 2304) — 우리 모델과 같은 설계
hermes에게 전달되는 사양 (prompts/ch05/step08-download-gpt2.md)
step08_download_gpt2.py 라는 파이썬 파일을 만들어줘.

만들 것: 허깅페이스에 공개된 GPT-2 124M 가중치 파일과 설정 파일을 내려받아 확인하는 스크립트.

접점 (step09 가 import 한다):
- 상수 WEIGHTS_FILE = "model.safetensors", CONFIG_FILE = "config.json".
- 함수 ensure_gpt2_files() — 두 파일을 https://huggingface.co/openai-community/gpt2/resolve/main/ 뒤에 파일 이름을 붙인 주소에서 현재 디렉토리로 내려받는다.
  설정 파일은 없을 때만, 가중치 파일은 없거나 크기가 548105171 바이트가 아닐 때만 내려받고, 이미 있으면 model.safetensors 이미 있음 처럼 파일 이름 뒤에 이미 있음 을 출력한다.
  내려받을 때는 model.safetensors 내려받기 처럼 출력한 뒤 1MB 조각 단위로 받아 쓰면서 10% 마다   진행 10% 처럼 진행률을 출력한다. 표준 라이브러리 urllib.request 로 받는다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: ensure_gpt2_files() 를 실행한 뒤 가중치 파일 크기를 확인하고, 설정 파일(JSON)에서 n_ctx, n_embd, n_head, n_layer, vocab_size 다섯 값을 이 순서의 딕셔너리로 꺼내고,
safetensors 의 safe_open(파일, framework="pt") 로 가중치 파일을 열어 텐서 이름 개수와 wte.weight, wpe.weight, h.0.attn.c_attn.weight 의 모양을 확인한다.

출력 (직접 실행하면 ensure_gpt2_files() 의 출력 뒤에 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 가중치 파일 크기: 548105171 바이트 처럼 바이트 수
- 크기 일치: True 처럼 548105171 과 같은지 (print("라벨:", 불리언) 꼴)
- 설정: {'n_ctx': 1024, 'n_embd': 768, 'n_head': 12, 'n_layer': 12, 'vocab_size': 50257} 처럼 다섯 값의 딕셔너리
- 텐서 개수: 160 처럼 텐서 이름의 개수
- 토큰 임베딩 모양: torch.Size([50257, 768]) 처럼 wte.weight 의 모양
- 위치 임베딩 모양: torch.Size([1024, 768]) 처럼 wpe.weight 의 모양
- 블록 0 어텐션 투영 모양: torch.Size([768, 2304]) 처럼 h.0.attn.c_attn.weight 의 모양

지켜야 할 것:
- safetensors, torch 와 표준 라이브러리 외의 패키지는 import 하지 않는다 (requests, huggingface_hub 금지).
- 요구한 것 외의 기능(명령행 인자, 해시 검증, 재시도, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 첫 실행 — 두 번째부터는 "이미 있음" 두 줄 뒤 확인만) ---
config.json 내려받기
  진행 10%
model.safetensors 내려받기
  진행 10%
  진행 20%
  ...
  진행 100%
가중치 파일 크기: 548105171 바이트
크기 일치: True
설정: {'n_ctx': 1024, 'n_embd': 768, 'n_head': 12, 'n_layer': 12, 'vocab_size': 50257}
텐서 개수: 160
토큰 임베딩 모양: torch.Size([50257, 768])
위치 임베딩 모양: torch.Size([1024, 768])
블록 0 어텐션 투영 모양: torch.Size([768, 2304])
[OK] 7줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch05/step08_download_gpt2.py, 핵심):

def download(url, filename):
    with urllib.request.urlopen(url) as response, open(filename, "wb") as f:
        total = int(response.headers["Content-Length"])
        done, next_mark = 0, 10
        while True:
            chunk = response.read(1024 * 1024)               # 1MB 씩 — 548MB 를 메모리에 다 올리지 않는다
            if not chunk:
                break
            f.write(chunk)
            done += len(chunk)
            if done * 100 // total >= next_mark:
                print(f"  진행 {next_mark}%")
                next_mark += 10

def ensure_gpt2_files():
    if os.path.exists(WEIGHTS_FILE) and os.path.getsize(WEIGHTS_FILE) == EXPECTED_SIZE:
        print(WEIGHTS_FILE, "이미 있음")
    else:
        print(WEIGHTS_FILE, "내려받기")
        download(BASE_URL + WEIGHTS_FILE, WEIGHTS_FILE)

크기 검사가 핵심입니다 — 다운로드가 중간에 끊기면 파일은 있지만 짧고, 다음 실행이 그것을 발견해 다시 받습니다. with … as response, open(…) as f 처럼 with 하나에 두 자원을 여는 문법은 둘 다 자동으로 닫힙니다. 1판에서는 이 함수의 코드를 사양에 한 줄씩 받아썼지만, 2판 사양은 "1MB 조각·10% 진행률·크기 검사·이미 있으면 생략" 이라는 동작만 적습니다.

실측

hermes(qwen3.8:27b)의 내려받기 함수는 기준 구현과 같은 구조(1MB 조각, 10% 단위 진행률)였고, Content-Length 헤더가 없을 때를 대비한 검사가 하나 더 있었습니다 — 사양 밖의 방어지만 출력에는 영향이 없습니다. 자체 점검으로 실제 다운로드(548MB, 약 2분)까지 수행한 뒤 응답했습니다. 3분에 파일, 7분에 종료, 첫 시도 통과. 검증은 "이미 있음" 경로로 통과 — 두 번째 실행부터의 동작도 사양의 일부입니다.

step09. 가중치 이식 — 공개 GPT-2 를 우리 모델에 넣고 말을 시킨다

무엇을 — safetensors 의 텐서 160개를 우리 GPTModel 의 속성 이름(tok_emb, trf_blocks[i].att.W_query, ff.layers[0], norm1.scale, …)에 맞춰 옮겨 넣는 load_weights_into_gpt(gpt, params), 그리고 이식한 모델의 생성과 소설 손실
왜 — 4장에서 속성 이름을 못 박은 이유가 여기서 드러난다. 이름과 모양이 맞으면 남이 훈련한 1억 2,400만 개 숫자가 그대로 우리 코드 안에서 돌아간다
맥락 — step08 의 파일, step07 의 generate, step03 의 로더·손실, step01 의 변환 함수, 4장 GPTModel, 그리고 3장 step09 에 5장 제작 중 추가한 qkv_bias 가 전부 만난다. 스테이지 1 의 마지막 스텝
기대 결과 — 탐욕적 생성 Every effort moves you forward. The first step is to understand the importance of your work. The second, 소설 손실 3.755 / 3.560 — 훈련 전 10.988 보다 훨씬 낮고, 우리 훈련본의 검증 손실 6.45 보다도 낮다
hermes에게 전달되는 사양 (prompts/ch05/step09-load-gpt2-weights.md)
step09_load_gpt2_weights.py 라는 파이썬 파일을 만들어줘.

만들 것: 공개 GPT-2 124M 가중치를 우리 GPTModel 에 옮겨 넣는 함수와, 이식한 모델로 문장을 생성하고 소설 손실을 재는 스크립트.

접점 (직전 스텝들 재사용 + 6·7장이 import 한다):
- 같은 디렉토리의 step01_text_utils.py 에서 text_to_token_ids, token_ids_to_text 를, step03_data_split.py 에서 make_loaders, calc_loss_loader 를,
  step07_generate.py 에서 generate 를, step08_download_gpt2.py 에서 ensure_gpt2_files, WEIGHTS_FILE 을 import 한다. 앞 스텝들이 경로를 추가해 두었으므로
  step01_tokenize 의 load_text, step01_gpt_config 의 GPT_CONFIG_124M, step07_gpt_model 의 GPTModel, step08_generate 의 generate_text_simple 을 import 할 수 있다.
  가중치 파일은 safetensors.torch 의 load_file 로 읽는다 (텐서 이름 → 텐서 딕셔너리).
- 함수 load_weights_into_gpt(gpt, params) — 딕셔너리 params 의 텐서를 gpt 의 파라미터에 복사해 넣는다. 대응 (모양이 다르면 오류를 낸다):
  - wte.weight → gpt.tok_emb.weight 와 gpt.out_head.weight (둘 다), wpe.weight → gpt.pos_emb.weight
  - 블록 b 마다 (h.{b}. 접두어): attn.c_attn.weight (768, 2304) 를 마지막 차원에서 셋으로 등분해 순서대로 **전치한** 것 → trf_blocks[b].att.W_query.weight,
    W_key.weight, W_value.weight; attn.c_attn.bias 를 셋으로 등분 → 세 층의 bias; attn.c_proj.weight 전치 → att.out_proj.weight, attn.c_proj.bias → att.out_proj.bias;
    mlp.c_fc.weight 전치 → ff.layers[0].weight, mlp.c_fc.bias → ff.layers[0].bias; mlp.c_proj.weight 전치 → ff.layers[2].weight, mlp.c_proj.bias → ff.layers[2].bias;
    ln_1.weight → norm1.scale, ln_1.bias → norm1.shift, ln_2.weight → norm2.scale, ln_2.bias → norm2.shift
  - ln_f.weight → gpt.final_norm.scale, ln_f.bias → gpt.final_norm.shift
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 장치는 CPU. ensure_gpt2_files() 를 호출한다. GPT_CONFIG_124M 의 복사본에 qkv_bias 를 True 로 넣은 설정(문맥 1024 그대로)으로 GPTModel 을 만들고,
가중치를 읽어 옮겨 넣은 뒤 평가 모드로 바꾼다. tiktoken 의 gpt2 토크나이저로 Every effort moves you 를 토큰 ID 텐서로 바꿔:
① 4장 generate_text_simple 로 새 토큰 20개를 탐욕적으로 생성해 디코딩한다(context_size 는 설정의 context_length, 줄바꿈은 공백으로).
② 파이토치 시드 123 고정 직후 generate 로 온도 1.5, top-k 50, 새 토큰 25개를 생성해 디코딩한다(줄바꿈은 공백으로).
③ load_text() 의 소설로 make_loaders(텍스트, 문맥 256 짜리 설정 복사본) 두 로더를 만들고 기울기 기록 없이 각각의 손실을 잰다.

출력 (직접 실행하면 ensure_gpt2_files() 의 출력 뒤에 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 적재 전 파라미터 개수: 163,037,184 처럼 가중치를 넣기 전 모델의 파라미터 원소 수 합을 천 단위 콤마로
- 적재 후 파라미터 개수: 163,037,184 처럼 넣은 뒤의 합
- 탐욕적 생성: Every effort moves you forward.  The first step is to understand the importance of your work.  The second 처럼 ① 의 문장
- 온도 1.5, top-k 50: Every effort moves you ... 처럼 ② 의 문장
- 소설 훈련 손실: 3.755 처럼 ③ 의 훈련 로더 손실을 소수점 셋째 자리까지
- 소설 검증 손실: 3.560 처럼 검증 로더 손실을 소수점 셋째 자리까지

지켜야 할 것:
- torch, tiktoken, safetensors 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 기준 구현) ---
config.json 이미 있음
model.safetensors 이미 있음
적재 전 파라미터 개수: 163,037,184
적재 후 파라미터 개수: 163,037,184
탐욕적 생성: Every effort moves you forward.  The first step is to understand the importance of your work.  The second
온도 1.5, top-k 50: Every effort moves you toward finding an ideal new way to practice something!  What makes us want to be on top of that?
소설 훈련 손실: 3.755
소설 검증 손실: 3.560
[OK] 6줄 전부 일치 (온도 1.5 줄은 시작 부분만)   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch05/step09_load_gpt2_weights.py, 핵심):

def assign(param, tensor):
    if param.shape != tensor.shape:
        raise ValueError(f"모양 불일치: 모델 {tuple(param.shape)} vs 가중치 {tuple(tensor.shape)}")
    return torch.nn.Parameter(tensor.clone().detach())

def load_weights_into_gpt(gpt, params):
    gpt.tok_emb.weight = assign(gpt.tok_emb.weight, params["wte.weight"])
    gpt.pos_emb.weight = assign(gpt.pos_emb.weight, params["wpe.weight"])
    for b, block in enumerate(gpt.trf_blocks):
        p = f"h.{b}."
        q_w, k_w, v_w = torch.chunk(params[p + "attn.c_attn.weight"], 3, dim=-1)   # (768, 2304) → 세 개의 (768, 768)
        block.att.W_query.weight = assign(block.att.W_query.weight, q_w.T)          # 전치 → (768, 768)
        block.att.W_key.weight = assign(block.att.W_key.weight, k_w.T)
        block.att.W_value.weight = assign(block.att.W_value.weight, v_w.T)
        q_b, k_b, v_b = torch.chunk(params[p + "attn.c_attn.bias"], 3, dim=-1)
        block.att.W_query.bias = assign(block.att.W_query.bias, q_b)                 # qkv_bias=True 라서 존재한다
        ...
        block.ff.layers[0].weight = assign(block.ff.layers[0].weight, params[p + "mlp.c_fc.weight"].T)
        block.norm1.scale = assign(block.norm1.scale, params[p + "ln_1.weight"])
    gpt.final_norm.scale = assign(gpt.final_norm.scale, params["ln_f.weight"])
    gpt.out_head.weight = assign(gpt.out_head.weight, params["wte.weight"])        # 가중치 묶기

표 5-2 를 코드로 옮긴 것입니다. assign 의 모양 검사가 없으면 (768, 2304) 를 (768, 768) 자리에 넣으려 할 때 파이토치가 늦게, 알아보기 어려운 오류를 냅니다. clone().detach() 는 safetensors 가 준 텐서와 메모리를 분리해 새 파라미터로 만듭니다. 4장 사양이 layers[0]·layers[2](GELU 가 layers[1]), norm1·norm2, scale·shift 를 못 박아 둔 덕에 이 코드가 hermes 의 4장 모델에도, 기준 구현에도 똑같이 들어갑니다 — 접점이 곧 호환성입니다.

실측

hermes(qwen3.8:27b)는 이식을 기준 구현과 다른 방식 — param.data.copy_(tensor) 로 제자리 복사 — 으로 했습니다. copy_ 는 모양이 맞지 않으면 스스로 오류를 내므로 "모양이 다르면 오류를 낸다"는 조건을 다른 길로 만족한 것입니다. 등분은 chunk(3, dim=1), 전치는 transpose(0, 1). 탐욕적 생성은 계약대로 같았고, 시작 부분만 대조하는 온도 1.5 샘플도 실제로는 문장 전체가 원서·기준 구현과 같았으며, 손실 3.755 / 3.560 도 같았습니다. 6분에 파일, 13분에 종료, 첫 시도 통과. 이로써 5장 아홉 스텝이 전부 첫 시도에 통과했고 사양 수정은 0회입니다 — 3장 step09 와 4장 step02 를 보완·재생성한 것이 이 장의 유일한 사양 변경입니다.

용어 정리

용어원어(약어)한 줄 정의
사전훈련pretraining라벨 없는 대량 텍스트로 "다음 토큰 예측"을 시켜 언어 자체를 배우게 하는 훈련. 미세조정(6·7장)의 앞 단계
손실loss모델이 얼마나 틀렸는지를 나타내는 숫자 하나. 훈련은 이 숫자를 줄이는 일이다
교차 엔트로피cross entropy정답 토큰의 확률에 로그를 취해 평균하고 부호를 바꾼 손실. 정답에 확률을 몰아 주라는 요구
자연로그natural logarithm (ln)e 를 밑으로 하는 로그. 확률 1 이면 0, 작아질수록 큰 음수
로그-소프트맥스log-softmax로짓에서 log(softmax) 를 한 번에 안정적으로 계산하는 방법. cross_entropy 가 내부에서 쓴다
퍼플렉시티perplexitye^손실. "몇 개 후보 사이에서 헤매는가"로 읽는 손실의 다른 표기
훈련 데이터 / 검증 데이터training / validation data갱신에 쓰는 문제집과, 갱신에 쓰지 않고 손실만 재는 모의고사
분할 비율train ratio문자 기준 훈련 쪽 비율(0.9). 검증 창이 두 개 남는 값이라 안전하다
배치 손실batch loss배치 하나의 (배치×토큰) 위치 전부에 대한 교차 엔트로피 평균
에포크epoch훈련 로더를 한 바퀴 다 도는 단위. 9 배치 = 1 에포크
스텝step배치 하나로 갱신 한 번. 이 장은 90 스텝
옵티마이저optimizer기울기를 받아 파라미터를 어떻게 얼마나 고칠지 정하는 갱신 규칙
경사 하강gradient descent기울기의 반대 방향으로 학습률만큼 움직이는 가장 단순한 갱신 규칙
AdamWAdamW기울기의 1차·2차 모멘트로 걸음 크기를 자동 조절하고 가중치 감쇠를 분리한 옵티마이저. GPT 훈련의 표준
학습률learning rate (lr)갱신 걸음의 기본 크기(0.0004)
가중치 감쇠weight decay파라미터가 커지지 않게 매 스텝 조금씩 줄이는 브레이크(0.1)
모멘트momentAdamW 가 파라미터 텐서마다 기억하는 기울기의 최근 평균(1차)과 제곱 평균(2차)
기울기 초기화zero_grad지난 배치의 기울기를 지우는 일. 안 지우면 누적된다
평가evaluation평가 모드·기울기 기록 없이 손실만 재는 일. 5 스텝마다 앞 5 배치로
과대적합overfitting훈련 데이터를 외워 훈련 손실은 낮은데 검증 손실은 높은(또는 오르는) 상태
손실 곡선loss curve스텝에 따른 훈련·검증 손실 그래프. 두 곡선이 갈라지는 지점이 과대적합의 시작
확률적 샘플링probabilistic sampling확률에 비례해 토큰을 뽑는 생성 방식. torch.multinomial
온도temperature소프트맥스 전에 로짓을 나누는 수. 작으면 뾰족(결정적), 크면 평평(무작위)
top-k 샘플링top-k sampling로짓이 큰 k 개만 남기고 나머지를 −∞ 로 만들어 후보를 자르는 기법
난수 소비 규칙RNG consumption rule"한 번에 하나씩 1,000번"처럼 난수를 어떤 순서로 쓰는지 정한 규칙. 재현성의 조건
종료 토큰end-of-sequence token (eos)생성을 멈추게 하는 토큰 ID. 뽑히면 붙이지 않고 멈춘다
state_dictstate_dict"이름 → 텐서" 딕셔너리. 모델과 옵티마이저 둘 다 갖는다
체크포인트checkpoint훈련 도중의 모델·옵티마이저 상태를 저장한 파일. 멈춘 자리에서 재개할 수 있다
safetensorssafetensors텐서만 담는 안전한 저장 포맷. 실행 코드가 섞일 수 없어 내려받아 열어도 안전하다
허깅페이스Hugging Face공개 모델 가중치가 모여 있는 저장소. GPT-2 가중치를 여기서 받는다
전치transpose행렬의 행과 열을 바꾸기. (입력, 출력) 가중치를 (출력, 입력) 으로
등분chunk텐서를 한 차원에서 같은 크기로 나누기. (768, 2304) → 세 개의 (768, 768)
가중치 이식weight loading다른 곳에서 훈련된 가중치를 이름·모양을 맞춰 우리 모델에 넣는 일
qkv_biasqkv_bias질의·키·값 투영에 편향을 둘지 정하는 옵션. GPT-2 는 True. 5장 제작 중 3장 사양에 추가됨
멱등idempotent여러 번 실행해도 결과가 같은 성질. 이미 있으면 내려받지 않는 step08

정리

  • 손실은 정답 토큰의 확률을 로그·평균·부호 반전한 숫자 하나다. 여섯 확률로 손계산한 10.794 가 파이토치 값과 같았고, 퍼플렉시티 48,726 은 "5만 개 사이에서 헤맨다"는 뜻이다.
  • 소설을 90/10 으로 나눠 2장 데이터로더에 넣으면 훈련 9 배치·검증 1 배치. 훈련 루프는 "초기화 → 손실 → 역전파 → AdamW 갱신" 네 줄이고 90 스텝에 3분이 걸린다.
  • 훈련 손실은 9.78 → 0.39, 검증 손실은 6.13 을 바닥으로 다시 오른다 — 소설 한 편을 외운 과대적합. 샘플이 문장이 된 것과 배운 것은 다르다.
  • argmax 는 늘 같은 문장을 낸다. 온도(뾰족/평평)와 top-k(후보 자르기)를 장난감 어휘로 손계산하고 generate 에 달았다. 확률적 결과의 계약은 불변식(길이·온도 0 = 4장·종료 토큰 길이 4)이다.
  • 체크포인트는 모델 state_dict 와 옵티마이저 state_dict 를 함께 담는다(1.95GB). 옵티마이저 상태가 있어야 멈춘 자리에서 이어 훈련한다.
  • 공개 GPT-2 가중치 160 텐서 중 148 개를 4장 접점 이름에 맞춰 옮겨 넣으면 같은 코드가 영어를 한다 — 소설 검증 손실 3.560, 우리 훈련본의 절반 근처. 그러려면 질의·키·값 편향이 필요했고, 3장 사양에 qkv_bias 를 추가해 두 스텝을 다시 생성했다.
  • 사양 설계: 훈련이 개입하면 값 대신 불변식(평가 횟수 18·토큰 44,032·텐서 161·길이 19/4)을, 결정적인 것(GPT-2 탐욕적 생성)은 문장 전체를 계약으로 했다. 난수 소비 규칙("한 번에 하나씩")을 사양에 적어야 횟수가 재현된다.

다음 장 예고

6장에서는 이 장에서 이식한 GPT-2 를 분류기로 미세조정합니다. 스팸 문자 데이터셋을 내려받아 2장 방식으로 배치를 만들고, 출력층을 어휘 50,257 개 대신 "스팸 / 정상" 두 개로 바꿔 끼우고, 대부분의 파라미터는 얼려 둔 채 마지막 블록과 새 출력층만 훈련합니다. 이번 장의 load_checkpoint·calc_loss_loader· train_model_simple 이 거의 그대로 다시 쓰이고, 손실 곡선 대신 정확도 곡선을 봅니다. 사전훈련이 "언어를 배우는 것"이었다면 미세조정은 "그 언어 능력을 한 가지 일에 맞추는 것"입니다.

Chapter 06 전 스텝 검증 완료 (8/8 첫 시도, 6장 사양 수정 0회)

6장. 분류 미세조정 — 언어 모델을 스팸 감별사로

이 장에서 배우는 것

  • 미세조정의 두 갈래(분류 vs 지시)와, 이 장이 고른 분류 미세조정이 무엇을 바꾸고 무엇을 남기는지
  • 스팸 문자 5,572 건을 균형 맞추고(747 대 747) 훈련·검증·테스트로 나눠 같은 길이(120)로 패딩하는 데이터 파이프라인
  • 출력층을 50,257 에서 2 로 갈아 끼우고 파라미터의 5.7% 만 훈련하는 "헤드 수술" — 4장이 이름을 못 박은 덕에 가능한 일
  • 왜 마지막 토큰의 로짓만 보는가 — 인과 마스크가 만드는 "문장 전체를 본 유일한 위치"
  • 5장 훈련 루프를 그대로 가져와 650 번 갱신하면 정확도가 동전 던지기(46%)에서 96% 로 오르는 과정
  • 실습 8스텝: 기준 구현 8개, 사양 8개, 6장 끝에는 낯선 문자를 "스팸 / 정상" 으로 판정하는 함수가 손에 있다

들어가며 — 5장에서 6장으로

5장 끝에서 여러분은 두 가지 모델을 가졌습니다. 소설 한 편을 외운 우리 훈련본과, 영어를 아는 공개 GPT-2 124M. 둘 다 "다음 토큰을 잇는" 일만 합니다 — 스팸 문자를 보여 주고 "이거 스팸이야?" 라고 물으면 답 대신 질문을 되풀이합니다(6.4절에서 실제로 봅니다). 언어를 아는 것과 일을 하는 것은 다릅니다. 사전훈련된 모델에 특정한 일을 가르치는 것이 미세조정(fine-tuning)이고, 1장 로드맵의 스테이지 3 이 여기서 시작됩니다.

미세조정에는 두 갈래가 있습니다. 분류 미세조정은 모델이 정해진 갈래(스팸/정상, 긍정/부정) 중 하나를 고르게 하고, 지시 미세조정(7장)은 어떤 지시든 읽고 문장으로 답하게 합니다. 분류는 할 수 있는 일이 하나뿐이지만 그 하나를 정확히 하고, 데이터도 훈련도 훨씬 적게 듭니다 — 이 장의 훈련은 CPU 로 6분입니다.

이 장의 특별한 점은 새로 만드는 부품이 거의 없다는 것입니다. 4장 GPTModel, 5장 이식 함수, 5장 훈련 루프의 골격을 그대로 쓰고, 바꾸는 것은 출력층 하나와 "무엇을 채점하느냐" 뿐입니다. 4장 사양이 out_head, trf_blocks, final_norm 이라는 이름을 못 박아 두었기 때문에, 이 장은 그 이름으로 층을 갈아 끼우고 얼리고 되돌립니다.

flowchart LR
    subgraph D["데이터 (6.1 ~ 6.3)"]
        direction TB
        D1["step01 5,572 건 내려받기"] --> D2["step02 747 대 747 균형
70 / 10 / 20 분할"] --> D3["step03 토큰화·패딩 120
로더 130 / 19 / 38"] end subgraph M["모델 (6.4 ~ 6.6)"] direction TB M1["step04 GPT-2 124M
5장 이식 함수"] --> M2["step05 출력층 768→2
5.7% 만 훈련"] --> M3["step06 마지막 토큰 채점
기준선 46%"] end subgraph T["훈련과 사용 (6.7 ~ 6.8)"] direction TB T1["step07 650 번 갱신
정확도 96%"] --> T2["step08 새 문자 판정"] end D --> T M --> T CH5["5장 model.safetensors
load_weights_into_gpt
훈련 루프 골격"] ==> M CH4["4장 GPTModel
out_head · trf_blocks · final_norm"] ==> M
그림 6-1. 6장 로드맵. 데이터 세 스텝과 모델 세 스텝이 훈련에서 만난다. 굵은 화살표는 챕터 경계를 넘는 import — 5장 산출물이 이 장의 재료다.

6.1 데이터 — 스팸 문자 5,572 건 (step01)

분류 미세조정의 재료는 "문장 + 정답 라벨" 쌍입니다. UCI 저장소의 SMS 스팸 모음은 영어 문자 메시지 5,572 건에 사람이 ham(정상) 또는 spam 을 붙여 둔 데이터입니다. zip 하나를 내려받아 풀면 탭으로 구분된 두 열(레이블, 본문)짜리 파일이 나오고, pandas 로 읽으면 표가 됩니다. 이 장에서 pandas 로 하는 일은 넷뿐입니다 — 읽기(read_csv), 세기(value_counts), 고르기·섞기(sample), 쓰기(to_csv).

레이블건수비율예
ham (정상)4,82586.6%Go until jurong point, crazy.. Available only in bugis n great world la e buffet...
spam74713.4%WINNER!! As a valued network customer you have been selected to receivea £900 prize reward!

표 6-1. 원본 데이터의 레이블 분포. 스팸이 13% 뿐이라 그대로 훈련하면 "전부 정상" 이라고만 답해도 정확도 87% 가 나온다 — 게으른 분류기의 함정.

6.2 균형과 분할 — 747 대 747, 그리고 70 / 10 / 20 (step02)

표 6-1 의 불균형이 문제입니다. 다수 클래스만 답해도 점수가 높으면 모델은 그 쪽으로 게을러집니다. 가장 단순한 처방은 다수 클래스를 소수 클래스 수만큼 무작위로 줄이는 것 (undersampling)입니다 — ham 4,825 건 중 747 건만 고릅니다. 데이터를 버리는 셈이지만 이 장의 목적(원리 익히기)에는 충분합니다.

flowchart LR
    A["5,572 건
ham 4,825 · spam 747"] --> B["ham 747 건 무작위 선택
random_state 123"] B --> C["균형 1,494 건
ham 747 · spam 747"] C --> D["레이블 ham→0, spam→1
섞기 random_state 123"] D --> E["훈련 1,045 (70%)
스팸 517"] D --> F["검증 149 (10%)
스팸 79"] D --> G["테스트 300 (20%)
스팸 151"]
그림 6-2. 데이터 파이프라인. 무작위가 두 번(고르기, 섞기) 개입하므로 random_state=123 이 두 번 다 계약이다.

손계산 ① 1,494 건이 1,045 / 149 / 300 이 되는 계산

1,494 × 0.7 = 1,045.8 → 정수 부분 1,045. 1,494 × 0.1 = 149.4 → 149. 나머지 1,494 − 1,045 − 149 = 300. 스팸 수 517 + 79 + 151 = 747 ✓ — 섞은 결과 훈련 쪽에 스팸이 49.5% 들어갔습니다(균형이 대략 유지됨). 검증 세트는 훈련 중 지켜보는 모의고사, 테스트 세트는 훈련이 끝난 뒤 한 번만 재는 최종 시험입니다 — 5장에서 검증 데이터를 떼어 둔 것과 같은 이유에 "최종 점수는 훈련 중 한 번도 안 본 데이터로" 가 더해졌습니다.

레이블을 문자열(ham/spam)에서 정수(0/1)로 바꿔 저장합니다 — 6.5절의 새 출력층이 내는 로짓 두 개의 자리 번호(0번, 1번)와 맞추기 위해서입니다.

6.3 패딩과 로더 — 길이가 다른 문자를 같은 길이로 (step03)

5장의 소설은 창으로 잘라 늘 256 토큰이었지만, 문자 메시지는 3 토큰짜리도 120 토큰짜리도 있습니다. 배치는 직사각형 텐서여야 하므로 짧은 메시지의 뒤를 패딩 토큰으로 채웁니다. 패딩 토큰은 GPT-2 어휘의 마지막 토큰 <|endoftext|>(ID 50256)을 씁니다 — 새 토큰을 만들면 임베딩이 없기 때문이고, 문서 끝 표시는 원래 "여기서부터는 내용이 아님" 이라는 뜻이라 용도에도 맞습니다.

flowchart LR
    subgraph IN["토큰화된 문자 세 건"]
        A["Go until jurong … (28 토큰)"]
        B["Ok lar… (7 토큰)"]
        C["WINNER!! … (43 토큰)"]
    end
    subgraph PAD["max_length 120 으로 패딩"]
        A2["28 토큰 + 50256 × 92"]
        B2["7 토큰 + 50256 × 113"]
        C2["43 토큰 + 50256 × 77"]
    end
    A --> A2
    B --> B2
    C --> C2
    PAD --> BATCH["배치 텐서 (8, 120)
레이블 (8,)"]
그림 6-3. 패딩. 훈련 세트에서 가장 긴 메시지(120 토큰)가 기준이고, 검증·테스트도 120 에 맞춘다 — 더 긴 메시지는 잘린다.

손계산 ② 배치가 130 / 19 / 38 개인 이유

훈련 1,045 ÷ 8 = 130.6 → 자투리 5 건을 버려 130 (섞으므로 매 에포크 다른 5 건이 빠진다). 검증 149 ÷ 8 = 18.6 → 자투리를 버리지 않아 19 (마지막 배치는 5 건). 테스트 300 ÷ 8 = 37.5 → 38 (마지막 배치 4 건). 검증·테스트에서 자투리를 버리지 않는 이유는 점수를 낼 때 모든 예제를 세어야 하기 때문입니다.

왜 검증·테스트를 훈련의 max_length(120)에 맞추는가? 6.6절에서 분류는 마지막 위치의 출력을 봅니다. 길이가 다르면 "마지막 위치" 가 달라져 훈련 때와 다른 조건이 됩니다. 같은 길이로 맞추면 패딩 토큰이 마지막이더라도 모델이 훈련 때 본 것과 같은 상황입니다.

6.4 사전훈련 모델 — 5장 이식 함수를 5장 밖에서 쓴다 (step04)

미세조정은 무작위 초기값이 아니라 이미 언어를 아는 모델에서 시작합니다. 5장 step09 가 만든 GPT-2 124M 이 그 모델이고, 이 스텝은 5장 함수 load_weights_into_gpt 와 5장이 내려받은 model.safetensors 를 챕터 경계 너머(../ch05)에서 그대로 씁니다. 설정에서 5장과 다른 점은 드롭아웃을 0 으로 끈 것뿐입니다 — 미세조정에서는 데이터가 작아 드롭아웃의 효과가 뚜렷하지 않고, 원서도 끕니다.

분류 전 모델에 스팸 문자를 보여 주고 "Is the following text 'spam'? Answer with 'yes' or 'no':" 라고 물으면 어떻게 될까요? 실측은 이렇습니다.

Is the following text 'spam'? Answer with 'yes' or 'no': 'You are a winner you have been specially selected to receive $1000 cash or a $2000 award.'
The following text 'spam'? Answer with 'yes' or 'no': 'You are a winner

답 대신 질문을 되풀이합니다. 모델은 영어는 알지만 지시를 따르는 법은 모릅니다 — 다음 토큰을 이을 뿐입니다. 그것을 가르치는 두 방법이 있습니다.

flowchart TB
    P["사전훈련 GPT-2
다음 토큰 예측만 안다"] --> C["분류 미세조정 (6장)
출력층 → 2 갈래
마지막 토큰으로 채점"] P --> I["지시 미세조정 (7장)
출력층 그대로
지시 + 응답 문장으로 훈련"] C --> C2["할 수 있는 일: 하나
스팸 / 정상 — 96%"] I --> I2["할 수 있는 일: 지시가 닿는 모든 것
답의 품질은 크기·데이터에 좌우"]
그림 6-4. 미세조정의 두 갈래. 분류는 출력층을 바꾸고, 지시 미세조정은 데이터를 바꾼다. 둘 다 사전훈련 모델에서 출발한다.

6.5 분류 헤드 — 출력층을 50,257 에서 2 로 갈아 끼우고 대부분을 얼린다 (step05)

수술은 세 단계입니다. ① 모든 파라미터를 얼린다(requires_grad = False — 기울기를 계산하지 않고 갱신도 하지 않는다). ② out_head 를 새 선형층 (768 → 2) 으로 바꿔 끼운다 — 새 층은 만들 때부터 훈련 가능이다. ③ 마지막 트랜스포머 블록과 최종 정규화만 되돌린다(requires_grad = True). 나머지 11 개 블록과 임베딩은 사전훈련 값 그대로 남습니다.

flowchart LR
    subgraph BEFORE["수술 전 — 5장 GPT-2 124M"]
        direction TB
        E1["tok_emb · pos_emb"] --> B1["trf_blocks 0 ~ 10"] --> B2["trf_blocks 11"] --> N1["final_norm"] --> H1["out_head 768 → 50257
로짓 (배치, 토큰, 50257)"] end subgraph AFTER["수술 후 — 분류기"] direction TB E2["tok_emb · pos_emb ❄"] --> B3["trf_blocks 0 ~ 10 ❄"] --> B4["trf_blocks 11 🔥"] --> N2["final_norm 🔥"] --> H2["out_head 768 → 2 🔥 (새 층)
로짓 (배치, 토큰, 2)"] end BEFORE -. "얼리기 → 바꿔 끼우기 → 되돌리기" .-> AFTER
그림 6-5. 헤드 수술 전후. ❄ 는 얼린 층(사전훈련 값 고정), 🔥 는 훈련되는 층. 4장 사양이 못 박은 이름(out_head, trf_blocks, final_norm)으로 층을 지목한다.

왜 마지막 블록까지 되돌리는가? 새 출력층만 훈련해도 되지만, 원서의 실험대로 마지막 블록과 정규화를 함께 훈련하면 정확도가 눈에 띄게 오릅니다 — 출력층이 읽는 벡터 자체를 분류에 맞게 조금 바꿀 수 있기 때문입니다. 더 많은 블록을 되돌리면 더 오르지만 훈련 시간도 그만큼 늘어납니다(연습문제 거리).

손계산 ③ 전체 파라미터 — 163,037,184 에서 124,441,346 으로

바뀐 것은 출력층뿐입니다. 옛 출력층 50,257 × 768 = 38,597,376 개가 빠지고 새 출력층 2 × 768 + 2(편향) = 1,538 개가 들어옵니다. 163,037,184 − 38,597,376 + 1,538 = 124,441,346. (5장의 163,037,184 는 4장의 163,009,536 에 질의·키·값 편향 27,648 을 더한 값이었습니다.)

손계산 ④ 훈련 가능 파라미터 — 7,090,946 개, 전체의 5.7%

마지막 블록 = 4장 손계산 ⑦ 의 7,085,568 + 질의·키·값 편향 3 × 768 = 2,304 → 7,087,872. 최종 정규화 scale·shift 768 × 2 = 1,536. 새 출력층 1,538. 합 7,087,872 + 1,536 + 1,538 = 7,090,946 — 전체의 5.7%. 역전파가 마지막 블록까지만 내려가므로 계산도 메모리도 그만큼만 듭니다. CPU 로 6분에 끝나는 이유입니다.

새 층은 무작위로 초기화되므로 시드 위치(얼린 직후, 층을 만들기 직전)가 계약입니다. Do you have time 을 넣으면 마지막 토큰 로짓이 tensor([[-3.5983, 3.9902]]) — 원서와 같은 값이고, argmax 는 1(스팸)이지만 훈련 전이라 아무 뜻도 없습니다.

6.6 마지막 토큰으로 채점한다 — 정확도와 손실 (step06)

수술한 모델의 출력은 (배치, 토큰, 2) 입니다. 토큰마다 로짓 두 개가 나오는데, 분류에는 마지막 토큰의 것만 씁니다. 이유는 3장의 인과 마스크입니다 — i 번째 위치는 0~i 번째 토큰만 볼 수 있으므로, 문장 전체를 본 위치는 마지막 토큰뿐입니다. 첫 토큰의 출력은 첫 단어만 보고 내린 판단이라 쓸모가 없습니다.

flowchart LR
    T["입력 (1, 120)
Do you have time + 패딩"] --> M["분류기"] M --> O["출력 (1, 120, 2)"] O --> L0["위치 0 의 로짓
첫 토큰만 봄 ✗"] O --> L1["위치 1 의 로짓
두 토큰만 봄 ✗"] O --> LN["위치 119 (마지막) 의 로짓
전부 봄 ✓ → argmax = 레이블"]
그림 6-6. 마지막 토큰 채점. 인과 마스크 때문에 마지막 위치만이 문장 전체를 본 판단이다. 패딩이 뒤에 붙어 있어도 그 자리가 "마지막" 이다.

수식 두 개가 이 스텝의 전부입니다.

정확도 = (마지막 토큰 로짓의 argmax 가 레이블과 같은 예제 수) ÷ (본 예제 수)

손실 = 마지막 토큰 로짓 (배치, 2) 과 레이블 (배치,) 의 교차 엔트로피 — 5장 손실과 같은 식, 어휘 50,257 개가 2 개로 줄었을 뿐

손계산 ⑤ 훈련 전 정확도 46.25% 는 몇 개를 맞힌 것인가

앞 10 배치 × 8 = 80 예제. 46.25% × 80 = 37 개. 검증 45.00% = 36 개, 테스트 48.75% = 39 개. 동전을 80 번 던져도 그 정도 맞습니다 — 무작위 출력층은 아무것도 모릅니다. 이 값이 원서와 정확히 같은 것은 시드 123 이 (ⅰ) 새 출력층의 초기값과 (ⅱ) 훈련 로더가 섞는 순서를 둘 다 정하기 때문입니다 — 그래서 사양은 "정확도 셋 → 손실 셋" 이라는 재는 순서까지 못 박습니다(훈련 로더를 다시 돌 때마다 난수를 씁니다).

손계산 ⑥ 손실 2.45 는 "아무것도 모름" 보다 나쁘다

두 갈래 문제에서 확률을 반반으로 두는 모델의 손실은 −ln 0.5 = 0.693 입니다. 훈련 전 손실 2.453 은 그보다 훨씬 큽니다 — 무작위 출력층이 틀린 쪽을 자신 있게 고르고 있다는 뜻입니다 — 로짓이 (−3.6, 4.0) 처럼 쏠려 있는데 정답이 0 이면 그 예제의 손실은 ln(1 + e^7.6) ≈ 7.6 입니다. 80 예제 중 절반쯤이 이런 식이면 평균이 2.4 가 됩니다. 훈련의 첫 임무는 이 과신을 지우는 것이고, 그래서 손실이 처음 50 스텝에 2.15 → 0.62 로 빠르게 떨어집니다(6.7절).

6.7 훈련 루프 — 650 번의 갱신 (step07)

5장 step04 의 루프와 같은 골격입니다. 다른 것은 세 가지뿐 — 손실 함수가 마지막 토큰용, 본 토큰 수 대신 본 예제 수, 에포크 끝에 샘플 생성 대신 정확도 기록.

flowchart TB
    subgraph EP["에포크 (5번)"]
        direction TB
        B["훈련 로더에서 배치
(8, 120) + 레이블 (8,)"] --> Z["zero_grad"] --> L["마지막 토큰 손실
calc_loss_batch"] --> BW["backward
(얼린 층은 건너뜀)"] --> ST["AdamW step
lr 5e-5 · wd 0.1"] ST --> C{"스텝이 50 의 배수?"} C -- 예 --> EV["evaluate_model
훈련·검증 앞 5 배치 손실"] C -- 아니오 --> B EV --> B end EP --> ACC["에포크 끝: 훈련·검증 앞 5 배치 정확도"] ACC --> EP
그림 6-7. 분류 훈련 루프. 130 배치 × 5 에포크 = 650 스텝. 평가는 50 스텝마다(13 번), 정확도는 에포크마다(5 번).

손계산 ⑦ 스텝 650, 평가 13, 예제 5,200

130 배치 × 5 에포크 = 650 스텝(0 ~ 649). 50 의 배수인 스텝 0, 50, …, 600 에서 평가 → 13 번. 본 예제 수 = 650 배치 × 8 = 5,200(1,040 건짜리 훈련 세트를 다섯 바퀴). 이 두 수가 step07 의 계약입니다. 학습률 0.00005 는 5장(0.0004)의 8분의 1 — 이미 좋은 값에서 출발하므로 조금씩만 고칩니다.

기준 구현의 실측(CPU, 5분 36초)은 이렇습니다.

xychart-beta
    title "훈련 손실과 검증 손실 (앞 5 배치) — 50 스텝마다"
    x-axis "전역 스텝" [0, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600]
    y-axis "손실" 0 --> 2.5
    line [2.153, 0.617, 0.523, 0.561, 0.419, 0.409, 0.333, 0.340, 0.136, 0.153, 0.222, 0.207, 0.083]
    line [2.392, 0.637, 0.557, 0.489, 0.397, 0.353, 0.320, 0.306, 0.200, 0.132, 0.137, 0.143, 0.074]
그림 6-8. 손실 곡선. 5장과 달리 두 곡선이 끝까지 붙어 내려간다 — 데이터가 1,045 건이라 외울 것이 없고, 훈련되는 파라미터가 5.7% 뿐이라 외울 여력도 없다. 원서와 같은 값.
에포크훈련 정확도 (앞 5 배치)검증 정확도 (앞 5 배치)
170.00%72.50%
282.50%85.00%
390.00%90.00%
4100.00%97.50%
5100.00%97.50%
최종 (전체)97.21%97.32% — 테스트 95.67%

표 6-2. 에포크별 정확도. 앞 5 배치(40 예제)로 재는 중간 값은 거칠고, 마지막 줄의 전체 정확도가 진짜 점수다. 원서와 같은 값.

훈련 정확도 97.21% 와 테스트 정확도 95.67% 의 차이 1.5%p 가 "처음 보는 문자에서 얼마나 떨어지는가" 입니다. 5장의 훈련 0.39 대 검증 6.45 같은 붕괴는 없습니다 — 그림 6-8 의 두 곡선이 붙어 있는 것과 같은 이야기입니다.

손계산 ⑧ 정확도를 개수로 되돌리면

최종 정확도는 로더 전체로 잽니다(앞 5 배치가 아니라). 훈련 로더는 자투리 5 건을 버리므로 1,040 건: 97.21% × 1,040 = 1,011 건 맞힘(29 건 틀림). 검증 149 건: 97.32% × 149 = 145 건 (4 건 틀림). 테스트 300 건: 95.67% × 300 = 287 건(13 건 틀림). 퍼센트 둘째 자리가 원서와 같은 것은 우연이 아니라 "맞힌 개수 ÷ 전체" 가 정확히 같은 분수이기 때문입니다 — 1,011/1,040 = 0.972115…, 145/149 = 0.973154…, 287/300 = 0.956666…. 정확도 값이 흔들리는 하드웨어에서도 이 분수가 한두 건 차이로만 움직입니다.

5장 사전훈련 (step04)6장 분류 미세조정 (step07)
출발점무작위 초기값GPT-2 124M 사전훈련 가중치
데이터소설 1편, 4,608 토큰문자 1,045 건 (라벨 있음)
배치 / 스텝2 × 256 토큰, 90 스텝8 × 120 토큰, 650 스텝
채점 위치모든 토큰 위치 (배치×256)마지막 토큰 하나
훈련되는 파라미터전부 (162,419,712)5.7% (7,090,946)
학습률0.00040.00005 (8분의 1)
손실 (첫 → 끝)9.78 → 0.39 (검증 6.45, 과대적합)2.15 → 0.08 (검증 0.07, 붙어 내려감)
결과소설을 외운 모델테스트 정확도 95.67% 의 감별사
CPU 시간2분 47초5분 36초

표 6-3. 두 훈련의 비교. 같은 루프 골격인데 출발점·채점 위치·훈련 범위가 달라 결과의 성격이 완전히 다르다. 스텝이 7배인데 시간은 2배인 이유는 역전파가 마지막 블록까지만 내려가기 때문이다.

6.8 새 문자 판정 — 분류기를 되살려 쓰는 함수 (step08)

훈련의 결과물은 정확도가 아니라 쓸 수 있는 함수입니다. 5장 step06 처럼 state_dict 를 되살리고, 훈련 때와 같은 전처리 — 토큰화, 120 으로 자르기, 패딩, 마지막 토큰 — 를 거쳐 argmax 를 읽습니다. 전처리가 하나라도 다르면(예: 패딩을 안 하면) 훈련 때 본 적 없는 입력이 됩니다.

flowchart LR
    T["새 문자
You are a winner …"] --> E["토큰화"] --> C["120 으로 자르기"] --> P["50256 으로 패딩"] --> M["분류기 (1, 120) → (1, 120, 2)"] --> L["마지막 위치 argmax"] --> R{"1?"} R -- 예 --> S["스팸"] R -- 아니오 --> H["정상"]
그림 6-9. 판정 함수. step03 데이터셋과 정확히 같은 전처리 뒤에 6.6절의 채점을 한 번 한다.

당첨 문자(You are a winner you have been specially selected to receive $1000 cash or a $2000 award.)는 스팸, 저녁 약속 문자(Hey, just wanted to check if we're still on for dinner tonight? Let me know!)는 정상 — 둘 다 훈련 데이터에 없는 문장입니다. 6.4절에서 질문을 되풀이하던 모델이 650 번의 갱신 뒤에 감별사가 됐습니다.

실습 — 루프 여덟 바퀴

여덟 스텝은 접점으로 이어져 있습니다. 순서대로 진행하세요. 5장 실습을 끝낸 상태에서 시작해야 합니다 — step04 가 workspace/ch05/model.safetensors 와 5장 step09 의 load_weights_into_gpt 를 import 합니다.

flowchart LR
    S1["step01
load_sms"] --> S2["step02
균형·분할 → CSV"] S2 --> S3["step03
SpamDataset
build_dataloaders"] C5["5장 model.safetensors
load_weights_into_gpt"] ==> S4["step04
build_pretrained_model
BASE_CONFIG"] C4["4장 GPTModel"] ==> S4 S4 --> S5["step05
build_classifier_model"] S3 --> S6["step06
calc_accuracy_loader
calc_loss_*"] S5 --> S6 S6 --> S7["step07
train_classifier_simple
CLASSIFIER_FILE"] S3 --> S7 S5 --> S7 S7 --> S8["step08
classify_review"] S3 --> S8 S5 --> S8
그림 6-10. 6장 실습의 접점 흐름. 데이터 줄기(step01→03)와 모델 줄기(step04→05)가 step06 에서 만나고, step07 이 훈련해 step08 이 쓴다. 굵은 화살표가 챕터 경계를 넘는 import 다.

각 스텝은 bash harness/run_loop.sh ch06 prompts/ch06/stepNN-*.md → bash harness/verify.sh ch06 stepNN → bash harness/verify.sh ch06 stepNN --solution 의 세 명령입니다 (step07 만 HERMES_TIMEOUT=2400 을 앞에 붙입니다). 이번 장 사양의 특징은 시드가 세 곳이라는 점입니다 — pandas 의 random_state=123(고르기·섞기), 파이토치 시드 123 (로더 섞기 직전, 새 출력층 직전, 훈련 직전). 원서와 같은 값이 나오려면 셋 다 제자리에 있어야 하고, 사양은 그 자리를 전부 적습니다. 훈련이 개입하는 step07 만 불변식 계약입니다.

step01. 스팸 문자 데이터 — 5,572 건을 내려받아 표로 읽기

무엇을 — UCI 저장소의 SMS 스팸 모음(zip)을 내려받아 풀고, 레이블(ham/spam)과 본문 두 열의 표(pandas DataFrame)로 읽어 주는 load_sms()
왜 — 분류 미세조정에는 "문장 + 정답 라벨" 쌍이 필요하다. 5장 소설과 달리 이 데이터는 한 줄이 한 예제이고, 정답이 붙어 있다
맥락 — 6장의 출발점. step02 가 이 표를 균형·분할하고, 이후 모든 스텝이 그 결과를 쓴다. 5장 step08 처럼 "없으면 내려받고 있으면 생략" 하는 멱등 스크립트다
기대 결과 — 5,572 행, 열 이름 ['Label', 'Text'], ham 4,825 / spam 747 — 스팸이 13% 뿐인 불균형 데이터
hermes에게 전달되는 사양 (prompts/ch06/step01-download-sms.md)
step01_download_sms.py 라는 파이썬 파일을 만들어줘.

만들 것: SMS 스팸 모음 데이터를 내려받아 풀고 표로 읽는 스크립트.

접점 (step02 가 import 한다):
- 함수 download_and_unzip() — 현재 디렉토리에 sms_spam_collection/SMSSpamCollection.tsv 가 이미 있으면 sms_spam_collection/SMSSpamCollection.tsv 이미 있음 을 출력하고
  끝낸다. 없으면 https://archive.ics.uci.edu/static/public/228/sms+spam+collection.zip 을 sms_spam_collection.zip 으로 내려받아(표준 라이브러리 urllib.request)
  디렉토리 sms_spam_collection 에 풀고, 풀린 파일 SMSSpamCollection 의 이름을 SMSSpamCollection.tsv 로 바꾼 뒤 sms_spam_collection/SMSSpamCollection.tsv 준비됨 을 출력한다.
- 함수 load_sms() — download_and_unzip() 을 부른 뒤, 탭으로 구분된 그 파일을 헤더 없이 읽어 열 이름이 Label, Text 인 pandas 데이터프레임을 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 download_and_unzip() 의 출력 뒤에 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 행 수: 5572 처럼 행 수
- 열 이름: ['Label', 'Text'] 처럼 열 이름 목록
- 레이블 분포: 한 줄 뒤 다음 줄부터 Label 열의 값별 개수 (pandas value_counts() 를 to_string() 으로 출력한 그대로 — 많은 것부터)
- 첫 행 레이블: ham 처럼 첫 행의 레이블
- 첫 행 본문: Go until jurong point, crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat... 처럼 첫 행의 본문

지켜야 할 것:
- pandas 와 표준 라이브러리 외의 패키지는 import 하지 않는다 (requests 금지).
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 재시도, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 첫 실행) ---
sms_spam_collection.zip 내려받기
sms_spam_collection/SMSSpamCollection.tsv 준비됨
행 수: 5572
열 이름: ['Label', 'Text']
레이블 분포:
Label
ham     4825
spam     747
첫 행 레이블: ham
첫 행 본문: Go until jurong point, crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat...
[OK] 6줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch06/step01_download_sms.py, 핵심):

def download_and_unzip():
    if os.path.exists(DATA_FILE):
        print(DATA_FILE, "이미 있음")
        return
    print(ZIP_FILE, "내려받기")
    urllib.request.urlretrieve(URL, ZIP_FILE)
    with zipfile.ZipFile(ZIP_FILE, "r") as zip_ref:
        zip_ref.extractall(EXTRACT_DIR)
    os.rename(os.path.join(EXTRACT_DIR, "SMSSpamCollection"), DATA_FILE)   # 확장자 .tsv 붙이기
    print(DATA_FILE, "준비됨")

def load_sms():
    download_and_unzip()
    return pd.read_csv(DATA_FILE, sep="\t", header=None, names=["Label", "Text"])

urlretrieve 로 받고 zipfile 로 풀고 os.rename 으로 이름을 붙이는 세 줄이 전부입니다. read_csv 의 sep="\t" 가 탭 구분, header=None 이 "첫 줄도 데이터" 라는 뜻입니다. value_counts() 는 많은 것부터 세어 주므로 ham 이 먼저 나옵니다.

실측

hermes(qwen3.8:27b)는 두 접점을 사양대로 만들었습니다. 기준 구현과 다른 점은 경로 문자열을 상수로 빼지 않고 세 번 적은 것, 첫 행을 df.iloc[0, 0](열 번호)로 읽은 것 — 둘 다 동작은 같습니다. 자체 점검으로 zip 을 실제로 내려받고 풀어 본 뒤 응답했습니다. 3.5분에 파일, 5분에 종료, 첫 시도 통과. 다른 장 파일은 건드리지 않았습니다.

step02. 균형과 분할 — 747 대 747, 그리고 70 / 10 / 20

무엇을 — ham 을 spam 수(747)만큼 무작위로 줄여 균형을 맞추는 create_balanced_dataset, 섞어서 훈련 70% / 검증 10% / 테스트 20% 로 나누는 random_split, 결과를 CSV 세 개로 저장
왜 — 불균형 데이터로 훈련하면 다수 클래스만 답하는 게으른 분류기가 된다. 테스트 세트를 따로 두는 이유는 5장의 검증 데이터와 같다 — 안 본 데이터로 최종 점수를 낸다
맥락 — step01 의 표를 받아 step03 이 읽을 파일을 만든다. 무작위가 두 번(ham 고르기, 섞기) 개입하므로 시드가 계약이다
기대 결과 — 균형 후 1,494 행, 훈련 1,045 / 검증 149 / 테스트 300, 그중 스팸 517 / 79 / 151 — 원서와 같은 값
hermes에게 전달되는 사양 (prompts/ch06/step02-prepare-dataset.md)
step02_prepare_dataset.py 라는 파이썬 파일을 만들어줘.

만들 것: 스팸 데이터의 균형을 맞추고 레이블을 정수로 바꾼 뒤 훈련·검증·테스트로 나눠 CSV 로 저장하는 스크립트.

접점 (직전 스텝 재사용 + step03 이 import 한다):
- 같은 디렉토리의 step01_download_sms.py 에서 load_sms() 를 import 한다.
- 상수 TRAIN_FILE = "train.csv", VAL_FILE = "validation.csv", TEST_FILE = "test.csv".
- 함수 create_balanced_dataset(df) — Label 이 spam 인 행 수만큼 ham 행을 무작위로 고르되 pandas DataFrame.sample 에 random_state=123 을 주고,
  그 ham 부분집합 뒤에 spam 행 전부를 이 순서로 이어 붙인 데이터프레임을 반환한다.
- 함수 random_split(df, train_frac, validation_frac) — 전체를 sample(frac=1, random_state=123) 으로 섞고 인덱스를 0 부터 새로 매긴 뒤,
  앞 전체 × train_frac 의 정수 부분을 훈련, 그 다음 전체 × validation_frac 의 정수 부분을 검증, 나머지를 테스트로 잘라 (훈련, 검증, 테스트) 를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: load_sms() 의 표를 균형 맞추고, Label 을 ham → 0, spam → 1 정수로 바꾼 뒤 0.7 / 0.1 로 나눠 세 파일에 인덱스 없이 저장한다.

출력 (직접 실행하면 step01 의 다운로드 안내 뒤에 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 균형 후 행 수: 1494 처럼 균형 맞춘 표의 행 수
- 균형 후 레이블 분포: 한 줄 뒤 다음 줄부터 value_counts() 를 to_string() 으로 출력한 그대로 (정수로 바꾸기 전)
- 훈련 세트 크기: 1045, 검증 세트 크기: 149, 테스트 세트 크기: 300 처럼 세 줄
- 훈련 세트 스팸 수: 517, 검증 세트 스팸 수: 79, 테스트 세트 스팸 수: 151 처럼 세 줄 (Label 열의 합)

지켜야 할 것:
- pandas 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
sms_spam_collection/SMSSpamCollection.tsv 이미 있음
균형 후 행 수: 1494
균형 후 레이블 분포:
Label
ham     747
spam    747
훈련 세트 크기: 1045
검증 세트 크기: 149
테스트 세트 크기: 300
훈련 세트 스팸 수: 517
검증 세트 스팸 수: 79
테스트 세트 스팸 수: 151
[OK] 9줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch06/step02_prepare_dataset.py, 핵심):

def create_balanced_dataset(df):
    num_spam = (df["Label"] == "spam").sum()
    ham_subset = df[df["Label"] == "ham"].sample(num_spam, random_state=123)   # ham 747 건 무작위
    return pd.concat([ham_subset, df[df["Label"] == "spam"]])

def random_split(df, train_frac, validation_frac):
    df = df.sample(frac=1, random_state=123).reset_index(drop=True)          # 전체 섞기
    train_end = int(len(df) * train_frac)
    validation_end = train_end + int(len(df) * validation_frac)
    return df[:train_end], df[train_end:validation_end], df[validation_end:]

sample(n, random_state=123) 은 n 건을 무작위로 고르고, sample(frac=1, …) 은 전부를 무작위 순서로 다시 늘어놓습니다 — 같은 함수가 "고르기" 와 "섞기" 를 다 합니다. reset_index(drop=True) 는 섞인 뒤 옛 행 번호를 버리고 0 부터 다시 매깁니다 — 안 하면 슬라이스가 옛 번호를 따라갑니다.

실측

hermes(qwen3.8:27b)는 시드 규칙 두 개를 사양대로 지켜 스팸 수 517 / 79 / 151 이 기준 구현과 같았습니다 — 시드 위치가 계약이라는 것이 이 값으로 확인됩니다. 본문을 main() 으로 감싸고 슬라이스에 iloc 을 쓴 점이 기준 구현과 다릅니다. 1.5분에 파일, 6분에 종료, 첫 시도 통과.

step03. 데이터셋과 로더 — 길이가 다른 문자를 같은 길이로

무엇을 — 문자 본문을 토큰 ID 로 바꾸고 가장 긴 것(120)에 맞춰 패딩하는 SpamDataset, 훈련·검증·테스트 로더 세 개를 만드는 build_dataloaders()
왜 — 5장의 소설은 창으로 잘라 길이가 늘 256 이었지만 문자 메시지는 길이가 제각각이다. 배치로 묶으려면 같은 길이여야 하고, 모자란 자리는 패딩 토큰으로 채운다
맥락 — 2장 데이터로더의 분류판. step02 의 CSV 를 읽고, 이후 모든 스텝이 build_dataloaders() 를 부른다. 5장 step01 처럼 시드 123 이 로더의 섞기 순서를 정한다
기대 결과 — 패딩 토큰 ID 50256, 가장 긴 훈련 메시지 120 토큰, 배치 모양 (8, 120) / (8,), 배치 수 130 / 19 / 38
hermes에게 전달되는 사양 (prompts/ch06/step03-dataloader.md)
step03_dataloader.py 라는 파이썬 파일을 만들어줘.

만들 것: 문자 메시지 CSV 를 토큰 ID 텐서와 레이블로 내주는 파이토치 데이터셋과, 훈련·검증·테스트 로더 세 개.

접점 (직전 스텝 재사용 + 이후 모든 스텝이 import 한다):
- 같은 디렉토리의 step02_prepare_dataset.py 에서 TRAIN_FILE, VAL_FILE, TEST_FILE 을 import 한다.
- 상수 PAD_TOKEN_ID = 50256, BATCH_SIZE = 8.
- 클래스 SpamDataset(csv_file, tokenizer, max_length=None, pad_token_id=50256) — torch.utils.data.Dataset 을 상속한다. CSV 의 Text 열을 tokenizer 로 토큰 ID 리스트로
  바꾸고, max_length 가 None 이면 가장 긴 리스트의 길이를 속성 max_length 로 삼고, 아니면 그 길이로 리스트를 자른다. 그 뒤 모든 리스트를 max_length 까지
  pad_token_id 로 채운다. 항목은 (토큰 ID 정수 텐서 (max_length,), 레이블 정수 텐서 (0차원)) 순서쌍, 길이는 행 수.
- 함수 build_dataloaders() — tiktoken 의 gpt2 토크나이저로 훈련 데이터셋을 max_length=None 으로, 검증·테스트 데이터셋을 훈련의 max_length 로 만든 뒤,
  파이토치 시드 123 고정 직후 DataLoader 세 개를 만든다 — 배치 크기 8, 훈련만 섞고 자투리를 버리며, 검증·테스트는 섞지 않고 자투리를 버리지 않는다.
  (훈련 로더, 검증 로더, 테스트 로더, 훈련의 max_length) 를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 패딩 토큰 ID: [50256] 처럼 문자열 <| + endoftext + |> 를 특수 토큰 허용으로 인코딩한 결과
- 가장 긴 훈련 메시지 토큰 수: 120 처럼 build_dataloaders() 가 돌려준 max_length
- 입력 배치 모양: torch.Size([8, 120]) 처럼 훈련 로더 첫 배치의 입력 모양
- 레이블 배치 모양: torch.Size([8]) 처럼 그 배치의 레이블 모양
- 훈련 배치 수: 130, 검증 배치 수: 19, 테스트 배치 수: 38 처럼 세 줄

지켜야 할 것:
- torch, tiktoken, pandas 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
패딩 토큰 ID: [50256]
가장 긴 훈련 메시지 토큰 수: 120
입력 배치 모양: torch.Size([8, 120])
레이블 배치 모양: torch.Size([8])
훈련 배치 수: 130
검증 배치 수: 19
테스트 배치 수: 38
[OK] 7줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch06/step03_dataloader.py, 핵심):

class SpamDataset(Dataset):
    def __init__(self, csv_file, tokenizer, max_length=None, pad_token_id=PAD_TOKEN_ID):
        self.data = pd.read_csv(csv_file)
        encoded = [tokenizer.encode(text) for text in self.data["Text"]]
        self.max_length = max(len(ids) for ids in encoded) if max_length is None else max_length
        encoded = [ids[:self.max_length] for ids in encoded]                                   # 자르기
        self.encoded_texts = [ids + [pad_token_id] * (self.max_length - len(ids)) for ids in encoded]   # 패딩

    def __getitem__(self, index):
        return (torch.tensor(self.encoded_texts[index], dtype=torch.long),
                torch.tensor(int(self.data.iloc[index]["Label"]), dtype=torch.long))

생성자에서 인코딩·자르기·패딩을 다 해 두고 __getitem__ 은 텐서로 바꿔 내기만 합니다 — 2장 step06 데이터셋과 같은 설계입니다. max_length=None 일 때만 "가장 긴 것" 을 재는 분기가 훈련 세트와 나머지를 구분합니다. build_dataloaders 는 시드 123 직후 DataLoader 셋을 만드는데, 시드는 로더를 만들 때가 아니라 훈련 로더를 돌기 시작할 때 소비됩니다 — 그래서 step06 은 재기 직전에 시드를 다시 고정합니다.

실측

hermes(qwen3.8:27b)의 데이터셋은 레이블을 생성자에서 정수 리스트로 미리 빼 두고(기준 구현은 iloc 로 매번 읽음) 자르기·패딩을 리스트 내포 한 줄로 합쳤습니다. 흥미로운 부분은 자체 점검입니다 — hermes 가 스스로 만든 검증 스크립트가 "패딩이 있는 행" 을 잘못 골라 FAIL 을 한 번 보고, 산출물이 아니라 검증 스크립트 쪽을 고쳐 통과시킨 뒤 응답했습니다(응답 기록에 그 진단이 남아 있습니다). 산출물은 처음부터 정답이었고 우리 검증기는 7/7. 2.5분에 파일, 10분에 종료, 첫 시도 통과.

step04. 사전훈련 모델 불러오기 — 5장 이식 함수를 5장 밖에서 쓴다

무엇을 — 5장이 내려받은 GPT-2 124M 가중치를 5장 load_weights_into_gpt 로 4장 GPTModel 에 넣어 돌려주는 build_pretrained_model(), 그리고 분류 전 모델에 스팸 질문을 던져 보는 실험
왜 — 미세조정은 "이미 언어를 아는 모델"에서 시작한다. 5장 step09 가 그 모델이고, 이 스텝은 그것을 부품으로 꺼내 쓴다. 질문을 던져 보면 왜 그대로는 분류기가 못 되는지 보인다
맥락 — 4장 step02 가 3장을, 5장 step01 이 4장을 가져온 것과 같은 챕터 경계 import. step05 가 이 모델의 출력층을 갈아 끼운다
기대 결과 — 파라미터 163,037,184, Every effort moves you forward. … (5장과 같은 문장), 스팸 질문에는 답 대신 질문을 되풀이
hermes에게 전달되는 사양 (prompts/ch06/step04-load-pretrained.md)
step04_load_pretrained.py 라는 파이썬 파일을 만들어줘.

만들 것: 5장이 내려받은 GPT-2 124M 가중치를 넣은 모델을 돌려주는 함수와, 그 모델에 문장 생성과 스팸 질문을 시켜 보는 스크립트.

접점 (5장·4장 산출물 재사용 + step05 가 import 한다):
- 5장 작업 디렉토리는 이 파일이 있는 디렉토리의 옆 디렉토리 ../ch05 다. 그 디렉토리를 import 검색 경로에 추가한 뒤 step09_load_gpt2_weights.py 의
  load_weights_into_gpt(gpt, params), step08_download_gpt2.py 의 WEIGHTS_FILE, step01_text_utils.py 의 text_to_token_ids·token_ids_to_text 를 import 한다.
  5장 파일들이 ../ch04·../ch02 를 스스로 경로에 추가하므로 step01_gpt_config 의 GPT_CONFIG_124M, step07_gpt_model 의 GPTModel, step08_generate 의
  generate_text_simple 도 import 할 수 있다. 모델·이식·생성을 새로 구현하지 않는다.
- 상수 WEIGHTS_PATH — ../ch05 디렉토리 안의 WEIGHTS_FILE 경로 (파일 위치 기준). 가중치를 다시 내려받지 않는다.
- 딕셔너리 BASE_CONFIG — GPT_CONFIG_124M 의 복사본에 drop_rate 를 0.0 으로, qkv_bias 를 True 로 넣은 것.
- 함수 build_pretrained_model() — GPTModel(BASE_CONFIG) 에 safetensors.torch 의 load_file 로 읽은 가중치를 load_weights_into_gpt 로 넣고 평가 모드로 바꿔
  (모델, tiktoken gpt2 토크나이저) 를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 모델을 만들고 ① Every effort moves you 뒤에 새 토큰 15개를 탐욕적으로 생성하고(context_size 는 설정의 context_length, 줄바꿈은 공백으로),
② 문자열 Is the following text 'spam'? Answer with 'yes' or 'no': 'You are a winner you have been specially selected to receive $1000 cash or a $2000 award.'
뒤에 새 토큰 23개를 탐욕적으로 생성한다(줄바꿈은 공백으로).

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 파라미터 개수: 163,037,184 처럼 모델의 모든 파라미터 원소 수의 합을 천 단위 콤마로
- 생성 1: Every effort moves you forward.  The first step is to understand the importance of your work 처럼 ① 의 전체 문자열
- 생성 2: Is the following text 'spam'? ... 처럼 ② 의 전체 문자열

지켜야 할 것:
- torch, tiktoken, safetensors 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
파라미터 개수: 163,037,184
생성 1: Every effort moves you forward.  The first step is to understand the importance of your work
생성 2: Is the following text 'spam'? Answer with 'yes' or 'no': 'You are a winner you have been specially selected to receive $1000 cash or a $2000 award.'  The following text 'spam'? Answer with 'yes' or 'no': 'You are a winner
[OK] 3줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch06/step04_load_pretrained.py, 핵심):

CH05_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "ch05")
sys.path.append(CH05_DIR)
from step09_load_gpt2_weights import load_weights_into_gpt
from step08_download_gpt2 import WEIGHTS_FILE

WEIGHTS_PATH = os.path.join(CH05_DIR, WEIGHTS_FILE)              # 5장이 내려받은 파일 그대로
BASE_CONFIG = dict(GPT_CONFIG_124M, drop_rate=0.0, qkv_bias=True)   # 드롭아웃 끔, 편향 켬

def build_pretrained_model():
    model = GPTModel(BASE_CONFIG)
    load_weights_into_gpt(model, load_file(WEIGHTS_PATH))
    model.eval()
    return model, tiktoken.get_encoding("gpt2")

5장 step09 의 __main__ 블록이 하던 일을 함수 하나로 옮겼을 뿐입니다. 경로를 추가하는 다섯 줄은 4장 step02·5장 step01 과 같은 관용구 — 세 번째 보는 것이니 눈에 익었을 겁니다.

실측

hermes(qwen3.8:27b)는 5장 step01 때와 같은 버릇대로 pathlib 로 경로를 만들어 sys.path.insert(0, …) 했고, 설정 복사는 {GPT_CONFIG_124M, "drop_rate": 0.0, "qkv_bias": True}, 가중치 경로는 Path 객체 그대로 load_file 에 넘겼습니다. 여기서 import 된 load_weights_into_gpt 는 5장에서 hermes 가 만든** 학생 생성본(.data.copy_ 방식)입니다 — 학생 코드가 다음 장의 부품으로 기능한 첫 사례입니다. 3분에 파일, 7분에 종료, 첫 시도 통과.

step05. 분류 헤드 — 출력층을 50,257 에서 2 로 갈아 끼우고 대부분을 얼린다

무엇을 — 모든 파라미터를 얼린 뒤 out_head 를 (768 → 2) 선형층으로 바꾸고, 마지막 트랜스포머 블록과 final_norm 만 훈련되게 되돌리는 build_classifier_model()
왜 — 스팸/정상은 두 갈래 문제다. 어휘 50,257 개 점수 대신 두 개 점수를 내게 하고, 1억 2,400만 개 중 700만 개(5.7%)만 훈련하면 CPU 로도 몇 분이면 된다
맥락 — step04 의 모델을 받아 손본다. 4장 사양이 out_head·trf_blocks·final_norm 이라는 이름을 못 박았기 때문에 이 수술이 가능하다. step06·07 이 이 모델을 훈련한다
기대 결과 — 전체 124,441,346 / 훈련 가능 7,090,946, Do you have time 의 마지막 토큰 로짓 tensor([[-3.5983, 3.9902]]) — 원서와 같은 값
hermes에게 전달되는 사양 (prompts/ch06/step05-classifier-head.md)
step05_classifier_head.py 라는 파이썬 파일을 만들어줘.

만들 것: 사전훈련 모델의 출력층을 두 갈래 분류용으로 바꾸고 일부만 훈련되게 만드는 함수와, 그 모델의 출력을 확인하는 스크립트.

접점 (직전 스텝 재사용 + step06·07·08 이 import 한다):
- 같은 디렉토리의 step04_load_pretrained.py 에서 build_pretrained_model, BASE_CONFIG 를 import 한다.
- 상수 NUM_CLASSES = 2.
- 함수 build_classifier_model() — build_pretrained_model() 의 모델을 받아 모든 파라미터의 requires_grad 를 False 로 만든 뒤, 파이토치 시드 123 고정 직후
  out_head 를 새 선형층 (BASE_CONFIG 의 emb_dim → NUM_CLASSES, 편향 있음) 으로 바꾼다. 그 다음 trf_blocks 의 마지막 블록과 final_norm 의 파라미터만
  requires_grad 를 True 로 되돌린다 (새 출력층은 만들 때부터 True). (모델, 토크나이저) 를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 모델을 만들고 Do you have time 을 토큰 ID 로 바꿔 (1, 4) 텐서로 만든 뒤 기울기 기록 없이 모델에 넣는다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다. 텐서는 print("라벨:", 텐서) 꼴로, f-문자열 보간은 쓰지 않는다):
- 전체 파라미터 개수: 124,441,346 처럼 모든 파라미터 원소 수의 합을 천 단위 콤마로
- 훈련 가능 파라미터 개수: 7,090,946 처럼 requires_grad 가 True 인 파라미터 원소 수의 합을 천 단위 콤마로
- 입력: tensor([[5211,  345,  423,  640]]) 처럼 입력 텐서
- 출력 모양: torch.Size([1, 4, 2]) 처럼 출력의 모양
- 마지막 토큰 로짓: tensor([[-3.5983,  3.9902]]) 처럼 출력의 마지막 위치 (1, 2) 텐서
- 예측 레이블: 1 처럼 그 로짓에서 값이 큰 쪽의 번호

지켜야 할 것:
- torch 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
전체 파라미터 개수: 124,441,346
훈련 가능 파라미터 개수: 7,090,946
입력: tensor([[5211,  345,  423,  640]])
출력 모양: torch.Size([1, 4, 2])
마지막 토큰 로짓: tensor([[-3.5983,  3.9902]])
예측 레이블: 1
[OK] 6줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch06/step05_classifier_head.py, 핵심):

def build_classifier_model():
    model, tokenizer = build_pretrained_model()
    for param in model.parameters():
        param.requires_grad = False                                        # ① 전부 얼리기
    torch.manual_seed(123)
    model.out_head = torch.nn.Linear(BASE_CONFIG["emb_dim"], NUM_CLASSES)   # ② 새 출력층 (훈련 가능)
    for param in model.trf_blocks[-1].parameters():
        param.requires_grad = True                                         # ③ 마지막 블록 되돌리기
    for param in model.final_norm.parameters():
        param.requires_grad = True                                         #    최종 정규화 되돌리기
    return model, tokenizer

속성에 새 모듈을 대입하면(model.out_head = …) 파이토치가 옛 층을 버리고 새 층을 등록합니다 — 5장 step09 가 파라미터를 대입한 것과 같은 원리입니다. trf_blocks[-1] 은 nn.Sequential 의 마지막 항목 — 4장이 블록을 Sequential 로 쌓았기 때문에 인덱스로 집을 수 있습니다.

실측

hermes(qwen3.8:27b)의 수술 순서는 기준 구현과 같고, 새 출력층의 requires_grad 를 굳이 다시 True 로 켜는 줄이 둘 있습니다 — 새 층은 원래 True 라 불필요하지만 무해합니다. 로짓 [-3.5983, 3.9902] 가 원서·기준 구현과 같으니 시드 위치(얼린 직후, 층 직전)를 지킨 것입니다. 이 스텝은 파일을 8.5분에 만든 뒤 자체 점검 중 15분 상한에 걸렸습니다 — 하네스가 그때까지의 파일로 검증을 진행해 통과. 느려진 원인의 일부는 제작자에게 있습니다: 같은 시간에 7장 채점 초안이 같은 Ollama 서버의 같은 모델을 110번 부르고 있었습니다. 검증 서버를 나눠 쓰면 hermes 의 응답 시간이 늘어난다는, 하네스 운용의 실측입니다.

step06. 정확도와 손실 — 마지막 토큰으로 채점하는 세 함수와 훈련 전 기준선

무엇을 — 마지막 토큰 로짓의 argmax 가 레이블과 맞은 비율을 재는 calc_accuracy_loader, 배치·로더의 교차 엔트로피 손실 calc_loss_batch·calc_loss_loader, 그리고 훈련 전 분류기의 점수
왜 — 5장의 손실 함수는 (배치×토큰) 위치 전부를 채점했지만 분류는 마지막 토큰 하나만 채점한다. 훈련 전 정확도가 동전 던지기(약 50%)라는 것을 숫자로 확인해 두면 훈련의 효과가 보인다
맥락 — 5장 step03 의 손실 함수를 분류용으로 다시 쓴다. step07 훈련 루프가 세 함수를 import 한다
기대 결과 — 훈련 전 정확도 46.25% / 45.00% / 48.75%, 손실 2.453 / 2.583 / 2.322 — 원서와 같은 값
hermes에게 전달되는 사양 (prompts/ch06/step06-pretrain-eval.md)
step06_pretrain_eval.py 라는 파이썬 파일을 만들어줘.

만들 것: 분류 모델의 정확도와 손실을 재는 함수 세 개와, 훈련 전 모델의 점수를 확인하는 스크립트.

접점 (직전 스텝 재사용 + step07 이 import 한다):
- 같은 디렉토리의 step03_dataloader.py 에서 build_dataloaders 를, step05_classifier_head.py 에서 build_classifier_model 을 import 한다.
- 함수 calc_accuracy_loader(loader, model, device, num_batches=None) — 평가 모드·기울기 기록 없이 로더의 앞 num_batches 개 배치(None 이면 전부, 로더 길이보다 크면
  로더 길이만큼)에서 모델 출력의 **마지막 위치 로짓** (배치, 2) 의 argmax 가 레이블과 같은 예제 수를 세어, 본 예제 수로 나눈 float 을 반환한다.
- 함수 calc_loss_batch(inputs, labels, model, device) — 두 텐서를 device 로 옮기고 마지막 위치 로짓 (배치, 2) 과 레이블 (배치,) 의 교차 엔트로피 손실(0차원 텐서)을 반환한다.
- 함수 calc_loss_loader(loader, model, device, num_batches=None) — 로더의 앞 num_batches 개 배치(None 이면 전부, 로더 길이보다 크면 로더 길이만큼)의 배치 손실
  평균(float)을 반환한다. 배치가 없으면 nan.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 장치는 CPU. build_dataloaders() 로 세 로더를 만들고, 그 다음 build_classifier_model() 로 모델을 만든다. 파이토치 시드 123 고정 직후 **이 순서로**: 훈련·검증·테스트
로더의 앞 10 배치 정확도를 재고, 이어서 기울기 기록 없이 훈련·검증·테스트 로더의 앞 5 배치 손실을 잰다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 훈련 정확도: 46.25%, 검증 정확도: 45.00%, 테스트 정확도: 48.75% 처럼 정확도 × 100 을 소수점 둘째 자리까지 + %
- 훈련 손실: 2.453, 검증 손실: 2.583, 테스트 손실: 2.322 처럼 손실을 소수점 셋째 자리까지

지켜야 할 것:
- torch 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
훈련 정확도: 46.25%
검증 정확도: 45.00%
테스트 정확도: 48.75%
훈련 손실: 2.453
검증 손실: 2.583
테스트 손실: 2.322
[OK] 6줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch06/step06_pretrain_eval.py, 핵심):

def calc_accuracy_loader(loader, model, device, num_batches=None):
    model.eval()
    correct, total = 0, 0
    limit = len(loader) if num_batches is None else min(num_batches, len(loader))
    for i, (inputs, labels) in enumerate(loader):
        if i >= limit:
            break
        with torch.no_grad():
            logits = model(inputs.to(device))[:, -1, :]          # 마지막 토큰의 로짓 (배치, 2)
        correct += (torch.argmax(logits, dim=-1) == labels.to(device)).sum().item()
        total += labels.shape[0]
    return correct / total

def calc_loss_batch(inputs, labels, model, device):
    logits = model(inputs.to(device))[:, -1, :]
    return torch.nn.functional.cross_entropy(logits, labels.to(device))

5장 step03 과 다른 곳은 [:, -1, :] 한 조각뿐입니다 — (배치, 120, 2) 에서 마지막 위치를 골라 (배치, 2) 로 만듭니다. cross_entropy 는 "예측 행 하나에 정답 하나" 꼴을 받으므로 5장처럼 펼칠 필요가 없습니다(이미 그 꼴). calc_loss_loader 는 5장과 완전히 같은 골격입니다.

실측

hermes(qwen3.8:27b)의 세 함수는 사양대로였고 변수명이 한글(맞춘_수, 본_수, 예측)입니다. 재는 순서와 시드 위치를 지켜 정확도 46.25 / 45.00 / 48.75% 가 원서·기준 구현과 같습니다 — 순서를 바꿨다면 앞 10 배치가 다른 메시지가 되어 다른 값이 나왔을 것입니다. 파일은 7.5분에 나왔고 자체 점검 중 다시 15분 상한 — step05 와 같은 서버 공유 사정입니다. 하네스는 그때까지의 파일로 검증해 6/6 통과.

step07. 분류 미세조정 — 650 번의 갱신으로 동전 던지기에서 96% 로

무엇을 — 5장 훈련 루프의 분류판 train_classifier_simple: 5 에포크 동안 마지막 블록·최종 정규화·새 출력층만 갱신하며 50 스텝마다 손실을, 에포크 끝마다 정확도를 기록하고, 끝나면 분류기를 저장
왜 — 5장에서 배운 루프가 "무엇을 채점하느냐" 만 바꿔 다른 일을 한다. 얼린 파라미터 덕에 역전파가 마지막 블록까지만 내려가 CPU 로 6분이면 끝난다
맥락 — step06 의 세 함수와 step03·05 를 import 한다. step08 이 여기서 저장한 review_classifier.pth 를 되살린다
기대 결과 — 손실 2.15 → 0.08, 정확도 70% → 100%(앞 5 배치), 최종 정확도 훈련 97.21% / 검증 97.32% / 테스트 95.67% (기준 구현 실측 — 원서와 같은 값)

실행은 HERMES_TIMEOUT=2400 bash harness/run_loop.sh ch06 prompts/ch06/step07-finetune.md

hermes에게 전달되는 사양 (prompts/ch06/step07-finetune.md)
step07_finetune.py 라는 파이썬 파일을 만들어줘.

만들 것: 분류 모델을 훈련하는 루프와 보조 함수, 그리고 훈련을 실행해 최종 정확도를 재고 분류기를 저장하는 스크립트.

접점 (직전 스텝들 재사용 + step08 이 import 한다):
- 같은 디렉토리의 step03_dataloader.py 에서 build_dataloaders 를, step05_classifier_head.py 에서 build_classifier_model 을, step06_pretrain_eval.py 에서
  calc_accuracy_loader, calc_loss_batch, calc_loss_loader 를 import 한다. 손실·정확도·모델을 새로 구현하지 않는다.
- 상수 CLASSIFIER_FILE = "review_classifier.pth".
- 함수 evaluate_model(model, train_loader, val_loader, device, eval_iter) — 평가 모드·기울기 기록 없이 두 로더 앞 eval_iter 배치의 손실을 재고 훈련 모드로 되돌려
  (훈련 손실, 검증 손실) 을 반환한다.
- 함수 train_classifier_simple(model, train_loader, val_loader, optimizer, device, num_epochs, eval_freq, eval_iter) — 에포크마다 훈련 로더의 배치를 차례로 꺼내
  "기울기 초기화 → 배치 손실 → 역전파 → 갱신" 을 하고 본 예제 수(배치의 행 수 누적)를 센다. 전역 스텝은 첫 갱신을 0 으로 해서 1 씩 늘리며, 갱신 직후 스텝이 eval_freq 의
  배수이면 evaluate_model 로 평가해 두 손실을 목록에 기록하고 아래 형식으로 출력한다. 에포크가 끝날 때마다 훈련·검증 로더 앞 eval_iter 배치의 정확도를 목록에 기록하고
  아래 형식으로 출력한다. 반환은 (훈련 손실 목록, 검증 손실 목록, 훈련 정확도 목록, 검증 정확도 목록, 본 예제 수).
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 장치는 CPU. build_dataloaders() 로 세 로더를, build_classifier_model() 로 모델을 만든다. 파이토치 시드 123 고정 직후 AdamW(학습률 0.00005, 가중치 감쇠 0.1)를
만들고 5 에포크, eval_freq 50, eval_iter 5 로 훈련한다. 끝나면 세 로더 전체의 정확도를 재고 모델의 state_dict 를 CLASSIFIER_FILE 에 torch.save 로 저장한다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 평가할 때마다 에포크 1 (스텝 000000): 훈련 손실 2.153, 검증 손실 2.392 처럼 — 스텝은 여섯 자리 0 채움, 손실은 소수점 셋째 자리까지
- 에포크가 끝날 때마다 에포크 1 정확도: 훈련 70.00%, 검증 72.50% 처럼 — 정확도 × 100 을 소수점 둘째 자리까지
- 훈련이 끝나면 훈련 시간(분): 5.6 처럼 소수점 첫째 자리까지
- 평가 횟수: 13 처럼 훈련 손실 목록의 길이
- 본 예제 수: 5200 처럼 반환된 본 예제 수
- 최종 훈련 정확도: 97.21%, 최종 검증 정확도: 97.32%, 최종 테스트 정확도: 95.67% 처럼 세 로더 전체의 정확도
- 테스트 정확도가 90% 이상: 예 처럼 참이면 예, 아니면 아니오
- 저장: review_classifier.pth 처럼 저장한 파일 이름

지켜야 할 것:
- torch 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 그래프 저장, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 기준 구현 — 중간 줄은 생략) ---
에포크 1 (스텝 000000): 훈련 손실 2.153, 검증 손실 2.392
에포크 1 (스텝 000050): 훈련 손실 0.617, 검증 손실 0.637
에포크 1 (스텝 000100): 훈련 손실 0.523, 검증 손실 0.557
에포크 1 정확도: 훈련 70.00%, 검증 72.50%
...
에포크 5 (스텝 000600): 훈련 손실 0.083, 검증 손실 0.074
에포크 5 정확도: 훈련 100.00%, 검증 97.50%
훈련 시간(분): 5.6
평가 횟수: 13
본 예제 수: 5200
최종 훈련 정확도: 97.21%
최종 검증 정확도: 97.32%
최종 테스트 정확도: 95.67%
테스트 정확도가 90% 이상: 예
저장: review_classifier.pth
[OK] 7줄 전부 일치 (손실·정확도 값은 대조하지 않음)   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch06/step07_finetune.py, 핵심):

def train_classifier_simple(model, train_loader, val_loader, optimizer, device, num_epochs, eval_freq, eval_iter):
    train_losses, val_losses, train_accs, val_accs = [], [], [], []
    examples_seen, step = 0, -1
    for epoch in range(num_epochs):
        model.train()
        for inputs, labels in train_loader:
            optimizer.zero_grad()
            loss = calc_loss_batch(inputs, labels, model, device)      # 마지막 토큰 손실
            loss.backward()                                            # 얼린 층은 건너뛴다
            optimizer.step()
            examples_seen += inputs.shape[0]                           # 5장의 토큰 수 대신 예제 수
            step += 1
            if step % eval_freq == 0:
                train_loss, val_loss = evaluate_model(model, train_loader, val_loader, device, eval_iter)
                train_losses.append(train_loss); val_losses.append(val_loss)
                print(f"에포크 {epoch + 1} (스텝 {step:06d}): 훈련 손실 {train_loss:.3f}, 검증 손실 {val_loss:.3f}")
        train_acc = calc_accuracy_loader(train_loader, model, device, num_batches=eval_iter)   # 5장의 샘플 대신 정확도
        val_acc = calc_accuracy_loader(val_loader, model, device, num_batches=eval_iter)
        train_accs.append(train_acc); val_accs.append(val_acc)
        print(f"에포크 {epoch + 1} 정확도: 훈련 {train_acc * 100:.2f}%, 검증 {val_acc * 100:.2f}%")
    return train_losses, val_losses, train_accs, val_accs, examples_seen

5장 step04 의 루프에서 바뀐 곳은 주석을 단 세 줄뿐입니다. optimizer 는 model.parameters() 전부를 받지만 requires_grad 가 False 인 파라미터는 기울기가 None 이라 AdamW 가 건너뜁니다 — 얼리기의 효과가 옵티마이저까지 자동으로 이어집니다.

실측

hermes(qwen3.8:27b)의 루프는 기준 구현과 같은 골격(변수명은 한글 — 에포크, 전역_스텝, 훈련_손실)이었고, 시드 위치를 지킨 결과 손실 궤적·정확도·최종 정확도 97.21 / 97.32 / 95.67% 가 기준 구현과 글자까지 같았습니다. 5장 step04 와 같은 현상 — 불변식으로 계약했지만 시드 자리를 다 적어 두면 값까지 같아집니다. 파일 10분, 자체 점검 훈련 1회 포함 23분에 종료(상한 40분), 검증 7분, 첫 시도 통과.

step08. 새 문자 판정 — 분류기를 되살려 쓰는 함수

무엇을 — 저장한 분류기를 되살려 새 문자 메시지를 "스팸" / "정상" 으로 판정하는 classify_review(text, model, tokenizer, device, max_length, pad_token_id=50256)
왜 — 훈련의 결과물은 정확도 숫자가 아니라 쓸 수 있는 함수다. 훈련 때와 같은 전처리(자르기·패딩·마지막 토큰)를 그대로 해야 같은 답이 나온다
맥락 — step07 의 review_classifier.pth 와 step05 의 모델 구조, step03 의 max_length(120) 를 쓴다. 6장의 마지막 스텝
기대 결과 — 당첨 문자는 스팸, 저녁 약속 문자는 정상
hermes에게 전달되는 사양 (prompts/ch06/step08-classify.md)
step08_classify.py 라는 파이썬 파일을 만들어줘.

만들 것: 저장한 분류기를 되살려 문자 메시지 한 건을 판정하는 함수와, 두 문자를 판정해 보는 스크립트.

접점 (직전 스텝들 재사용):
- 같은 디렉토리의 step03_dataloader.py 에서 build_dataloaders, PAD_TOKEN_ID 를, step05_classifier_head.py 에서 build_classifier_model 을,
  step07_finetune.py 에서 CLASSIFIER_FILE 을 import 한다.
- 함수 classify_review(text, model, tokenizer, device, max_length, pad_token_id=PAD_TOKEN_ID) — 모델을 평가 모드로 두고, 문자열을 토큰 ID 로 바꿔
  max_length 와 모델 위치 임베딩 행 수 중 작은 쪽으로 자르고 pad_token_id 로 max_length 까지 채운 뒤 (1, max_length) 텐서로 만들어 기울기 기록 없이 모델에 넣고,
  마지막 위치 로짓의 argmax 가 1 이면 스팸, 아니면 정상 을 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 장치는 CPU. build_classifier_model() 의 모델에 CLASSIFIER_FILE 의 state_dict 를 넣는다(torch.load 에 map_location 과 weights_only=True). build_dataloaders() 가
돌려준 max_length 로 다음 두 문자를 판정한다:
- You are a winner you have been specially selected to receive $1000 cash or a $2000 award.
- Hey, just wanted to check if we're still on for dinner tonight? Let me know!

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 판정 길이: 120 처럼 max_length
- 문자 1: 스팸 처럼 첫 문자의 판정
- 문자 2: 정상 처럼 둘째 문자의 판정

지켜야 할 것:
- torch 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다. 분류기 파일을 새로 저장하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
판정 길이: 120
문자 1: 스팸
문자 2: 정상
[OK] 3줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch06/step08_classify.py, 핵심):

def classify_review(text, model, tokenizer, device, max_length, pad_token_id=PAD_TOKEN_ID):
    model.eval()
    ids = tokenizer.encode(text)
    context_length = model.pos_emb.weight.shape[0]
    ids = ids[:min(max_length, context_length)]                       # 자르기 — step03 과 같은 규칙
    ids = ids + [pad_token_id] * (max_length - len(ids))              # 패딩 — step03 과 같은 규칙
    inputs = torch.tensor(ids, device=device).unsqueeze(0)            # (1, max_length)
    with torch.no_grad():
        logits = model(inputs)[:, -1, :]                              # 마지막 토큰 — step06 과 같은 규칙
    return "스팸" if torch.argmax(logits, dim=-1).item() == 1 else "정상"

step03 의 전처리와 step06 의 채점을 한 함수에 모은 것입니다. model.load_state_dict(torch.load(…, weights_only=True)) 로 되살리는 부분은 5장 step06 과 같습니다 — 옵티마이저는 더 훈련하지 않으므로 저장하지 않았습니다.

실측

hermes(qwen3.8:27b)의 판정 함수는 사양의 네 단계를 그대로 따랐고, 문맥 길이를 model.pos_emb.num_embeddings 로, 마지막 로짓을 [0, -1] 로 집었습니다 — 기준 구현의 weight.shape[0] 과 [:, -1, :] 와 같은 뜻입니다. 변수명 한글(입력, 로짓_마지막, max_길이). 4.5분에 파일, 8분에 종료, 첫 시도 통과. 이로써 6장 여덟 스텝이 전부 첫 시도에 통과했고 사양 수정은 0회입니다. 두 스텝(05·06)이 15분 상한에 걸렸지만 파일은 정답이었고, 원인의 일부는 제작자가 같은 서버로 7장 채점을 돌린 데 있었습니다.

용어 정리

용어원어(약어)한 줄 정의
미세조정fine-tuning사전훈련된 모델에 특정한 일을 가르치는 추가 훈련. 무작위 초기값이 아니라 아는 모델에서 출발한다
분류 미세조정classification fine-tuning정해진 갈래 중 하나를 고르게 하는 미세조정. 출력층을 갈래 수로 바꾼다
지시 미세조정instruction fine-tuning지시를 읽고 문장으로 답하게 하는 미세조정(7장). 출력층은 그대로, 데이터가 바뀐다
레이블label예제마다 붙은 정답(ham/spam → 0/1)
클래스class분류의 갈래. 이 장은 두 개(정상 0, 스팸 1)
pandas 데이터프레임DataFrame행과 열이 있는 표. 읽기·세기·고르기·쓰기에 쓴다
불균형 데이터imbalanced data클래스 비율이 크게 다른 데이터(ham 87% : spam 13%). 게으른 분류기를 만든다
언더샘플링undersampling다수 클래스를 소수 클래스 수만큼 무작위로 줄여 균형을 맞추는 처방
random_staterandom_statepandas 의 시드. 고르기와 섞기를 재현 가능하게 한다
테스트 세트test set훈련 중 한 번도 보지 않고 마지막에 한 번만 재는 데이터(20%)
패딩padding짧은 열의 뒤를 특별한 토큰으로 채워 길이를 맞추는 일
패딩 토큰pad token채우기에 쓰는 토큰. GPT-2 의 <|endoftext|>(50256) 을 빌려 쓴다
max_lengthmax_length배치의 공통 길이. 훈련 세트의 가장 긴 메시지(120)
자르기truncationmax_length 보다 긴 열의 뒤를 버리는 일
collatecollate항목들을 배치 텐서로 묶는 단계. 이 장은 길이가 같아 기본 동작으로 충분하다(7장은 직접 쓴다)
얼리기freezingrequires_grad = False — 기울기를 계산하지 않고 갱신하지 않는다
되돌리기unfreezing얼린 파라미터를 다시 훈련 가능으로 만드는 일
분류 헤드classification head출력층을 바꿔 끼운 새 선형층(768 → 2)
훈련 가능 파라미터trainable parametersrequires_grad 가 True 인 파라미터의 원소 수(7,090,946 — 5.7%)
마지막 토큰 채점last-token readout인과 마스크 때문에 문장 전체를 본 유일한 위치인 마지막 토큰의 로짓으로 판정
정확도accuracy맞힌 예제 수 ÷ 본 예제 수
기준선baseline훈련 전 점수(46%). 훈련의 효과를 재는 출발점
과신overconfidence틀린 쪽에 큰 확률을 주는 상태. 손실 2.45 가 반반(0.693)보다 큰 이유
에포크epoch훈련 로더 한 바퀴(130 배치)
학습률learning rate갱신 걸음 크기. 미세조정은 5e-5 로 사전훈련(4e-4)보다 작게
일반화generalization훈련에 없던 예제에서도 맞히는 능력. 테스트 정확도 95.67%
state_dictstate_dict"이름 → 텐서" 딕셔너리. 분류기 저장·적재에 쓴다(5장)
weights_onlyweights_onlytorch.load 가 텐서만 읽게 하는 안전 옵션
requires_gradrequires_grad파라미터마다 붙은 "기울기를 계산·갱신할지" 표시. False 면 얼린 것
옵티마이저의 건너뛰기optimizer skips frozen params기울기가 None 인(얼린) 파라미터는 AdamW 가 갱신하지 않는다 — 얼리기가 옵티마이저까지 이어지는 이유
드롭아웃 0drop_rate 0.0미세조정에서는 드롭아웃을 끈다. 데이터가 작아 효과가 뚜렷하지 않다는 원서의 선택
자투리 배치last partial batch배치 크기에 못 미치는 마지막 배치. 훈련은 버리고(drop_last) 평가는 남긴다

정리

  • 미세조정은 아는 모델에서 출발한다. 분류 미세조정은 출력층을 갈래 수(2)로 바꾸고 마지막 블록·정규화·새 층(5.7%)만 훈련한다 — CPU 로 6분.
  • 데이터는 5,572 건을 747 대 747 로 균형 맞추고 70/10/20 으로 나눠, 가장 긴 메시지(120)에 맞춰 패딩한다. 시드는 pandas 와 파이토치 두 곳에 있다.
  • 분류는 마지막 토큰의 로짓으로 채점한다 — 인과 마스크 때문에 마지막 위치만이 문장 전체를 봤다. 훈련 전 정확도 46%, 손실 2.45(과신).
  • 5장 훈련 루프의 골격에 손실 함수와 기록만 바꿔 650 번 갱신하면 손실 2.15 → 0.08, 정확도 97.21 / 97.32 / 95.67% — 원서와 같은 값. 두 손실 곡선이 붙어 내려간다(과대적합 없음).
  • 판정 함수는 훈련과 같은 전처리(토큰화·자르기·패딩·마지막 토큰)를 거쳐야 한다. 낯선 두 문자를 스팸/정상으로 맞혔다.
  • 사양 설계: 4장 접점 이름으로 층을 지목하고, 시드 세 곳과 재는 순서를 못 박아 원서 값을 계약으로 삼았다. 훈련 스텝만 불변식(평가 횟수 13·예제 5,200·테스트 90% 이상).

다음 장 예고

7장에서는 같은 GPT-2 를 — 이번에는 중형(355M) 을 — 지시를 따르는 모델로 미세조정합니다. 출력층은 그대로 두고, "지시 + 입력 + 응답" 을 하나의 문자열로 이어 붙인 1,100 건으로 5장 훈련 루프를 그대로 돌립니다. 길이가 다른 문장을 배치로 묶는 collate 함수(패딩과 −100 가리기), 1.5GB 가중치를 24 층 모델에 넣는 5장 이식 함수의 재사용, 그리고 훈련한 모델의 답을 강좌의 공식 모델(qwen3.8:27b)이 0~100 점으로 채점하는 "LLM 심판" 까지 — 이 강좌의 마지막 장입니다.

Chapter 07 전 스텝 검증 완료 (9/9 첫 시도, 7장 사양 수정 0회)

7장. 지시 미세조정 — 지시를 읽고 답하는 모델, 그리고 그 답을 채점하는 모델

이 장에서 배우는 것

  • 지시(instruction)·입력(input)·출력(output) 세 필드의 데이터 1,100 건을 알파카(Alpaca) 프롬프트 형식의 한 문자열로 만드는 법
  • 길이가 다른 문장들을 배치로 묶는 collate 함수 — 패딩, 한 칸 민 타깃, 그리고 손실에서 빼는 −100
  • 5장 이식 함수가 설정 세 줄만 바꾸면 GPT-2 중형(355M, 24 층)에도 그대로 통한다는 것
  • 5장 훈련 루프를 수정 없이 가져와 2 에포크 미세조정하면 "지시를 되풀이하던" 모델이 답을 쓰기 시작하는 과정
  • 테스트 110 건의 응답을 강좌의 공식 모델(qwen3.8:27b)에게 0~100 점으로 채점시키는 "LLM 심판" — 결정적이지 않은 평가를 불변식으로 검증하는 법
  • 실습 9스텝: 기준 구현 9개, 사양 9개, 7장 끝에는 자기가 만든 챗봇을 다른 LLM 이 채점한 점수표가 손에 있다

들어가며 — 6장에서 7장으로

6장의 분류기는 한 가지 일을 정확히 합니다 — 스팸인지 아닌지. 하지만 "이 문장을 수동태로 바꿔" 나 "'complicated' 의 반대말은?" 같은 지시에는 답할 수 없습니다. 출력층이 두 갈래뿐이기 때문입니다. 지시 미세조정(instruction fine-tuning)은 출력층을 그대로 둔 채(어휘 50,257 개), "지시 + 답" 을 이어 붙인 문장으로 5장과 똑같이 다음 토큰을 예측하게 훈련합니다. 모델은 "### Response:" 뒤에 무엇이 와야 하는지를 배우고, 그 결과 어떤 지시든 읽고 문장으로 답하게 됩니다 — 챗봇의 원형입니다.

이 장은 강좌의 마지막 장이고, 새로 만드는 부품이 가장 적은 장입니다. 4장 모델, 5장 이식 함수·훈련 루프·손실 함수·generate, 6장에서 배운 패딩과 시드 규칙이 전부 그대로 쓰입니다. 새로 쓰는 것은 세 가지 — 지시를 문자열로 만드는 형식화, 길이가 다른 문장을 묶는 collate, 그리고 답을 채점하는 심판 호출입니다. 두 가지 어려움도 있습니다. 모델이 355M 으로 커져 CPU 훈련이 20분 가까이 걸리고, 답의 품질은 정확도처럼 숫자 하나로 재지지 않습니다. 후자를 다른 LLM 에게 채점시키는 것이 이 장의 마지막 반전입니다.

flowchart LR
    subgraph D["데이터 (7.1 ~ 7.4)"]
        direction TB
        D1["step01 1,100 건"] --> D2["step02 알파카 형식
935 / 55 / 110"] --> D3["step03 collate
패딩·−100"] --> D4["step04 로더
116 / 7 / 14"] end subgraph M["모델 (7.5 ~ 7.7)"] direction TB M1["step05 GPT-2 중형 355M
5장 이식 함수 재사용"] --> M2["step06 훈련 전
지시를 되풀이"] --> M3["step07 2 에포크
5장 루프 재사용"] end subgraph E["평가 (7.8 ~ 7.9)"] direction TB E1["step08 테스트 110 건 응답"] --> E2["step09 qwen3.8:27b 채점
평균 점수"] end D --> M --> E CH5["5장 load_weights_into_gpt
train_model_simple · generate
calc_loss_loader"] ==> M
그림 7-1. 7장 로드맵. 데이터 네 스텝, 모델 세 스텝, 평가 두 스텝. 5장 함수 네 개가 수정 없이 재사용된다.

7.1 지시 데이터 — 1,100 건의 instruction · input · output (step01)

지시 미세조정의 재료는 "지시와 모범 답" 쌍입니다. 원서가 준비한 1,100 건은 항목마다 세 필드를 갖습니다.

필드뜻항목 50항목 999
instruction무엇을 하라는 지시Identify the correct spelling of the following word.What is an antonym of 'complicated'?
input지시에 딸린 재료 (비어 있을 수 있음)Ocassion(비어 있음)
output모범 답The correct spelling is 'Occasion.'An antonym of 'complicated' is 'simple'.

표 7-1. 항목 두 개. 1,100 건 중 775 건(70%)은 입력이 비어 있다 — 지시만으로 충분한 과제(반대말, 정의, 번역)다.

문법 교정·반대말·수동태 변환·정의 같은 짧은 과제들이고, 답도 한두 문장입니다. 원서는 이 데이터를 GPT-4 로 만들었습니다 — 큰 모델의 답으로 작은 모델을 가르치는 방식은 실제 지시 미세조정에서도 흔합니다(데이터가 없으면 만든다).

7.2 알파카 형식과 분할 — 세 필드를 한 문자열로 (step02)

모델은 필드를 모릅니다. 토큰 열 하나만 읽습니다. 그래서 세 필드를 구분자가 있는 하나의 긴 문자열로 이어 붙입니다. 스탠퍼드 알파카 프로젝트가 정한 형식이 표준처럼 쓰입니다.

Below is an instruction that describes a task. Write a response that appropriately completes the request.

### Instruction:
Identify the correct spelling of the following word.

### Input:
Ocassion

### Response:
The correct spelling is 'Occasion.'

머리말 한 문장, ### Instruction: 절, (입력이 있으면) ### Input: 절, ### Response: 절 — 절 사이는 빈 줄 하나. 모델은 이 형식 자체를 배웁니다: "### Response: 다음 줄부터가 내가 쓸 차례" 라는 것을. 그래서 생성 때는 응답 절 앞까지만 주고(format_input) 모델이 이어 쓰게 합니다.

flowchart LR
    I["instruction
Identify the correct spelling …"] --> F["format_input(entry)"] P["input
Ocassion"] --> F F --> S["머리말
### Instruction: …
### Input: …"] S --> T["훈련 (step04): + ### Response: + output
→ 토큰 열 → 다음 토큰 예측"] S --> G["생성 (step06·08): + (모델이 이어 씀)
→ ### Response: 뒤를 잘라 냄"]
그림 7-2. 형식화의 두 용도. 훈련 때는 정답까지 붙인 전체를, 생성 때는 응답 절 앞까지만 준다. format_input 이 응답 절을 붙이지 않는 이유다.

분할은 앞 85% 훈련(935), 다음 10% 테스트(110), 나머지 검증(55) — 원서의 순서 그대로입니다. 섞지 않습니다(데이터가 이미 무작위 순서). 테스트 110 건이 7.8~7.9절의 채점 대상입니다.

손계산 ① 935 / 55 / 110

1,100 × 0.85 = 935. 1,100 × 0.1 = 110. 나머지 1,100 − 935 − 110 = 55. 훈련 배치(8건)로는 935 ÷ 8 = 116.9 → 자투리를 버려 116 배치, 검증 7, 테스트 14 (7.4절).

7.3 배치 조립 — 패딩, 한 칸 밀기, 그리고 −100 (step03)

6장은 모든 문자를 120 으로 맞췄지만, 지시 데이터는 배치마다 가장 긴 것에 맞추는 편이 낭비가 적습니다(짧은 배치는 짧게). 그러려면 DataLoader 가 항목들을 텐서로 묶는 단계 — collate — 를 직접 써야 합니다. 세 단계입니다.

flowchart TB
    A["리스트 셋: [0,1,2,3,4] · [5,6] · [7,8,9]"] --> B["① 끝에 50256 하나씩 붙이고
가장 긴 것(5+1=6)에 맞춰 50256 으로 채움"] B --> C["② 입력 = 마지막 뺀 것 (3, 5)
타깃 = 첫 것 뺀 것 (3, 5) — 한 칸 밀기 (2장)"] C --> D["③ 타깃의 50256 중 첫 번째만 남기고
나머지를 −100 으로"] D --> E["입력 [[0,1,2,3,4],[5,6,P,P,P],[7,8,9,P,P]]
타깃 [[1,2,3,4,P],[6,P,−100,−100,−100],[8,9,P,−100,−100]]"]
그림 7-3. collate 세 단계(P = 50256). 첫 패딩은 "여기서 멈춰라" 는 정답으로 남기고, 그 뒤의 패딩은 −100 으로 가려 손실에서 뺀다.

왜 첫 패딩은 남기는가? 타깃을 한 칸 밀면 마지막 실제 토큰(6) 다음에 무엇이 와야 하는지 정답이 필요합니다 — 그 자리의 50256 이 "문장 끝" 을 가르칩니다. 미세조정된 모델이 답을 마치면 50256 을 내고, 5장 generate 의 eos_id=50256 이 거기서 멈춥니다(7.6절). 왜 나머지 패딩은 가리는가? "패딩 다음은 패딩" 을 배우는 것은 시간 낭비이고, 짧은 문장이 긴 패딩 때문에 손실을 지배하면 안 되기 때문입니다.

파이토치 cross_entropy 는 타깃이 −100 인 위치를 계산에서 뺍니다(ignore_index 의 기본값). 실험 세 개로 확인할 수 있습니다.

손계산 ② −100 이 정말 빠지는가

로짓 두 행 [−1, 1], [−0.5, 1.5] 와 타깃 [0, 1]: 첫 행은 정답 0 의 확률 e^−1 ÷ (e^−1 + e^1) = 0.1192 → 손실 −ln 0.1192 = 2.1269, 둘째 행은 정답 1 의 확률 e^1.5 ÷ (e^−0.5 + e^1.5) = 0.8808 → 0.1269. 평균 1.1269. 셋째 행 [−0.5, 1.5] 에 타깃 1 을 더하면 (2.1269 + 0.1269 + 0.1269) ÷ 3 = 0.7936. 셋째 타깃을 −100 으로 바꾸면 다시 1.1269 — 셋째 행이 분모에서도 빠졌습니다. step03 의 출력 세 줄이 정확히 이 값입니다.

allowed_max_length 는 모델 문맥 길이(1,024)를 넘는 열을 자르는 안전장치입니다 — 이 데이터의 가장 긴 배치는 91 토큰이라 실제로 잘리는 것은 없습니다.

7.4 데이터셋과 로더 — 지시 + 응답을 토큰 열로 (step04)

데이터셋은 항목마다 format_input 결과에 ### Response: 절과 답을 붙인 전체 문자열을 토큰 ID 리스트로 만들어 둡니다(텐서가 아니라 리스트 — collate 가 길이를 보고 패딩해야 하므로). 로더 세 개는 6장과 같은 규칙입니다: 배치 8, 훈련만 섞고 자투리 버림, 시드 123 직후 생성. functools.partial 로 collate 에 allowed_max_length=1024 를 미리 채워 DataLoader(collate_fn=…) 에 넘깁니다.

손계산 ③ 검증 첫 배치가 (8, 74) 이고 −100 이 118 개인 이유

검증 로더는 섞지 않으므로 첫 배치는 검증 세트의 첫 여덟 항목입니다. 그중 가장 긴 것이 73 토큰 → 끝 패딩 하나를 더해 74. 여덟 항목의 길이가 각각 L₁…L₈ 이면 타깃에서 −100 인 자리는 항목마다 74 − L − 1 개(첫 패딩은 남김) → 합 118. 길이 합이 8 × 73 − 118 = 466 이라는 뜻이니 평균 58 토큰짜리 짧은 문장들입니다. 훈련 로더를 한 바퀴 돌며 본 배치 길이는 최소 55, 최대 91 — 6장처럼 120 으로 고정했다면 절반 가까이가 패딩이었을 것입니다.

flowchart LR
    E["InstructionDataset
항목 → 토큰 ID 리스트 (길이 제각각)"] --> DL["DataLoader
배치 8 · 훈련만 섞음"] DL --> C["custom_collate_fn
allowed_max_length 1024"] C --> B1["배치 A (8, 55)"] C --> B2["배치 B (8, 74)"] C --> B3["배치 C (8, 91)"] B1 --> T["train_model_simple (5장)
(입력, 타깃) 만 있으면 된다"] B2 --> T B3 --> T
그림 7-4. 로더 파이프라인. 배치마다 폭이 다르다(55 ~ 91) — 6장의 고정 120 과 달리 collate 가 배치 단위로 맞춘다. 5장 루프는 폭을 묻지 않는다.

5장 train_model_simple 은 로더에서 (입력, 타깃) 순서쌍만 기대합니다. 이 로더가 그 계약을 지키므로 7.7절에서 5장 루프를 한 글자도 안 고치고 씁니다 — 접점이 곧 재사용입니다.

7.5 GPT-2 중형 — 같은 이식 함수가 24 층에도 통한다 (step05)

지시를 따르는 능력은 모델 크기에 크게 좌우됩니다. 124M 은 미세조정해도 답이 어설프고, 원서는 CPU 로 훈련 가능한 선에서 중형(355M) 을 고릅니다. GPT-2 네 크기는 구조가 같고 세 수치만 다릅니다.

크기임베딩 차원층수헤드 수파라미터 (출력층 별도)safetensors
소형 124M7681212163,037,184548 MB (5장)
중형 355M1,0242416406,286,3361.5 GB (이 장)
대형 774M1,2803620—3.2 GB
특대 1.5B1,6004825—6.4 GB

표 7-2. GPT-2 네 크기. 4장 GPTModel 은 설정 딕셔너리로 만들어지므로 세 수치만 바꾸면 되고, 5장 load_weights_into_gpt 는 블록 수만큼 루프를 돌 뿐이라 그대로 통한다.

flowchart LR
    CFG124["GPT_CONFIG_124M
emb 768 · 층 12 · 헤드 12"] --> M1["GPTModel(cfg) — 4장 클래스"] CFGM["MEDIUM_CONFIG = dict(GPT_CONFIG_124M,
emb_dim=1024, n_layers=24, n_heads=16,
drop_rate=0.0, qkv_bias=True)"] --> M1 M1 --> S["소형 163,037,184
model.safetensors 548MB"] M1 --> L["중형 406,286,336
gpt2-medium.safetensors 1.5GB"] S --> W["load_weights_into_gpt (5장)
enumerate(trf_blocks) — 12 번"] L --> W2["load_weights_into_gpt (5장)
같은 함수 — 24 번"]
그림 7-5. 설정 세 줄로 크기 바꾸기. 4장 클래스와 5장 함수는 층수를 설정과 블록 목록에서 읽으므로 소형·중형에 같은 코드가 쓰인다.

손계산 ④ 406,286,336 을 한 줄씩

토큰 임베딩 50,257 × 1,024 = 51,463,168. 위치 임베딩 1,024 × 1,024 = 1,048,576. 블록 하나: 어텐션 4 × (1,024 × 1,024) + 편향 4 × 1,024 = 4,198,400, 피드포워드 2 × (1,024 × 4,096) + 4,096 + 1,024 = 8,393,728, 정규화 2 × 2 × 1,024 = 4,096 → 12,596,224. 블록 24 개 = 302,309,376. 최종 정규화 2,048. 출력층 51,463,168. 합 51,463,168 + 1,048,576 + 302,309,376 + 2,048 + 51,463,168 = 406,286,336. 출력층을 임베딩과 묶어 세면 354,823,168 — "355M" 의 유래입니다(4장 손계산 ⑨ 와 같은 이유).

내려받기는 5장 step08 과 같은 방식(1MB 조각, 진행률, 크기 검사, 있으면 생략)이고 파일은 1,519,984,962 바이트입니다. 넣고 나서 Every effort moves you 를 이으면 forward, but you must be careful. You must not let your guard down — 소형의 forward. The first step is … 과 다른, 더 자연스러운 문장입니다.

7.6 미세조정 전 — 지시를 던지면 지시를 되풀이한다 (step06)

훈련 전 기준선을 봐 둡니다. 검증 세트 첫 항목("Convert the active sentence to passive: 'The chef cooks the meal every day.'")을 알파카 형식으로 주고 5장 generate 로 잇게 하면:

The chef cooks the meal every day.  ### Instruction:  Convert the active sentence to passive: 'The chef cooks the

6장 step04 의 스팸 질문과 같은 증상입니다 — 형식은 흉내 내지만(### Instruction: 을 또 씀) 지시를 수행하지는 않습니다. 응답만 잘라 내는 extract_response 는 생성 문자열에서 프롬프트 길이만큼을 떼고 ### Response: 표식을 지웁니다. generate 에 eos_id=50256 을 주는 것은 미세조정 뒤를 위한 준비입니다 — 7.3절에서 "첫 패딩은 정답으로 남긴" 덕에 훈련된 모델은 답을 마치면 50256 을 내고, 거기서 멈춥니다.

손실도 재 둡니다. 5장 step03 의 calc_loss_loader 를 그대로 7장 로더에 쓰면 훈련 3.826 / 검증 3.762 — 원서와 같은 값입니다. −100 타깃이 자동으로 빠지는 것은 cross_entropy 의 기본 동작이라 5장 함수를 고칠 것이 없습니다. 이 값은 "영어는 알지만 이 형식은 처음 본다" 는 수준으로, 5장 GPT-2 의 소설 손실(3.755)과 비슷합니다.

7.7 미세조정 — 5장 훈련 루프를 그대로, 232 번의 갱신 (step07)

이 스텝에는 새 함수가 하나도 없습니다. 5장 train_model_simple 을 import 해 7장 로더를 넣고 2 에포크를 돌립니다. 5장 루프는 로더에서 (입력, 타깃) 순서쌍을 꺼내 calc_loss_batch 에 넘길 뿐이고, 그 손실 함수는 (배치×토큰, 어휘) 로 펼쳐 cross_entropy 를 부를 뿐입니다 — −100 이 섞인 타깃도, 배치마다 다른 길이도 문제가 되지 않습니다. 바뀐 것은 모델 크기(355M)와 학습률(5장의 8분의 1인 0.00005 — 미세조정은 조금씩 고칩니다)뿐입니다.

flowchart LR
    subgraph CH5["5장 산출물 (수정 없음)"]
        A["step09 load_weights_into_gpt"]
        B["step01 text_to_token_ids · token_ids_to_text"]
        C["step07 generate (온도 0 · eos)"]
        D["step03 calc_loss_loader"]
        E["step04 train_model_simple"]
    end
    subgraph CH7["7장 스텝"]
        S5["step05 중형 모델"]
        S6["step06 answer · 기준선"]
        S7["step07 미세조정"]
        S8["step08 응답 추출"]
    end
    A --> S5
    B --> S6
    C --> S6
    D --> S6
    E --> S7
    S6 --> S8
그림 7-6. 5장 재사용 지도. 5장 함수 다섯 개가 7장 네 스텝에 들어간다. 이 그림이 가능한 이유는 5장 사양이 함수의 입력·반환을 접점으로 못 박았기 때문이다.

손계산 ⑤ 스텝 232, 평가 47

116 배치 × 2 에포크 = 232 스텝(0 ~ 231). 5 의 배수인 스텝 0, 5, …, 230 에서 평가 → 47 번. 이 수가 step07 의 계약이고, 손실 값은 계약이 아닙니다 — 그런데 기준 구현 실측은 원서와 같은 값이 나왔습니다(시드 123 CPU 셔플이 원서의 GPU 노트북과 같은 순열을 낸 것으로, 1판 제작 때도 확인된 사실입니다).

xychart-beta
    title "지시 미세조정 손실 (앞 5 배치) — 5 스텝마다, 47 지점 중 일부"
    x-axis "전역 스텝" [0, 5, 10, 20, 40, 60, 80, 100, 115, 130, 150, 170, 190, 210, 230]
    y-axis "손실" 0 --> 3
    line [2.637, 1.174, 0.872, 0.776, 0.672, 0.719, 0.605, 0.502, 0.508, 0.447, 0.381, 0.323, 0.340, 0.366, 0.294]
    line [2.626, 1.102, 0.945, 0.881, 0.806, 0.743, 0.725, 0.677, 0.664, 0.682, 0.676, 0.683, 0.651, 0.632, 0.657]
그림 7-7. 손실 곡선(기준 구현 실측 일부). 훈련 손실은 2.64 → 0.29, 검증 손실은 0.66 에서 멈춘다 — 2 에포크째 두 곡선이 벌어지기 시작하는 것이 원서가 2 에포크에서 멈추는 이유다.
검증 항목정답 (데이터셋)훈련 전 응답 (7.6절)훈련 후 응답
능동태 → 수동태The meal is cooked by the chef every day.지시를 되풀이The meal is cooked every day by the chef.
'Dance' 는 명사? 동사?'Dance' can be classified as a verb.—Dance is a verb.
문장을 은유로The book is a page-turner.—The book is like a novel.

표 7-3. 훈련 후 응답 세 건(기준 구현 실측, 원서와 같은 값). 첫 둘은 맞고 셋째는 은유가 아니라 직유다 — 355M 모델의 한계이자 7.9절 채점이 필요한 이유.

훈련은 이 강좌에서 가장 긴 19분(CPU, i9-12900K)입니다. 5장(3분)·6장(6분)보다 긴 이유는 모델이 3배 크고(355M) 파라미터 전부를 훈련하기 때문입니다(6장은 5.7%). 에포크 끝의 샘플은 5장 함수가 찍는 것이라 eos 에서 멈추지 않고 50 토큰을 다 씁니다 — <|endoftext|> 뒤에 "다음 항목"을 지어내는 것이 보입니다. 미세조정된 모델이 문서 끝 토큰을 제자리에 내는 법을 배웠다는 증거이기도 합니다(7.3절의 "첫 패딩은 남긴다").

7.8 테스트 110 건에 답하기 — 채점용 응답 파일 (step08)

6장의 분류기는 정확도 하나로 평가할 수 있었습니다 — 답이 0 아니면 1 이니까요. 지시 미세조정 모델의 답은 문장입니다. "The meal is cooked every day by the chef." 는 정답 "The meal is cooked by the chef every day." 와 글자가 다르지만 맞는 답이고, "cumulus cloud" 는 그럴듯하지만 틀린 답(정답은 적란운 cumulonimbus)입니다. 글자 비교로는 이 차이를 잴 수 없습니다. 그래서 평가를 두 스텝으로 나눕니다 — 먼저 테스트 110 건의 답을 파일로 모으고(이 절), 그 파일을 다른 LLM 에게 채점시킵니다(7.9절).

flowchart LR
    F["gpt2-medium355M-sft.pth"] --> L["load_finetuned_model
구조(step05) + 미세조정 가중치"] T["테스트 110 건
instruction · input · output"] --> A["answer × 110
온도 0 · eos 50256 · CPU 6분"] L --> A A --> J["instruction-data-with-response.json
+ model_response"] J --> S["step09 심판에게"]
그림 7-8. 응답 추출. 항목 딕셔너리에 model_response 키 하나를 더해 저장하므로 정답과 응답이 한 항목 안에 나란히 남는다.
테스트 항목정답 (데이터셋)모델 응답 (기준 구현 실측)
직유로 다시 쓰기The car is as fast as lightning.The car is as fast as a bullet.
뇌우와 관련된 구름의 종류는?… is cumulonimbus.The type of cloud associated with thunderstorms is a cumulus cloud.
'Pride and Prejudice' 의 저자는?Jane Austen.The author of 'Pride and Prejudice' is Jane Austen.

표 7-4. 앞 세 건(원서와 같은 값). 둘은 맞고 하나는 틀렸다 — 그리고 "맞다/틀리다" 사이에는 "직유는 맞지만 비유 대상이 다르다" 같은 회색 지대가 있다. 점수가 필요한 이유.

응답은 온도 0 이라 결정적이고, 종료 토큰에서 멈추므로 대부분 한 문장입니다. 110 건에 CPU 로 약 6분 — 훈련(19분)보다 짧습니다.

손계산 ⑦ 응답 한 건에 3.3 초

110 건에 6분 5초 = 365 초 → 건당 3.3 초. 응답이 평균 15 토큰쯤이면 토큰 하나에 0.2 초 — 355M 모델의 forward 한 번(입력 100 토큰 안팎)이 CPU 에서 그만큼 걸립니다. max_new_tokens=256 을 줬지만 종료 토큰 덕에 실제로는 그 10분의 1 도 안 씁니다. 종료 토큰을 안 배웠다면(7.3절에서 첫 패딩을 −100 으로 가렸다면) 건당 256 토큰 × 0.2 초 = 50 초, 110 건에 1시간 30분이 걸렸을 것입니다 — "첫 패딩은 남긴다" 한 줄이 실행 시간까지 바꿉니다.

7.9 LLM 심판 — 강좌의 공식 모델이 채점한다 (step09)

110 건의 답을 사람이 매번 읽을 수는 없습니다. 요즘의 표준은 더 큰 LLM 에게 채점 기준을 주고 점수를 받는 것(LLM-as-a-judge)입니다. 원서는 로컬의 llama3 를 심판으로 쓰지만, 이 강좌는 내내 코드 생성에 쓴 공식 모델 qwen3.8:27b 를 심판으로 씁니다 — 같은 Ollama 서버, 같은 채팅 API 입니다. 프롬프트는 원서와 같습니다:

Given the input `<알파카 형식 프롬프트>` and correct output `<정답>`, score the model response `<모델 응답>`
on a scale from 0 to 100, where 100 is the best score. Respond with the integer number only.
flowchart LR
    J["instruction-data-with-response.json
110 건"] --> P["항목마다 채점 프롬프트
입력 · 정답 · 모델 응답"] P --> O["Ollama /api/chat
qwen3.8:27b · think:false · 온도 0 · 시드 123"] O --> N["정수로 읽히는 답만 모음
유효 110 건"] N --> M["평균 32.55"] C["자기 점검: 정답을 응답 자리에
→ 100 점"] --> O
그림 7-9. LLM 심판 파이프라인. 추론형 모델은 답 앞에 생각을 쓰므로 think: false 로 끄고 정수만 받는다. 자기 점검이 없으면 심판이 아무 숫자나 내는지 알 수 없다.

손계산 ⑥ 평균 32.55 는 어떤 점수인가

기준 구현 실측: 첫 항목("The car is as fast as a bullet.")에 100 점, 정답 자체를 응답 자리에 넣어도 100 점, 110 건 평균 32.55. 원서의 llama3 는 같은 종류의 응답에 50점대를 줍니다 — qwen3.8:27b 가 더 엄격한 것이지 모델이 더 나빠진 것이 아닙니다(응답 자체는 원서와 같습니다). 점수는 절대값이 아니라 같은 심판으로 비교할 때 의미가 있습니다. 이 강좌에는 비교할 둘째 모델이 없으므로, 계약은 "정답(100)보다 낮고 0 보다 확실히 높다(20 이상)" 는 위치와 유효 건수(100 이상), 그리고 심판 자기 점검(90 이상)입니다.

손계산 ⑧ 채점 110 번의 비용

채점 프롬프트 하나는 머리말 21 토큰 + 지시·입력 + 정답 + 응답 + 지시문 30 토큰 ≈ 120 ~ 150 토큰이고, 답은 정수 하나(1 ~ 3 토큰)입니다. 27B 모델이 프롬프트 150 토큰을 읽고 토큰 두 개를 내는 데 한가한 서버에서 4 ~ 5 초 → 110 건에 약 8 ~ 10분. 이 교안을 만들 때는 hermes 와 서버를 나눠 써서 40분이 걸렸습니다(한 모델이 두 작업을 번갈아 처리). 토큰으로 세면 110 × 150 = 16,500 토큰 — 5장 소설(5,145 토큰)의 세 배를 심판이 읽은 셈입니다.

왜 값이 계약이 아닌가? 온도 0·시드 123 을 줘도 서버·버전에 따라 한두 점이 흔들리고, 심판을 바꾸면 척도가 통째로 바뀝니다. 5장 훈련 손실을 불변식으로 계약한 것과 같은 이유입니다. 채점 110 건은 서버가 한가할 때 약 10분이지만, 이 교안을 만들 때는 hermes 가 같은 서버로 6장 코드를 만들고 있어 40분이 걸렸습니다 — 두 작업이 한 모델을 번갈아 쓰면 둘 다 느려집니다(6장 step05·06 이 15분 상한에 걸린 이유이기도 합니다).

이것으로 강좌의 마지막 스텝이 끝납니다. 2장에서 토큰화한 문장이 3장 어텐션, 4장 모델, 5장 훈련, 6장 분류, 7장 지시를 거쳐 — 다른 LLM 이 채점하는 답이 됐습니다.

실습 — 루프 아홉 바퀴

아홉 스텝은 접점으로 이어져 있습니다. 순서대로 진행하세요. 5장 실습을 끝낸 상태에서 시작해야 합니다 — step05 부터가 5장 load_weights_into_gpt·text_to_token_ids· generate·calc_loss_loader·train_model_simple 을 import 합니다(6장은 필요 없습니다).

flowchart LR
    S1["step01
load_instruction_data"] --> S2["step02
format_input
split_data"] S3["step03
custom_collate_fn"] --> S4["step04
InstructionDataset
build_dataloaders"] S2 --> S4 C5["5장 load_weights_into_gpt
text↔ids · generate
calc_loss_loader · train_model_simple"] ==> S5["step05
build_medium_model
MEDIUM_CONFIG"] C5 ==> S6["step06
extract_response · answer"] S5 --> S6 S4 --> S6 S6 --> S7["step07
MODEL_FILE"] C5 ==> S7 S7 --> S8["step08
load_finetuned_model
RESPONSE_FILE"] S6 --> S8 S8 --> S9["step09
query_model
generate_model_scores"] S2 --> S9
그림 7-10. 7장 실습의 접점 흐름. 5장 함수 다섯 개가 step05~07 에 굵은 화살표로 들어온다. 7장이 새로 만드는 접점은 형식화(step02)·collate(step03)·응답 추출(step06)·심판 호출(step09)뿐이다.

각 스텝은 bash harness/run_loop.sh ch07 prompts/ch07/stepNN-*.md → bash harness/verify.sh ch07 stepNN → bash harness/verify.sh ch07 stepNN --solution 의 세 명령입니다 (step07·08·09 는 HERMES_TIMEOUT=3600 을 앞에 붙입니다 — 훈련 19분, 응답 추출 6분, 채점 10~40분). 이번 장 사양의 특징은 5장 접점을 사양이 이름으로 지목한다는 점입니다 — "손실·생성·훈련 루프를 새로 구현하지 않는다" 가 제약이고, hermes 는 5장 파일(자기가 만든)을 import 해야 합니다. 훈련·채점이 개입하는 step07·09 는 불변식 계약입니다.

step01. 지시 데이터 1,100 건 — instruction · input · output

무엇을 — 지시(instruction)·입력(input)·출력(output) 세 필드로 된 JSON 데이터 1,100 건을 내려받아 딕셔너리 리스트로 읽는 load_instruction_data()
왜 — 5장 모델은 문장을 잇기만 하고 6장 모델은 두 갈래만 고른다. "지시를 읽고 답하는" 챗봇을 만들려면 지시와 모범 답이 짝지어진 데이터가 필요하다
맥락 — 7장의 출발점. step02 가 이 리스트를 프롬프트 형식으로 만들고 나눈다. 5장 step08·6장 step01 과 같은 "없으면 내려받는" 멱등 스크립트
기대 결과 — 1,100 건, 키 ['instruction', 'input', 'output'], 입력이 비어 있는 항목 775 건
hermes에게 전달되는 사양 (prompts/ch07/step01-download-data.md)
step01_download_data.py 라는 파이썬 파일을 만들어줘.

만들 것: 지시 미세조정용 JSON 데이터를 내려받아 읽는 스크립트.

접점 (step02 와 이후 스텝이 import 한다):
- 상수 DATA_FILE = "instruction-data.json".
- 함수 load_instruction_data() — 현재 디렉토리에 DATA_FILE 이 없으면 https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/main/ch07/01_main-chapter-code/instruction-data.json
  에서 표준 라이브러리 urllib.request 로 내려받고, 파일을 UTF-8 로 읽어 JSON 리스트(항목마다 키 instruction, input, output 인 딕셔너리)를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 항목 수: 1100 처럼 리스트 길이
- 키: ['instruction', 'input', 'output'] 처럼 첫 항목의 키 목록
- 항목 50: {'instruction': 'Identify the correct spelling of the following word.', 'input': 'Ocassion', 'output': "The correct spelling is 'Occasion.'"} 처럼 50 번 항목 딕셔너리 (print("라벨:", 딕셔너리) 꼴)
- 항목 999: ... 처럼 999 번 항목 딕셔너리
- 입력이 비어 있는 항목 수: 775 처럼 input 이 빈 문자열인 항목 수

지켜야 할 것:
- 표준 라이브러리만 사용한다 (requests 금지).
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 재시도, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
항목 수: 1100
키: ['instruction', 'input', 'output']
항목 50: {'instruction': 'Identify the correct spelling of the following word.', 'input': 'Ocassion', 'output': "The correct spelling is 'Occasion.'"}
항목 999: {'instruction': "What is an antonym of 'complicated'?", 'input': '', 'output': "An antonym of 'complicated' is 'simple'."}
입력이 비어 있는 항목 수: 775
[OK] 5줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch07/step01_download_data.py, 핵심):

def load_instruction_data():
    if not os.path.exists(DATA_FILE):
        urllib.request.urlretrieve(URL, DATA_FILE)
    with open(DATA_FILE, "r", encoding="utf-8") as f:
        return json.load(f)                     # 딕셔너리 1,100 개의 리스트

2장 step01 의 load_text 와 같은 꼴 — 파일이 JSON 이라 read() 대신 json.load 입니다. 딕셔너리를 출력할 때 파이썬은 값에 작은따옴표가 있으면 큰따옴표로 감쌉니다 ("The correct spelling is 'Occasion.'") — 기대 출력의 따옴표가 섞여 보이는 이유입니다.

실측

hermes(qwen3.8:27b)는 내려받기를 urlretrieve 한 줄 대신 urlopen 으로 읽어 파일에 쓰는 세 줄로 했습니다 — 같은 동작입니다. 빈 입력 검사는 d["input"] == "" (기준 구현은 falsy 검사 not entry["input"] — 이 데이터에서는 같은 값). 1.5분에 파일, 3분에 종료, 첫 시도 통과.

step02. 알파카 형식과 분할 — 지시를 모델이 읽을 문장으로

무엇을 — 항목을 "머리말 + ### Instruction: + (### Input:)" 문자열로 만드는 format_input(entry), 데이터를 훈련 85% / 테스트 10% / 검증 5% 로 나누는 split_data(data)
왜 — 모델은 필드를 모른다. 지시·입력·응답을 구분자가 있는 하나의 긴 문자열로 이어 붙여야 "어디까지가 질문이고 어디부터가 답인지" 배울 수 있다
맥락 — step01 의 리스트를 받아 step04 데이터셋과 step06·08 의 프롬프트를 만든다. 형식은 스탠퍼드 알파카(Alpaca) 스타일 — 원서와 같다
기대 결과 — 항목 50 은 세 절(Instruction·Input·Response), 항목 999 는 두 절(Input 없음), 훈련 935 / 검증 55 / 테스트 110
hermes에게 전달되는 사양 (prompts/ch07/step02-format-split.md)
step02_format_split.py 라는 파이썬 파일을 만들어줘.

만들 것: 지시 항목을 알파카 프롬프트 형식의 문자열로 만드는 함수와, 데이터를 훈련·검증·테스트로 나누는 함수.

접점 (직전 스텝 재사용 + 이후 스텝이 import 한다):
- 같은 디렉토리의 step01_download_data.py 에서 load_instruction_data 를 import 한다.
- 함수 format_input(entry) — 문자열 Below is an instruction that describes a task. Write a response that appropriately completes the request. 뒤에 빈 줄 하나를 두고
  ### Instruction: 줄, 그 다음 줄에 항목의 instruction 을 붙인다. input 이 빈 문자열이 아니면 빈 줄 하나 뒤에 ### Input: 줄과 그 다음 줄에 input 을 붙인다.
  응답 절은 붙이지 않는다.
- 함수 split_data(data) — 리스트의 앞 길이 × 0.85 의 정수 부분을 훈련, 그 다음 길이 × 0.1 의 정수 부분을 테스트, 나머지를 검증으로 잘라 (훈련, 검증, 테스트) 순서로 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 항목 50 과 999 각각에 대해 형식화 예시 (항목 50): 한 줄 뒤, format_input 결과 + 빈 줄 + ### Response: 줄 + 그 다음 줄에 output 을 그대로 출력하고, --- 한 줄
- 훈련 세트 길이: 935, 검증 세트 길이: 55, 테스트 세트 길이: 110 처럼 세 줄

지켜야 할 것:
- 표준 라이브러리만 사용한다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
형식화 예시 (항목 50):
Below is an instruction that describes a task. Write a response that appropriately completes the request.

### Instruction:
Identify the correct spelling of the following word.

### Input:
Ocassion

### Response:
The correct spelling is 'Occasion.'
---
형식화 예시 (항목 999):
Below is an instruction that describes a task. Write a response that appropriately completes the request.

### Instruction:
What is an antonym of 'complicated'?

### Response:
An antonym of 'complicated' is 'simple'.
---
훈련 세트 길이: 935
검증 세트 길이: 55
테스트 세트 길이: 110
[OK] 15줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch07/step02_format_split.py, 핵심):

PREAMBLE = ("Below is an instruction that describes a task. "
            "Write a response that appropriately completes the request.")

def format_input(entry):
    instruction_text = f"{PREAMBLE}\n\n### Instruction:\n{entry['instruction']}"
    input_text = f"\n\n### Input:\n{entry['input']}" if entry["input"] else ""     # 입력이 있을 때만
    return instruction_text + input_text

def split_data(data):
    train_end = int(len(data) * 0.85)
    test_end = train_end + int(len(data) * 0.1)
    return data[:train_end], data[test_end:], data[train_end:test_end]        # (훈련, 검증, 테스트)

\n\n 이 절 사이의 빈 줄입니다. split_data 의 반환 순서(훈련, 검증, 테스트)와 데이터에서의 위치(훈련, 테스트, 검증)가 다른 점에 주의하세요 — 원서의 배치를 그대로 따랐습니다.

실측

hermes(qwen3.8:27b)는 format_input 을 문자열 리스트 + "\n".join 으로 조립했습니다 — 절 사이 빈 줄이 리스트의 빈 원소로 들어가는, 기준 구현(f-문자열)과 다른 길입니다. split_data 는 "자르는 순서는 훈련·테스트·검증, 반환 순서는 훈련·검증·테스트" 라는 사양의 함정 같은 조합을 정확히 지켰습니다. 2분에 파일, 4분에 종료, 첫 시도 통과.

step03. 배치 조립(collate) — 패딩, 한 칸 밀기, 그리고 −100

무엇을 — 길이가 다른 토큰 ID 리스트들을 한 배치로 묶는 custom_collate_fn: 패딩 → 입력/타깃 한 칸 밀기 → 첫 패딩 뒤의 패딩을 −100 으로 가리기 → (선택) 길이 자르기
왜 — 6장은 모든 문자를 120 으로 맞췄지만, 지시 데이터는 배치마다 가장 긴 것에 맞추는 편이 낭비가 적다. 그리고 패딩 자리의 손실은 세면 안 된다 — 파이토치의 −100 이 그 약속이다
맥락 — 2장 슬라이딩 윈도우의 "한 칸 민 타깃" 과 6장의 패딩이 합쳐진다. step04 의 데이터 로더가 이 함수를 collate_fn 으로 쓴다
기대 결과 — 세 리스트 [0,1,2,3,4], [5,6], [7,8,9] 가 (3, 5) 입력·타깃 텐서가 되고, 타깃의 두 번째 이후 패딩이 −100. −100 을 가린 손실이 두 위치짜리 손실과 같다(1.1269)
hermes에게 전달되는 사양 (prompts/ch07/step03-collate.md)
step03_collate.py 라는 파이썬 파일을 만들어줘.

만들 것: 길이가 다른 토큰 ID 리스트들을 패딩해 (입력, 타깃) 텐서 쌍으로 묶는 collate 함수와, −100 이 손실에서 빠지는 것을 보여 주는 실험.

접점 (step04 가 import 한다):
- 상수 PAD_TOKEN_ID = 50256, IGNORE_INDEX = -100.
- 함수 custom_collate_fn(batch, pad_token_id=PAD_TOKEN_ID, ignore_index=IGNORE_INDEX, allowed_max_length=None, device="cpu") — 토큰 ID 리스트들의 배치를 받아:
  ① 배치에서 가장 긴 리스트 길이 + 1 을 목표 길이로 하고, 각 리스트 끝에 pad_token_id 를 붙여 목표 길이까지 채운다.
  ② 입력은 채운 리스트에서 마지막 하나를 뺀 것, 타깃은 첫 하나를 뺀 것(한 칸 민 것).
  ③ 타깃에서 pad_token_id 가 나오는 자리 중 **첫 번째만 남기고** 나머지를 ignore_index 로 바꾼다.
  ④ allowed_max_length 가 None 이 아니면 입력·타깃을 그 길이까지만 남긴다.
  입력들을 쌓은 (배치, 길이) 정수 텐서와 타깃 텐서를 device 로 옮겨 (입력, 타깃) 순서쌍으로 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 리스트 [0, 1, 2, 3, 4], [5, 6], [7, 8, 9] 를 배치로 묶는다. 그리고 로짓 [[-1.0, 1.0], [-0.5, 1.5]] 와 타깃 [0, 1] 의 교차 엔트로피,
로짓 [[-1.0, 1.0], [-0.5, 1.5], [-0.5, 1.5]] 와 타깃 [0, 1, 1] 의 교차 엔트로피, 같은 로짓과 타깃 [0, 1, -100] 의 교차 엔트로피를 구한다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다. 텐서는 print(텐서) 또는 print("라벨:", 텐서) 꼴로, f-문자열 보간은 쓰지 않는다):
- 입력: 한 줄 뒤 다음 줄부터 입력 텐서
- 타깃: 한 줄 뒤 다음 줄부터 타깃 텐서
- 입력 모양: torch.Size([3, 5]) 처럼 입력의 모양
- 두 위치 손실: tensor(1.1269), 세 위치 손실: tensor(0.7936), 셋째를 -100 으로 가린 손실: tensor(1.1269) 처럼 세 줄

지켜야 할 것:
- torch 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
입력:
tensor([[    0,     1,     2,     3,     4],
        [    5,     6, 50256, 50256, 50256],
        [    7,     8,     9, 50256, 50256]])
타깃:
tensor([[    1,     2,     3,     4, 50256],
        [    6, 50256,  -100,  -100,  -100],
        [    8,     9, 50256,  -100,  -100]])
입력 모양: torch.Size([3, 5])
두 위치 손실: tensor(1.1269)
세 위치 손실: tensor(0.7936)
셋째를 -100 으로 가린 손실: tensor(1.1269)
[OK] 10줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch07/step03_collate.py, 핵심):

def custom_collate_fn(batch, pad_token_id=PAD_TOKEN_ID, ignore_index=IGNORE_INDEX, allowed_max_length=None, device="cpu"):
    batch_max_length = max(len(item) + 1 for item in batch)                    # 가장 긴 것 + 끝 패딩 하나
    inputs_list, targets_list = [], []
    for item in batch:
        padded = item + [pad_token_id] * (batch_max_length - len(item))       # ① 패딩
        inputs = torch.tensor(padded[:-1])                                     # ② 입력: 마지막 뺌
        targets = torch.tensor(padded[1:])                                     #    타깃: 첫 것 뺌
        pad_positions = torch.nonzero(targets == pad_token_id).squeeze(-1)
        if pad_positions.numel() > 1:
            targets[pad_positions[1:]] = ignore_index                          # ③ 첫 패딩만 남김
        if allowed_max_length is not None:
            inputs, targets = inputs[:allowed_max_length], targets[:allowed_max_length]   # ④ 자르기
        inputs_list.append(inputs)
        targets_list.append(targets)
    return torch.stack(inputs_list).to(device), torch.stack(targets_list).to(device)

torch.nonzero(targets == pad_token_id) 가 패딩 자리 번호들을 주고, [1:] 로 첫 번째를 뺀 나머지를 −100 으로 바꿉니다. 원서는 리스트 두 개를 따로 쌓은 뒤 행마다 가리는데, 기준 구현은 항목 하나를 끝까지 처리하고 쌓습니다 — 결과는 같습니다. 여러분의 코드는 이것과 달라도 됩니다.

실측

hermes(qwen3.8:27b)는 collate 전체를 순수 파이썬 리스트로 처리했습니다 — 패딩·한 칸 밀기·seen_pad 플래그 루프로 가리기·자르기를 리스트에서 끝내고, 마지막에 한 번만 torch.tensor 로 쌓습니다. 기준 구현(텐서 + torch.nonzero)과 완전히 다른 길인데 텐서 두 개와 손실 세 개가 전부 같습니다 — "검증은 동작으로만 한다"는 2판 철학의 좋은 표본입니다. 2분에 파일, 4분에 종료, 첫 시도 통과.

step04. 데이터셋과 로더 — 지시 + 응답을 토큰 열로

무엇을 — 항목마다 format_input + ### Response: + output 을 토큰 ID 리스트로 만들어 두는 InstructionDataset, step03 의 collate 로 묶는 세 로더 build_dataloaders(device)
왜 — 훈련에 들어가는 것은 "지시 + 정답" 전체 문자열이다. 모델은 그 전체의 다음 토큰을 예측하며 응답 부분을 배운다(5장 방식 그대로)
맥락 — 6장 step03 과 같은 자리의 스텝. 5장의 train_model_simple 이 이 로더를 그대로 받는다 — 배치가 (입력, 타깃) 순서쌍이기만 하면 된다
기대 결과 — 배치 수 116 / 7 / 14, 검증 첫 배치 (8, 74), 그 타깃의 −100 이 118 개, 훈련 배치 길이 55 ~ 91
hermes에게 전달되는 사양 (prompts/ch07/step04-dataloader.md)
step04_dataloader.py 라는 파이썬 파일을 만들어줘.

만들 것: 지시 항목을 토큰 ID 리스트로 내주는 파이토치 데이터셋과, step03 의 collate 로 묶는 훈련·검증·테스트 로더.

접점 (직전 스텝들 재사용 + 이후 스텝이 import 한다):
- 같은 디렉토리의 step01_download_data.py 에서 load_instruction_data 를, step02_format_split.py 에서 format_input, split_data 를, step03_collate.py 에서
  custom_collate_fn 을 import 한다.
- 상수 BATCH_SIZE = 8, ALLOWED_MAX_LENGTH = 1024.
- 클래스 InstructionDataset(data, tokenizer) — torch.utils.data.Dataset 을 상속한다. 항목마다 format_input(항목) 뒤에 빈 줄 하나, ### Response: 줄, 그 다음 줄에
  output 을 이어 붙인 문자열을 tokenizer 로 인코딩한 토큰 ID 리스트를 만들어 둔다. 항목은 그 리스트(텐서가 아니라 리스트), 길이는 항목 수.
- 함수 build_dataloaders(device="cpu") — tiktoken gpt2 토크나이저와 split_data(load_instruction_data()) 로 세 데이터셋을 만들고, custom_collate_fn 에
  allowed_max_length=ALLOWED_MAX_LENGTH 와 device 를 미리 채운 collate 로 파이토치 시드 123 고정 직후 DataLoader 세 개를 만든다 — 배치 8, 훈련만 섞고 자투리를
  버리며, 검증·테스트는 섞지 않고 자투리를 버리지 않는다. (훈련 로더, 검증 로더, 테스트 로더) 를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 훈련 배치 수: 116, 검증 배치 수: 7, 테스트 배치 수: 14 처럼 세 줄
- 검증 첫 배치 입력 모양: torch.Size([8, 74]), 검증 첫 배치 타깃 모양: torch.Size([8, 74]) 처럼 검증 로더 첫 배치의 두 모양
- 검증 첫 배치 타깃의 -100 개수: 118 처럼 그 타깃에서 −100 인 원소 수
- 훈련 배치 길이 최소·최대: 55 91 처럼 훈련 로더를 한 바퀴 돌며 본 입력 길이(둘째 차원)의 최솟값과 최댓값

지켜야 할 것:
- torch, tiktoken 과 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
훈련 배치 수: 116
검증 배치 수: 7
테스트 배치 수: 14
검증 첫 배치 입력 모양: torch.Size([8, 74])
검증 첫 배치 타깃 모양: torch.Size([8, 74])
검증 첫 배치 타깃의 -100 개수: 118
훈련 배치 길이 최소·최대: 55 91
[OK] 7줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch07/step04_dataloader.py, 핵심):

class InstructionDataset(Dataset):
    def __init__(self, data, tokenizer):
        self.encoded_texts = [tokenizer.encode(format_input(entry) + f"\n\n### Response:\n{entry['output']}") for entry in data]
    def __getitem__(self, index):
        return self.encoded_texts[index]                     # 텐서가 아니라 리스트 — collate 가 길이를 본다

def build_dataloaders(device="cpu"):
    tokenizer = tiktoken.get_encoding("gpt2")
    train_data, val_data, test_data = split_data(load_instruction_data())
    collate = partial(custom_collate_fn, allowed_max_length=ALLOWED_MAX_LENGTH, device=device)
    torch.manual_seed(123)
    train_loader = DataLoader(InstructionDataset(train_data, tokenizer), batch_size=BATCH_SIZE,
                              collate_fn=collate, shuffle=True, drop_last=True)
    ...

partial 은 함수의 인자 일부를 미리 채운 새 함수를 만듭니다 — DataLoader 는 collate 에 배치 하나만 넘기므로 나머지 인자를 이렇게 붙여 둡니다. 데이터셋 항목이 리스트인 점이 6장과 다릅니다 — 길이가 제각각이라 텐서로 미리 만들 수 없고, collate 가 배치 단위로 맞춥니다.

실측

hermes(qwen3.8:27b)의 코드는 기준 구현과 거의 같은 모양이었습니다 — 데이터셋 항목을 리스트로 두고, partial 로 collate 인자를 채우고, 시드 123 직후 로더 셋을 만듭니다. 훈련 배치 길이의 최소·최대(55, 91)까지 일치했습니다 — 같은 시드는 같은 순열을 뽑습니다. 2.5분에 파일, 5분에 종료, 첫 시도 통과.

step05. GPT-2 중형(355M) — 같은 이식 함수가 24층에도 통한다

무엇을 — 허깅페이스의 GPT-2 중형 가중치(1.5GB)를 내려받는 download_weights(), 중형 설정 MEDIUM_CONFIG, 5장 load_weights_into_gpt 로 넣어 돌려주는 build_medium_model()
왜 — 지시를 따르는 능력은 모델 크기에 크게 좌우된다. 124M 은 미세조정해도 답이 어설프고, 355M 이면 CPU 로 훈련 가능한 선에서 눈에 띄게 낫다(원서의 선택)
맥락 — 5장 step09 의 이식 함수가 설정만 바꾸면(임베딩 1024, 24층, 16헤드) 다른 크기의 GPT-2 에도 무수정으로 통한다는 것을 확인한다. step06·07 이 이 모델을 쓴다
기대 결과 — 파일 1,519,984,962 바이트, 파라미터 406,286,336, 블록 24, Every effort moves you forward, but you must be careful. You must not let your guard down
hermes에게 전달되는 사양 (prompts/ch07/step05-load-medium.md)
step05_load_medium.py 라는 파이썬 파일을 만들어줘.

만들 것: GPT-2 중형 가중치를 내려받아 우리 GPTModel 에 넣어 돌려주는 함수와, 그 모델의 크기·생성을 확인하는 스크립트.

접점 (5장·4장 산출물 재사용 + step06·07·08 이 import 한다):
- 5장 작업 디렉토리 ../ch05 를 import 검색 경로에 추가한 뒤 step09_load_gpt2_weights.py 의 load_weights_into_gpt, step01_text_utils.py 의 text_to_token_ids·
  token_ids_to_text 를 import 한다. 5장 파일들이 ../ch04·../ch02 를 스스로 경로에 추가하므로 step01_gpt_config 의 GPT_CONFIG_124M, step07_gpt_model 의 GPTModel,
  step08_generate 의 generate_text_simple 도 import 할 수 있다. 모델·이식·생성을 새로 구현하지 않는다.
- 상수 WEIGHTS_FILE = "gpt2-medium.safetensors", EXPECTED_SIZE = 1519984962.
- 딕셔너리 MEDIUM_CONFIG — GPT_CONFIG_124M 의 복사본에 emb_dim 1024, n_layers 24, n_heads 16, drop_rate 0.0, qkv_bias True 를 넣은 것 (어휘·문맥은 그대로).
- 함수 download_weights() — WEIGHTS_FILE 이 있고 크기가 EXPECTED_SIZE 면 gpt2-medium.safetensors 이미 있음 을 출력하고 끝낸다. 아니면 gpt2-medium.safetensors 내려받기 를
  출력한 뒤 https://huggingface.co/openai-community/gpt2-medium/resolve/main/model.safetensors 에서 표준 라이브러리 urllib.request 로 1MB 조각씩 받아 쓰며 10% 마다
    진행 10% 처럼 진행률을 출력한다.
- 함수 build_medium_model() — download_weights() 를 부르고 GPTModel(MEDIUM_CONFIG) 에 safetensors.torch 의 load_file 로 읽은 가중치를 load_weights_into_gpt 로 넣은 뒤
  평가 모드로 바꿔 (모델, tiktoken gpt2 토크나이저) 를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 모델을 만들고 Every effort moves you 뒤에 새 토큰 15개를 탐욕적으로 생성한다(context_size 는 설정의 context_length, 줄바꿈은 공백으로).

출력 (직접 실행하면 download_weights() 의 출력 뒤에 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 파라미터 개수: 406,286,336 처럼 모든 파라미터 원소 수의 합을 천 단위 콤마로
- 블록 수: 24 처럼 trf_blocks 의 길이
- 탐욕적 생성: Every effort moves you forward, but you must be careful. You must not let your guard down 처럼 생성 문자열

지켜야 할 것:
- torch, tiktoken, safetensors 와 표준 라이브러리 외의 패키지는 import 하지 않는다 (requests, huggingface_hub 금지).
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 재시도, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 두 번째 실행 — 첫 실행은 "내려받기" 와 진행률 10 줄이 앞에 붙는다) ---
gpt2-medium.safetensors 이미 있음
파라미터 개수: 406,286,336
블록 수: 24
탐욕적 생성: Every effort moves you forward, but you must be careful. You must not let your guard down
[OK] 3줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch07/step05_load_medium.py, 핵심):

MEDIUM_CONFIG = dict(GPT_CONFIG_124M, emb_dim=1024, n_layers=24, n_heads=16, drop_rate=0.0, qkv_bias=True)

def build_medium_model():
    download_weights()
    model = GPTModel(MEDIUM_CONFIG)                            # 4장 클래스 — 설정만 다르다
    load_weights_into_gpt(model, load_file(WEIGHTS_FILE))     # 5장 함수 — 블록 수만큼 루프
    model.eval()
    return model, tiktoken.get_encoding("gpt2")

설정 한 줄이 이 스텝의 핵심입니다. 4장 GPTModel 이 설정 딕셔너리로 층수·차원·헤드를 읽고, 5장 load_weights_into_gpt 가 enumerate(gpt.trf_blocks) 로 블록 수만큼 돌기 때문에 두 함수 모두 고칠 것이 없습니다. download_weights 는 5장 step08 의 내려받기 함수와 같습니다(URL·파일명·크기만 다름).

실측

hermes(qwen3.8:27b)는 MEDIUM_CONFIG 를 dict(GPT_CONFIG_124M, …) 복사 대신 여섯 키를 직접 적은 리터럴로 만들었습니다 — 어느 쪽이든 같은 딕셔너리이고, 복사 방식은 접점이 아니므로 사양 위반이 아닙니다. 자체 점검으로 1.5GB 를 실제로 내려받은 뒤(약 5분) 응답했고, 5장 이식 함수(학생 생성본)가 24층 모델에 그대로 통했습니다. 첫 시도 통과.

step06. 미세조정 전 — 지시를 던지면 지시를 되풀이한다

무엇을 — 생성 결과에서 프롬프트를 떼고 ### Response: 표식을 지워 응답만 남기는 extract_response, 항목 하나에 탐욕적으로 답하는 answer(model, tokenizer, entry, max_new_tokens=35), 그리고 훈련 전 응답과 손실 기준선
왜 — 6장 step04 의 스팸 질문처럼, 지시 형식을 본 적 없는 모델은 답 대신 형식을 흉내 낸다. 그 기준선을 봐 두어야 step07 의 변화가 보인다
맥락 — 5장의 generate(종료 토큰 50256)·calc_loss_loader 를 수정 없이 가져다 쓴다 — 7장 로더가 5장과 같은 (입력, 타깃) 계약을 지키기 때문이다. step07·08 이 answer 를 쓴다
기대 결과 — 검증 항목 0 (능동태 → 수동태) 에 대해 The chef cooks the meal every day. ### Instruction: … 처럼 지시를 되풀이, 훈련 전 손실 3.826 / 3.762
hermes에게 전달되는 사양 (prompts/ch07/step06-pretrain-eval.md)
step06_pretrain_eval.py 라는 파이썬 파일을 만들어줘.

만들 것: 모델의 생성 결과에서 응답만 잘라 내는 함수, 항목 하나에 답하는 함수, 그리고 미세조정 전 중형 모델의 응답과 손실을 확인하는 스크립트.

접점 (직전 스텝들 + 5장 산출물 재사용 + step07·08 이 import 한다):
- 같은 디렉토리의 step01_download_data.py 에서 load_instruction_data 를, step02_format_split.py 에서 format_input, split_data 를, step04_dataloader.py 에서
  build_dataloaders 를, step05_load_medium.py 에서 build_medium_model, MEDIUM_CONFIG 를 import 한다.
- ../ch05 를 경로에 추가하고 step01_text_utils.py 의 text_to_token_ids·token_ids_to_text, step03_data_split.py 의 calc_loss_loader, step07_generate.py 의 generate 를
  import 한다. 손실·생성을 새로 구현하지 않는다.
- 함수 extract_response(generated_text, input_text) — generated_text 에서 앞 len(input_text) 글자를 뗀 나머지에서 문자열 ### Response: 를 지우고 앞뒤 공백을 뗀 문자열을 반환한다.
- 함수 answer(model, tokenizer, entry, max_new_tokens=35) — format_input(entry) 를 토큰 ID 텐서로 바꿔 generate 로 온도 0(기본값), 종료 토큰 50256, context_size 는
  MEDIUM_CONFIG 의 context_length 로 새 토큰을 최대 max_new_tokens 개 생성하고, 전체를 디코딩해 extract_response 로 잘라 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 장치는 CPU. 모델을 만들고 split_data 의 검증 세트 첫 항목에 answer 로 답한다(줄바꿈은 공백으로). 그 다음 build_dataloaders(장치) 로 로더를 만들고 파이토치 시드 123
고정 직후 기울기 기록 없이 훈련 로더·검증 로더 앞 5 배치의 손실을 잰다.

출력 (직접 실행하면 download_weights() 의 출력 뒤에 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 검증 항목 0 지시: Convert the active sentence to passive: 'The chef cooks the meal every day.' 처럼 그 항목의 instruction
- 검증 항목 0 정답: The meal is cooked by the chef every day. 처럼 output
- 훈련 전 응답: ... 처럼 answer 의 결과 (줄바꿈은 공백으로)
- 훈련 전 훈련 손실: 3.826, 훈련 전 검증 손실: 3.762 처럼 소수점 셋째 자리까지

지켜야 할 것:
- torch, tiktoken, safetensors 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측) ---
gpt2-medium.safetensors 이미 있음
검증 항목 0 지시: Convert the active sentence to passive: 'The chef cooks the meal every day.'
검증 항목 0 정답: The meal is cooked by the chef every day.
훈련 전 응답: The chef cooks the meal every day.  ### Instruction:  Convert the active sentence to passive: 'The chef cooks the
훈련 전 훈련 손실: 3.826
훈련 전 검증 손실: 3.762
[OK] 5줄 전부 일치   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch07/step06_pretrain_eval.py, 핵심):

def extract_response(generated_text, input_text):
    return generated_text[len(input_text):].replace("### Response:", "").strip()

def answer(model, tokenizer, entry, max_new_tokens=35):
    input_text = format_input(entry)
    idx = text_to_token_ids(input_text, tokenizer)
    output = generate(model, idx, max_new_tokens=max_new_tokens,
                      context_size=MEDIUM_CONFIG["context_length"], eos_id=50256)   # 온도 0 = 탐욕적
    return extract_response(token_ids_to_text(output, tokenizer), input_text)

generate 는 5장 step07 의 함수 그대로입니다 — 온도 기본값 0 이라 탐욕적이고, eos_id=50256 이 "첫 패딩에서 멈추기" 를 실행합니다(미세조정 전 모델은 50256 을 잘 내지 않아 35 토큰을 다 씁니다). 손실은 5장 step03 의 calc_loss_loader 를 7장 로더에 그대로 넣은 것입니다.

실측

hermes(qwen3.8:27b)는 "새로 구현하지 않는다" 제약대로 5장 generate 와 calc_loss_loader 를 import 했고, 훈련 전 응답과 손실 3.826 / 3.762 가 기준 구현·원서와 같았습니다. 파일은 일찍 나왔지만 자체 점검이 355M 모델을 CPU 로 돌리는 일이라 15분 상한에 걸렸습니다 — 6장의 서버 공유와 달리 이번에는 순수하게 점검 자체가 무거운 경우입니다. 하네스가 파일로 검증해 5/5 통과, 첫 시도.

step07. 지시 미세조정 — 5장 훈련 루프를 그대로, 232 번의 갱신

무엇을 — 5장 train_model_simple 을 수정 없이 import 해 GPT-2 중형을 지시 데이터로 2 에포크 훈련하고, 검증 항목 세 개에 답하게 한 뒤 모델을 저장하는 스크립트
왜 — 지시 미세조정은 5장 사전훈련과 같은 "다음 토큰 예측" 이다. 데이터(알파카 형식)와 로더(collate)만 다르고 루프는 같다 — 7장이 새 루프를 쓰지 않는 이유
맥락 — step04 로더·step05 모델·step06 answer 를 잇는다. step08 이 여기서 저장한 모델로 테스트 110 건에 답한다
기대 결과 — 손실 2.637 → 0.294(훈련) / 2.626 → 0.657(검증), 수동태 변환 지시에 The meal is cooked every day by the chef. — 원서와 같은 값. CPU 로 약 19분

실행은 HERMES_TIMEOUT=3600 bash harness/run_loop.sh ch07 prompts/ch07/step07-finetune.md

hermes에게 전달되는 사양 (prompts/ch07/step07-finetune.md)
step07_finetune.py 라는 파이썬 파일을 만들어줘.

만들 것: 5장 훈련 루프로 GPT-2 중형을 지시 데이터에 미세조정하고, 검증 항목에 답하게 한 뒤 저장하는 스크립트.

접점 (직전 스텝들 + 5장 산출물 재사용 + step08 이 import 한다):
- 같은 디렉토리의 step01_download_data.py 에서 load_instruction_data 를, step02_format_split.py 에서 format_input, split_data 를, step04_dataloader.py 에서
  build_dataloaders 를, step05_load_medium.py 에서 build_medium_model, MEDIUM_CONFIG 를, step06_pretrain_eval.py 에서 answer 를 import 한다.
- ../ch05 를 경로에 추가하고 step04_train.py 의 train_model_simple(model, train_loader, val_loader, optimizer, device, num_epochs, eval_freq, eval_iter, start_context, tokenizer)
  을 import 한다. 훈련 루프·손실·생성을 새로 구현하지 않는다.
- 상수 MODEL_FILE = "gpt2-medium355M-sft.pth".
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 장치는 CPU. build_medium_model() 의 모델, build_dataloaders(장치) 의 훈련·검증 로더, split_data(load_instruction_data()) 의 검증 세트를 준비한다. 파이토치 시드 123
고정 직후 AdamW(학습률 0.00005, 가중치 감쇠 0.1)를 만들고 train_model_simple 을 2 에포크, eval_freq 5, eval_iter 5, 시작 문맥은 검증 세트 첫 항목의 format_input 결과로
실행한다. 끝나면 검증 세트 앞 세 항목에 answer(모델, 토크나이저, 항목, max_new_tokens=256) 로 답하고, 모델의 state_dict 를 MODEL_FILE 에 저장한다.

출력 (직접 실행하면 download_weights() 의 출력과 train_model_simple 의 출력(평가 줄·샘플 줄) 뒤에 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 훈련 시간(분): 19.2 처럼 소수점 첫째 자리까지
- 평가 횟수: 47 처럼 반환된 훈련 손실 목록의 길이
- 첫 훈련 손실: 2.637, 마지막 훈련 손실: 0.294 처럼 소수점 셋째 자리까지
- 마지막 검증 손실이 첫 검증 손실보다 작다: 예 처럼 참이면 예, 아니면 아니오
- 마지막 검증 손실이 1.0 보다 작다: 예 처럼 참이면 예, 아니면 아니오
- 항목마다 지시: ..., 정답: ...(output), 응답: ...(줄바꿈은 공백으로) 세 줄씩 — 세 항목
- 저장: gpt2-medium355M-sft.pth 처럼 저장한 파일 이름

지켜야 할 것:
- torch, tiktoken, safetensors 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 그래프 저장, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 기준 구현 — 중간 줄은 생략) ---
gpt2-medium.safetensors 이미 있음
에포크 1 (스텝 000000): 훈련 손실 2.637, 검증 손실 2.626
에포크 1 (스텝 000005): 훈련 손실 1.174, 검증 손실 1.102
...
에포크 2 (스텝 000230): 훈련 손실 0.294, 검증 손실 0.657
샘플: Below is an instruction that describes a task. … ### Response: The meal is cooked every day by the chef.<|endoftext|>The following is an instruction …
훈련 시간(분): 19.2
평가 횟수: 47
첫 훈련 손실: 2.637
마지막 훈련 손실: 0.294
마지막 검증 손실이 첫 검증 손실보다 작다: 예
마지막 검증 손실이 1.0 보다 작다: 예
지시: Convert the active sentence to passive: 'The chef cooks the meal every day.'
정답: The meal is cooked by the chef every day.
응답: The meal is cooked every day by the chef.
지시: Classify an input string as either a noun or a verb.
정답: 'Dance' can be classified as a verb.
응답: Dance is a verb.
지시: Rewrite the sentence using a metaphor.
정답: The book is a page-turner.
응답: The book is like a novel.
저장: gpt2-medium355M-sft.pth
[OK] 7줄 전부 일치 (손실·응답은 대조하지 않음)   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch07/step07_finetune.py, 핵심):

CH05_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "ch05")
sys.path.append(CH05_DIR)
from step04_train import train_model_simple            # 5장 루프 — 한 글자도 안 고친다

model, tokenizer = build_medium_model()
train_loader, val_loader, _ = build_dataloaders(device)
_, val_data, _ = split_data(load_instruction_data())
torch.manual_seed(123)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.00005, weight_decay=0.1)
train_losses, val_losses, tokens_seen = train_model_simple(
    model, train_loader, val_loader, optimizer, device,
    num_epochs=2, eval_freq=5, eval_iter=5, start_context=format_input(val_data[0]), tokenizer=tokenizer)
for entry in val_data[:3]:
    print("응답:", answer(model, tokenizer, entry, max_new_tokens=256).replace("\n", " "))
torch.save(model.state_dict(), MODEL_FILE)

함수 정의가 하나도 없는 스텝입니다. train_model_simple 의 start_context 에 알파카 형식의 프롬프트를 넘기면 에포크 끝 샘플이 "지시에 대한 답" 이 됩니다 — 5장에서 소설 문장을 잇던 그 함수가 형식만 바꾸면 챗봇 시연이 됩니다.

실측

hermes(qwen3.8:27b)는 5장 루프를 import 해 함수 정의 없는 스크립트를 만들었고, 자체 점검으로 훈련 전체(19.5분)를 한 번 돌린 뒤 "이건 ad-hoc 검증이지 테스트 스위트 그린은 아니다" 라는 자기 한정을 달아 응답했습니다. 학생 코드의 손실 궤적·검증 응답 세 건·저장 파일 크기(1,725,957,033 바이트)가 기준 구현과 바이트 단위까지 같았습니다 — CPU 에서 같은 시드·같은 순서면 355M 훈련도 완전히 재현됩니다. 검증에 또 19분 39초, 첫 시도 통과.

step08. 테스트 110 건에 답하기 — 채점용 응답 파일 만들기

무엇을 — 미세조정한 모델을 되살리는 load_finetuned_model(device), 테스트 110 건마다 answer 로 응답을 만들어 항목에 model_response 로 붙이고 JSON 으로 저장하는 스크립트
왜 — 답의 품질은 정확도처럼 한 번에 재지지 않는다. 먼저 모든 테스트 항목의 답을 파일로 모아 두고(이 스텝), 그 파일을 다른 LLM 에게 채점시킨다(step09)
맥락 — step07 의 gpt2-medium355M-sft.pth 와 step06 의 answer 를 쓴다. 테스트 세트는 훈련·검증에 한 번도 쓰이지 않은 110 건이다
기대 결과 — 110 건 전부 응답이 채워진 instruction-data-with-response.json. 앞 세 건: 직유 The car is as fast as a bullet., 구름 cumulus cloud, 저자 Jane Austen — 원서와 같은 값
hermes에게 전달되는 사양 (prompts/ch07/step08-extract-responses.md)
step08_extract_responses.py 라는 파이썬 파일을 만들어줘.

만들 것: 미세조정한 모델을 되살려 테스트 항목 전부에 답하게 하고, 응답을 붙인 JSON 파일을 저장하는 스크립트.

접점 (직전 스텝들 재사용 + step09 가 import 한다):
- 같은 디렉토리의 step01_download_data.py 에서 load_instruction_data 를, step02_format_split.py 에서 split_data 를, step05_load_medium.py 에서 build_medium_model 을,
  step06_pretrain_eval.py 에서 answer 를, step07_finetune.py 에서 MODEL_FILE 을 import 한다.
- 상수 RESPONSE_FILE = "instruction-data-with-response.json".
- 함수 load_finetuned_model(device) — build_medium_model() 의 모델에 MODEL_FILE 의 state_dict 를 넣고(torch.load 에 map_location 과 weights_only=True) device 로 옮겨
  평가 모드로 바꾼 뒤 (모델, 토크나이저) 를 반환한다.
- 이 파일을 import 만 했을 때는 아무것도 출력하지 않는다.

동작: 장치는 CPU. 모델을 되살리고 split_data(load_instruction_data()) 의 테스트 세트 항목마다 answer(모델, 토크나이저, 항목, max_new_tokens=256) 의 결과를 항목 딕셔너리에
키 model_response 로 넣는다. 10 건마다 응답 생성 10/110 처럼 진행을 출력한다. 끝나면 테스트 세트 리스트 전체를 RESPONSE_FILE 에 들여쓰기 4 의 JSON 으로 저장한다.

출력 (직접 실행하면 download_weights() 의 출력 뒤에 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 테스트 항목 수: 110 처럼 테스트 세트 길이
- 진행 줄 응답 생성 10/110 … 응답 생성 110/110
- 앞 세 항목에 대해 지시: ..., 정답: ..., 응답: ...(줄바꿈은 공백으로) 세 줄씩
- 응답이 채워진 항목 수: 110 처럼 model_response 가 빈 문자열이 아닌 항목 수
- 저장: instruction-data-with-response.json 처럼 저장한 파일 이름

지켜야 할 것:
- torch, tiktoken, safetensors 와 표준 라이브러리 외의 패키지는 import 하지 않는다.
- 다른 장 디렉토리의 파일은 고치지 않는다. 모델 파일을 새로 저장하지 않는다.
- 요구한 것 외의 기능(명령행 인자, 예외 처리, 추가 출력)은 넣지 않는다.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 기준 구현 — 진행 줄은 생략) ---
gpt2-medium.safetensors 이미 있음
테스트 항목 수: 110
응답 생성 10/110
...
응답 생성 110/110
지시: Rewrite the sentence using a simile.
정답: The car is as fast as lightning.
응답: The car is as fast as a bullet.
지시: What type of cloud is typically associated with thunderstorms?
정답: The type of cloud typically associated with thunderstorms is cumulonimbus.
응답: The type of cloud associated with thunderstorms is a cumulus cloud.
지시: Name the author of 'Pride and Prejudice'.
정답: Jane Austen.
응답: The author of 'Pride and Prejudice' is Jane Austen.
응답이 채워진 항목 수: 110
저장: instruction-data-with-response.json
[OK] 6줄 전부 일치 (응답 문장은 대조하지 않음)   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch07/step08_extract_responses.py, 핵심):

def load_finetuned_model(device):
    model, tokenizer = build_medium_model()                                   # 구조 + 원래 가중치
    model.load_state_dict(torch.load(MODEL_FILE, map_location=device, weights_only=True))   # 미세조정 가중치로 덮어쓴다
    model.to(device)
    model.eval()
    return model, tokenizer

for i, entry in enumerate(test_data):
    entry["model_response"] = answer(model, tokenizer, entry, max_new_tokens=256)
    if (i + 1) % 10 == 0:
        print(f"응답 생성 {i + 1}/{len(test_data)}")
with open(RESPONSE_FILE, "w", encoding="utf-8") as f:
    json.dump(test_data, f, indent=4)

build_medium_model() 이 원래 가중치를 넣은 뒤 load_state_dict 가 미세조정 가중치로 덮어씁니다 — 두 번 넣는 셈이지만 5장 step06 과 같은 "구조는 코드, 값은 파일" 패턴입니다. 항목 딕셔너리에 키 하나를 더하면 원래 세 필드는 그대로 남아 step09 의 채점 프롬프트가 정답(output)과 응답을 나란히 볼 수 있습니다.

실측

hermes(qwen3.8:27b)는 자체 점검으로 응답 생성 전체를 한 번 실행한 뒤 임시 검증 스크립트를 지우고 응답했습니다. 학생 코드의 응답 110 건과 저장 파일 크기(29,336 바이트)가 기준 구현과 같았습니다 — step07 의 바이트 단위 재현이 여기까지 이어집니다. 검증 5분 5초, 첫 시도 통과.

step09. LLM 심판 — 강좌의 공식 모델이 110 건을 0~100 점으로 채점한다

무엇을 — Ollama 채팅 API 로 qwen3.8:27b 에게 프롬프트 하나를 보내는 query_model, 항목마다 "입력·정답·모델 응답을 보고 0~100 점" 을 묻는 generate_model_scores, 그리고 110 건의 평균 점수
왜 — 문장 답의 품질은 사람이 읽어야 알 수 있는데 110 건을 매번 읽을 수는 없다. 더 큰 LLM 에게 채점 기준을 주고 점수를 받는 것이 요즘의 표준 평가법(LLM-as-a-judge)이다
맥락 — step08 의 응답 파일을 읽고, 이 강좌가 내내 코드 생성에 쓴 모델(qwen3.8:27b)을 이번에는 심판으로 쓴다. 강좌의 마지막 스텝
기대 결과 — 유효 점수 110 건, 평균 32.55 (기준 구현 실측 — 심판이 원서의 llama3 보다 엄격하다), 정답 자체를 응답으로 넣으면 100 점

실행은 HERMES_TIMEOUT=3600 bash harness/run_loop.sh ch07 prompts/ch07/step09-score-responses.md

hermes에게 전달되는 사양 (prompts/ch07/step09-score-responses.md)
step09_score_responses.py 라는 파이썬 파일을 만들어줘.

만들 것: Ollama 서버의 qwen3.8:27b 를 심판으로 삼아 테스트 응답 110 건을 0~100 점으로 채점하고 평균을 내는 스크립트.

접점 (직전 스텝들 재사용):
- 같은 디렉토리의 step02_format_split.py 에서 format_input 을, step08_extract_responses.py 에서 RESPONSE_FILE 을 import 한다.
- 상수 OLLAMA_URL = "http://211.170.162.109:11434", JUDGE_MODEL = "qwen3.8:27b".
- 함수 query_model(prompt, model=JUDGE_MODEL, url=OLLAMA_URL) — url + "/api/chat" 에 JSON {"model": …, "messages": [{"role": "user", "content": prompt}], "stream": false,
  "think": false, "options": {"seed": 123, "temperature": 0, "num_ctx": 2048}} 를 POST 하고(표준 라이브러리 urllib.request, 헤더 Content-Type: application/json, 타임아웃 300초),
  응답 JSON 의 message → content 문자열을 반환한다.
- 함수 score_prompt(entry, json_key) — 다음 문장을 만든다 (백틱 포함, 한 줄):
  Given the input {format_input(entry)} and correct output {entry['output']}, score the model response {entry[json_key]} on a scale from 0 to 100, where 100 is the best score. Respond with the integer number only.
- 함수 generate_model_scores(json_data, json_key, model=JUDGE_MODEL) — 항목마다 score_prompt 로 물어 답의 앞뒤 공백을 뗀 뒤 정수로 바뀌는 것만 리스트에 모은다(안 바뀌면
  점수로 읽을 수 없는 답: … 을 출력하고 건너뜀). 10 건마다 채점 진행 10/110 처럼 진행을 출력한다. 점수 리스트를 반환한다.

동작: url + "/api/tags" 로 서버의 모델 이름 목록을 받아 심판 모델이 있는지 확인한다. RESPONSE_FILE 을 읽고, 첫 항목의 정답·모델 응답·심판의 답을 출력한 뒤, **심판 자기 점검**으로
첫 항목의 정답(output)을 응답 자리에 넣어 점수를 받는다. 그 다음 전체를 model_response 로 채점해 평균을 낸다.

출력 (직접 실행하면 다음을 정확히 이 형식으로, 이 순서로 출력한다):
- 심판 모델 준비: True 처럼 모델 목록에 심판 모델이 있는지 (print("라벨:", 불리언) 꼴)
- 채점 대상: 110 처럼 항목 수
- 정답: ..., 모델 응답: ...(줄바꿈은 공백으로), 심판의 답: 100 처럼 첫 항목의 세 줄
- 정답을 응답으로 넣은 점수: 100 처럼 자기 점검 점수(정수)
- 정답 점수 90 이상: 예 처럼 90 이상이면 예, 아니면 아니오
- 진행 줄 채점 진행 10/110 … 채점 진행 110/110
- 유효 점수 수: 110 처럼 점수 리스트의 길이
- 평균 점수: 32.55 처럼 평균을 소수점 둘째 자리까지
- 유효 점수 100 건 이상: 예 처럼 참이면 예, 아니면 아니오
- 평균 점수 20 이상: 예 처럼 참이면 예, 아니면 아니오

지켜야 할 것:
- 표준 라이브러리만 사용한다 (requests, ollama 패키지 금지).
- 다른 장 디렉토리의 파일은 고치지 않는다. 응답 파일을 고쳐 쓰지 않는다.
- 요구한 것 외의 기능(명령행 인자, 재시도, 추가 출력)은 넣지 않는다. 정수 변환 실패 처리는 위에 적은 것만.
- 파일 맨 위에 이 스크립트가 하는 일을 한국어 주석 한 줄로 적는다.
--- 실제 실행 출력 (실측, 기준 구현 — 진행 줄은 생략) ---
심판 모델 준비: True
채점 대상: 110
정답: The car is as fast as lightning.
모델 응답: The car is as fast as a bullet.
심판의 답: 100
정답을 응답으로 넣은 점수: 100
정답 점수 90 이상: 예
채점 진행 10/110
...
채점 진행 110/110
유효 점수 수: 110
평균 점수: 32.55
유효 점수 100 건 이상: 예
평균 점수 20 이상: 예
[OK] 7줄 전부 일치 (점수 값은 대조하지 않음)   →   ✅ 검증 통과

기준 구현 읽기 (solutions/ch07/step09_score_responses.py, 핵심):

def query_model(prompt, model=JUDGE_MODEL, url=OLLAMA_URL):
    payload = json.dumps({
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "stream": False,
        "think": False,                                          # 추론형 모델의 "생각" 을 끈다 — 정수만 받기 위해
        "options": {"seed": 123, "temperature": 0, "num_ctx": 2048},
    }).encode("utf-8")
    request = urllib.request.Request(url + "/api/chat", data=payload, method="POST")
    request.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(request, timeout=300) as response:
        return json.load(response)["message"]["content"]

def generate_model_scores(json_data, json_key, model=JUDGE_MODEL):
    scores = []
    for i, entry in enumerate(json_data):
        reply = query_model(score_prompt(entry, json_key), model)
        try:
            scores.append(int(reply.strip()))                    # 정수로 읽히는 답만
        except ValueError:
            print("점수로 읽을 수 없는 답:", reply.strip()[:60])
        if (i + 1) % 10 == 0:
            print(f"채점 진행 {i + 1}/{len(json_data)}")
    return scores

0장에서 hermes 가 쓰던 바로 그 서버·모델을 표준 라이브러리 urllib 만으로 부릅니다 — 웹 교재의 AI 조교 챗봇이 브라우저에서 하는 일과 같은 API 입니다. stream: False 라 응답이 JSON 하나로 옵니다(스트리밍이면 줄마다 JSON). try/except 는 사양이 허용한 유일한 예외 처리입니다 — 심판이 "85점" 처럼 답하면 정수 변환이 실패하므로 건너뜁니다.

실측

hermes(qwen3.8:27b)는 think: false·온도 0·시드 123 을 사양대로 넣었고, 학생 코드의 채점 결과도 유효 110 건·평균 32.55 로 기준 구현과 같았습니다 — 같은 심판·같은 옵션이면 채점까지 재현됩니다. 검증은 1분 42초 만에 끝났습니다. 6장 제작 중 같은 채점이 40분 걸렸던 것과 비교하면, 서버를 나눠 쓰는 비용이 얼마나 큰지가 숫자로 보입니다. 첫 시도 통과 — 이로써 7장 아홉 스텝이 전부 첫 시도에 통과했고 사양 수정은 0회, 이 강좌의 마지막 검증이 끝났습니다.

용어 정리

용어원어(약어)한 줄 정의
지시 미세조정instruction fine-tuning"지시 + 응답" 문자열로 다음 토큰 예측을 훈련해 지시를 따르게 만드는 미세조정. 출력층은 그대로
지시 데이터instruction datainstruction · input · output 세 필드의 항목들. 이 장은 1,100 건
알파카 형식Alpaca prompt format머리말 + ### Instruction: + ### Input: + ### Response: 절로 이어 붙이는 프롬프트 형식
머리말preamble형식 맨 앞의 고정 문장("Below is an instruction …")
프롬프트prompt모델에 주는 입력 문자열. 생성 때는 응답 절 앞까지
형식화formatting필드를 구분자가 있는 하나의 문자열로 만드는 일(format_input)
collatecollateDataLoader 가 항목들을 배치 텐서로 묶는 단계. 길이가 다르면 직접 쓴다
동적 패딩dynamic padding배치마다 가장 긴 것에 맞춰 패딩하는 방식(6장의 고정 120 과 대비)
ignore_index (−100)ignore_indexcross_entropy 가 손실에서 빼는 타깃 값. 패딩 자리를 가린다
문장 끝 토큰end-of-text token답을 마쳤음을 뜻하는 토큰(50256). 타깃의 첫 패딩으로 남겨 가르치고, 생성 때 eos_id 로 멈춘다
allowed_max_lengthallowed_max_length모델 문맥(1,024)을 넘지 않게 자르는 상한
functools.partialpartial함수의 인자 일부를 미리 채운 새 함수를 만드는 도구
GPT-2 중형GPT-2 medium (355M)임베딩 1,024·24 층·16 헤드. 출력층을 따로 세면 406M
응답 추출response extraction생성 전체에서 프롬프트를 떼고 ### Response: 표식을 지워 답만 남기기
기준선baseline훈련 전 응답·손실(3.826 / 3.762). 지시를 되풀이하는 상태
에포크epoch훈련 로더 한 바퀴(116 배치). 이 장은 2 에포크 = 232 스텝
과대적합 조짐overfitting sign2 에포크째 검증 손실이 훈련 손실보다 높아지기 시작하는 것. 원서와 같은 이유로 2 에포크에서 멈춘다
응답 파일response file테스트 110 건에 model_response 를 붙여 저장한 JSON
LLM 심판LLM-as-a-judge다른 LLM 에게 채점 기준을 주고 0~100 점을 매기게 하는 평가법
심판 모델judge model채점하는 LLM. 이 강좌는 공식 모델 qwen3.8:27b(Ollama)
채팅 APIchat APIOllama 의 /api/chat. 메시지 리스트를 보내고 응답을 받는다
생각 모드 끄기think: false추론형 모델이 답 앞에 생각을 쓰지 않게 하는 옵션. 정수만 받기 위해 끈다
온도 0 · 시드temperature 0 · seed심판의 답을 최대한 결정적으로 만드는 옵션. 그래도 완전히 같지는 않다
불변식 검증invariant verification값이 흔들리는 평가를 "유효 100 건 이상·평균 40 이상" 같은 조건으로 검증하는 방식
평균 점수mean score유효 점수의 평균(32.55). 절대값이 아니라 같은 심판으로 비교할 때 의미가 있다
합성 데이터synthetic data더 큰 LLM(원서는 GPT-4)이 만들어 준 훈련 데이터. 지시 데이터 1,100 건이 그 예
전체 미세조정full fine-tuning파라미터 전부를 훈련하는 미세조정. 6장(5.7%만)과 달리 7장은 355M 전부를 갱신한다
num_ctxnum_ctxOllama 요청의 문맥 길이 옵션(2048). 심판이 읽을 프롬프트가 잘리지 않게 한다
stream 옵션streamFalse 면 응답이 JSON 하나로, True 면 토큰 줄 단위로 온다. 채점은 False 가 다루기 쉽다
바이트 단위 재현성bitwise reproducibility같은 시드·순서·CPU 면 훈련 결과 파일까지 똑같아지는 성질. 기준 구현과 학생 pth 가 일치했다

정리

  • 지시 미세조정은 출력층을 그대로 두고 "지시 + 응답" 문자열로 5장과 같은 다음 토큰 예측을 훈련한다. 형식(알파카)이 곧 데이터다.
  • 길이가 다른 문장은 collate 가 배치마다 패딩하고, 타깃을 한 칸 밀며, 첫 패딩(문장 끝)만 남기고 나머지를 −100 으로 가린다 — cross_entropy 가 −100 을 뺀다(손계산으로 확인).
  • 5장 이식 함수는 설정 세 줄만 바꾸면 GPT-2 중형(24 층, 406M)에도 그대로 통한다. 5장 훈련 루프·손실 함수·generate 도 수정 없이 7장 로더에서 돈다 — 접점이 곧 재사용.
  • 훈련 전 모델은 지시를 되풀이한다(손실 3.83). 2 에포크·232 스텝 뒤 답을 쓰기 시작하고 검증 손실이 1 아래로 내려간다. 훈련이 개입하므로 계약은 불변식이다.
  • 답의 품질은 정확도로 재지지 않는다. 테스트 110 건을 qwen3.8:27b 에게 채점시키고 평균을 본다 — 채점은 결정적이지 않으므로 "유효 100 건 이상·평균 40 이상" 으로 검증한다.
  • 사양 설계: 데이터·collate·로더는 원서 값을 계약으로, 훈련·채점은 불변식으로. 7장의 새 코드는 형식화·collate·심판 호출 세 가지뿐이다.

강좌를 마치며

0장에서 hermes 에게 첫 사양을 준 뒤 여기까지, 여러분은 코드를 한 줄도 치지 않고 — 대신 사양을 쓰고 동작을 검증하며 — 토크나이저(2장)부터 어텐션(3장), GPT 모델(4장), 사전훈련(5장), 분류 미세조정(6장), 지시 미세조정과 LLM 심판(7장)까지 만들었습니다. 그 과정에서 3장 사양이 두 번 자랐고(4장·5장), 값이 흔들리는 것은 불변식으로 바꿨으며, hermes 가 제약을 어긴 것을 되돌렸습니다. 이 저장소의 prompts/ 가 여러분의 사양이고, logs/runs/ 가 그 사양이 실제로 통과한 기록입니다. 부록 A(파이토치 입문)는 본문에서 쓴 파이토치 기능을 처음부터 정리할 참고서로 예정되어 있습니다(제작 전).