ข้าม​ไป​ยัง​เนื้อหา

bigram — language model ตัว​แรก: one-hot → softmax → NLL แล้ว​ฝึก​ด้วย gradient descent

บท2 เรา​สร้าง autograd engine (Value) ที่​ไล่ BackpropagationBackpropagationเดิน​ย้อน​จาก loss หา​ว่า weight แต่ละ​ตัว​มี​ส่วน​ผิด​แค่​ไหน (คือ gradient)Process ย้อน​กลับ​เอง​ได้ — แต่​มัน​คำนวณ​ที​ละ​สเกลาร์ ยัง​ไม่ใช่ “model” ที่​ทำ​อะไร​มี​ความหมาย บท​นี้​เรา​จะ​ประกอบ​ชิ้น​ส่วน​พวก​นั้น​ขึ้น​เป็น Language ModelLanguage Modelmodel ที่​ทาย​ตัว​ถัด​ไป​จาก​ที่​ผ่าน​มา — งาน​หลัก​ของ​ทั้ง​คอร์สArchitecture ตัว​แรก: model ที่​รับ​ตัว​อักษร​หนึ่ง​ตัว แล้ว​ทาย​ว่า​ตัว​ถัด​ไป​น่า​จะ​เป็น​อะไร นี่​คือ bigram — model ที่​มอง​แค่ “ตัว​ก่อนหน้า 1 ตัว” เพื่อ​ทาย​ตัว​ถัด​ไป เล็ก​ที่สุด​เท่า​ที่​จะ​ยัง​เรียก​ว่า language model ได้

งาน​ที่​จะ​ให้​มัน​ทำ​คือ สร้าง​ชื่อ​คน จาก dataset names.txt ของ Karpathy (32,033 ชื่อ) เรา​จะ​เดิน​สอง​เส้นทาง​ที่​นำ​ไป​สู่​คำ​ตอบ​เดียวกัน: (1) count model — นับตรงๆ ว่า​ตัว​ไหน​ตาม​หลัง​ตัว​ไหน​บ่อย​แค่​ไหน แล้ว (2) Neural NetworkNeural Networkเอา neuron หลาย​ตัว​มา​ต่อ​กัน​เป็น​ชั้น ๆ ให้​ข้อมูล​ไหล​ผ่านArchitecture ชั้น​เดียว — เดา​ค่า weight แล้ว​ให้ Gradient DescentGradient Descentloop ฝึก: ขยับ weight สวน​ทาง gradient ที​ละ​ก้าว​ให้ loss ลดProcess ฝึก​มัน​จน​ได้​ผล​เท่า​กัน ตอน​จบ​เรา​จะ​เห็น​ว่า​เน็ต “ค้น​พบ” สิ่ง​ที่ count model นับได้ตรงๆ — นี่​คือ​แก่น​ของ​ทั้ง deep learning ใน​ตัวอย่าง​ที่​เล็ก​พอ​จะ​จับ​ต้อง​ได้​ทั้ง​ก้อน

📦 kaen-nn-from-scratch

code ลงมือ​ของ​บท​นี้​อยู่​ใน repo kaen-nn-from-scratch (code ตัวอย่าง​กำลัง​จัด​ทำ) file 03_bigram.pypure Python + NumPy เท่านั้น ไม่มี import torch ตาม​กติกา framework-quarantine ของ​บท2–5 รัน​บน CPU เครื่อง​เดียว​ได้​ใน​ไม่​กี่​วินาที

⚠️ code บท​นี้​ตั้งใจ deviate จาก Karpathy

notebook makemore_part1_bigrams.ipynb ของ Karpathy (S1) เขียน​ด้วย PyTorch ตั้งแต่​ต้น — ใช้ torch.tensor, F.one_hot, loss.backward() แต่​คอร์ส​นี้​กัน framework ออก​จาก​บท2–5 ไว้​ก่อน (เพื่อ​ให้​คุณ​เห็น “ของ​จริง” ก่อน​ที่ PyTorch จะ​ทำให้​ทุก​อย่าง​ใน​บท6) เรา​จึง เขียน​ใหม่​เป็น pure NumPy และ​คำนวณ gradient ของ softmax+cross-entropy ด้วย​มือ (มันคลีนมาก เดี๋ยว​ได้​เห็น) — คณิต​เหมือนกันเป๊ะ​ทุก​บรรทัด ต่าง​แค่​เครื่องมือ อย่า copy-paste จาก notebook ของ​เขา​มา​ปน​กับ code บท​นี้

model ไม่รู้จัก “ตัว​อักษร” มัน​รู้จัก​แต่​ตัวเลข (index) เรา​จึง​ต้อง​มี​ตาราง​แปลง​ไป-กลับ: stoi (string→int) และ itos (int→string) จุด​ที่​พลาด​กัน​บ่อย​ที่สุด​คือ การ​จอง​ช่อง index 0 ให้ token พิเศษ:

import numpy as np
words = open('names.txt').read().splitlines() # 32,033 ชื่อ
chars = sorted(set(''.join(words))) # ['a', 'b', ..., 'z'] = 26 ตัว
stoi = {s: i + 1 for i, s in enumerate(chars)} # a→1, b→2, ..., z→26 (สังเกต +1)
stoi['.'] = 0 # token พิเศษ '.' → index 0
itos = {i: s for s, i in stoi.items()} # ตารางย้อนกลับ
vocab_size = len(stoi) # = 27

. คือ token พิเศษ​ที่​ใช้​เป็น​ทั้ง ขอบ​เริ่ม และ ขอบ​จบ ของ​ชื่อ (เช่น emma มอง​เป็น .emma.) — มัน​บอก model ว่า “ตัว​แรก​ของ​ชื่อ​คือ​อะไร” และ “ชื่อ​จบ​ตรง​ไหน”

❌ bug คลาสสิก: ลืม +1

ถ้า​เขียน stoi = {s: i for i, s in enumerate(chars)} (ไม่​บวก​หนึ่ง) แล้ว​ค่อย​ตั้ง stoi['.'] = 0 — ตัว a ก็ได้ index 0 เท่ากับ . พอดี 2 token ชน​กัน​ที่​ช่อง​เดียว model จะ​แยก “จุด​เริ่ม​ชื่อ” ออก​จาก​ตัว a ไม่​ได้​เลย และ vocab จะ​เหลือ 26 ไม่ใช่ 27 นี่​คือ off-by-one ที่​แบก​ความ​ถูกต้อง​ของ​ทั้ง​บท​ไว้ ต้อง​ให้​ตัว​อักษร​ได้ 1..26 และ​ปล่อย 0 ว่าง​ไว้​ให้ .

วิธี​ที่​ตรง​ไป​ตรง​มา​ที่สุด: สร้าง​ตาราง N ขนาด 27×27 แล้ว​นับ​ว่า “ตัว c1 ตาม​ด้วย​ตัว c2” เกิด​ขึ้น​กี่​ครั้ง​ใน dataset:

N = np.zeros((27, 27), dtype=np.int64)
for w in words:
chs = ['.'] + list(w) + ['.'] # emma → . e m m a .
for c1, c2 in zip(chs, chs[1:]): # ไล่ทีละคู่ติดกัน
N[stoi[c1], stoi[c2]] += 1

แถว​ที่ i ของ N คือ “ถ้า​ตัว​ปัจจุบัน​คือ itos[i] ตัว​ถัด​ไป​เป็น​อะไร​บ้าง กี่​ครั้ง” เรา​แปลง​แต่ละ​แถว​เป็น ความ​น่า​จะ​เป็น ด้วย​การ​หาร​ด้วย​ผล​รวม​ของ​แถว แต่​ก่อน​หาร​ต้อง บวก​หนึ่ง (Laplace smoothing) เสีย​ก่อน:

P = (N + 1).astype(np.float64) # +1 = smoothing (กันช่องที่นับได้ 0)
P /= P.sum(axis=1, keepdims=True) # แต่ละแถวรวมได้ = 1 (keepdims สำคัญ!)
❌ กับดัก log(0): ทำไม​ต้อง +1

บาง bigram ไม่​เคย​โผล่​ใน dataset เลย (เช่น ไม่มี​ชื่อ​ไหน​ขึ้น​ต้น​ด้วย .q ตาม​ด้วย​บาง​ตัว) → ช่อง​นั้น​ใน N = 0 → ความ​น่า​จะ​เป็น = 0 พอ​เรา​วัด​คุณภาพ​ด้วย log(prob) จะ​เจอ log(0) = -inf แล้ว loss พุ่ง​เป็น inf ทั้ง​ก้อน การ +1 ทุก​ช่อง ก่อน​หาร (Laplace / add-one smoothing) ดัน​ความ​น่า​จะ​เป็น​ทุก​ช่อง​ให้​มากกว่า 0 นิด​หนึ่ง — ปัญหา​หาย​ทันที ตัวเลข smoothing ยิ่ง​มาก แถว​ยิ่ง “เรียบ” เข้า​ใกล้​แจกแจง​เท่า​กัน (จำหลัก​นี้​ไว้ เดี๋ยว​มัน​กลับ​มา​ใน​รูป L2 regularization)

วัด​คุณภาพ​ของ count model ด้วย average negative log likelihood — ยิ่ง model ทาย​คำ​ตอบ​จริง​ได้​ความ​น่า​จะ​เป็น​สูง ค่า​นี้​ยิ่ง​ต่ำ:

log_likelihood, n = 0.0, 0
for w in words:
chs = ['.'] + list(w) + ['.']
for c1, c2 in zip(chs, chs[1:]):
log_likelihood += np.log(P[stoi[c1], stoi[c2]])
n += 1
nll = -log_likelihood / n
print(nll) # ≈ 2.4541

ค่า​นี้ — ≈2.4541 — คือ พื้น (floor) ของ bigram บน names.txt เก็บ​เลข​นี้​ไว้​ใน​ใจ เพราะ​เดี๋ยว​เน็ต​ที่​เรา​จะ​ฝึก​ต้อง​ไล่​ให้​ทัน

ขั้น​ที่ 2 — เปลี่ยน​มุมมอง: count model คือ neural network ชั้น​เดียว

หัวข้อ​ที่​มีชื่อ​ว่า “ขั้น​ที่ 2 — เปลี่ยน​มุมมอง: count model คือ neural network ชั้น​เดียว”

นี่​คือ​หัวใจ​ของ​บท count model นับ probability ตรงๆ แต่​มี​อีก​ทาง​ที่​ให้​ผล​เดียวกัน: เดา ค่า weight เป็น​ตาราง W ขนาด 27×27 แล้ว​ให้ model ค่อยๆ ปรับ W เอง​จน​แถว​ของ​มัน​กลาย​เป็น probability ที่​ดี วิธี​ที่2 generalize ไป​สู่​เน็ต​ที่​ใหญ่​กว่า bigram ได้ (บท​หน้า​เป็นต้น​ไป) ส่วน count model ไป​ต่อ​ไม่​ได้

การ​ป้อน “index ของ​ตัว​อักษร” เข้า​เน็ต​แบบดิบๆ ไม่​เวิร์ก (เลข 5 ไม่​ได้ “มากกว่า” เลข 3 ใน​เชิง​ความหมาย) เรา​จึง​แปลง​เป็น one-hot vector — เวกเตอร์​ยาว 27 ที่​เป็น 0 หมด​ยกเว้น​ช่อง​ของ​ตัว​อักษร​นั้น​เป็น 1:

# สร้าง training set: ทุกคู่ (ตัวปัจจุบัน xs, ตัวถัดไป ys)
xs, ys = [], []
for w in words:
chs = ['.'] + list(w) + ['.']
for c1, c2 in zip(chs, chs[1:]):
xs.append(stoi[c1]); ys.append(stoi[c2])
xs = np.array(xs) # (N,) index ของตัวปัจจุบัน
ys = np.array(ys) # (N,) index ของ "คำตอบจริง" ตัวถัดไป
num = xs.shape[0] # รวมราว 228,146 คู่ (พิมพ์ดูเองได้)
# one-hot: index → เวกเตอร์ที่มี 1 อยู่ตำแหน่งเดียว
xenc = np.zeros((num, 27))
xenc[np.arange(num), xs] = 1.0 # (N, 27)
W = np.random.randn(27, 27) # weight เริ่มสุ่ม — นี่คือทั้ง model

forward pass ของ​เน็ต​ชั้น​เดียว​คือ​คูณ one-hot ด้วย W:

logits = xenc @ W # (N, 27) — คะแนนดิบต่อ 27 ตัวเลือก

เกร็ด​ที่​สวยงาม: xenc เป็น one-hot ดังนั้น xenc @ W = การ​เลือก​แถว​ที่ xs[i] ออก​มา​จาก W ตรงๆ — แถว​ที่ i ของ W จึง​เล่น​บท​เดียว​กับ​แถว​ที่ i ของ​ตาราง count N เป๊ะ นี่​คือ​เหตุผล​เชิง​คณิต​ว่า​ทำไม​สอง​เส้นทาง​ถึง​บรรจบ​กัน

flowchart TB
  IX["index ตัวปัจจุบัน<br/>เช่น 'e' → 5"]
  OH["one-hot xenc<br/>เวกเตอร์ยาว 27<br/>(1 ที่ช่อง 5, ที่เหลือ 0)"]
  LG["logits = xenc @ W<br/>(N, 27) คะแนนดิบ"]
  SM["stable softmax<br/>ลบ max ต่อแถว → exp → หารผลรวม<br/>(N, 27) รวมแต่ละแถว = 1"]
  PY["หยิบความน่าจะเป็นของ<br/>คำตอบจริง ys<br/>probs[arange, ys]"]
  NL["NLL = −mean(log ...)<br/>loss เป็นบวก → minimize"]
  IX --> OH --> LG --> SM --> PY --> NL

  classDef step fill:#fde68a,stroke:#92400e,color:#451a03;
  classDef loss fill:#7c2d12,stroke:#431407,color:#fed7aa;
  class IX,OH,LG,SM,PY step;
  class NL loss;

คำ​บรรยาย​ภาพ: เส้นทาง forward ของ bigram net ชั้น​เดียว — index ตัว​ปัจจุบัน​ถูก​ทำ​เป็น one-hot ยาว 27 คูณ​ด้วย weight W ได้ logits (คะแนน​ดิบ) ผ่าน softmax เสถียร (ลบ max ต่อ​แถว แล้ว exp แล้ว​หาร​ผล​รวม) กลาย​เป็น​ความ​น่า​จะ​เป็น​ที่​รวม​แต่ละ​แถว​ได้ 1 จาก​นั้น​หยิบ​เฉพาะ​ช่อง​ของ​คำ​ตอบ​จริง ys มา​เข้า​สูตร negative log likelihood ได้ loss ที่​เป็น​บวก​ซึ่ง​เรา​จะ​ทำให้​เล็ก​ลง ทุก​กล่อง​เป็น NumPy ล้วน ไม่มี torch

logits เป็น​คะแนน​ดิบ ติดลบ​ก็ได้ บวก​ก็ได้ เรา​ต้อง​แปลง​เป็น​ความ​น่า​จะ​เป็น (บวก​ทั้งหมด รวม​ได้ 1) ด้วย SoftmaxSoftmaxแปลง​คะแนน​ดิบ (logits) ให้​เป็น​ความ​น่า​จะ​เป็น​ที่​รวม​ได้ 1Process: ยก​กำลัง e แล้ว​หาร​ด้วย​ผล​รวม​ของ​แถว แต่​ถ้าทำตรงๆ np.exp(logits) จะ​ระเบิด​เมื่อ logit ใหญ่ (exp(1000) = inf) เคล็ด​ลับ​คือ softmax ไม่​แคร์​ค่า​คงที่​ที่​บวก​ทั้ง​แถว — เรา​จึง​ลบ max ของ​แต่ละ​แถว​ออก​ก่อน ให้​ค่าที่​ใหญ่​สุด​กลาย​เป็น e^0 = 1 พอดี:

logits = xenc @ W
logits -= logits.max(axis=1, keepdims=True) # STABILITY: ลบ max ต่อแถว
counts = np.exp(logits) # ตอนนี้ไม่มีทาง overflow
probs = counts / counts.sum(axis=1, keepdims=True) # (N, 27) แต่ละแถวรวม = 1
❌ keepdims=False: bug ที่​เงียบ​และ​ร้าย​ที่สุด​ของ​บท​นี้

logits.max(axis=1, keepdims=True) ให้ shape (N, 1) — broadcast กลับ​ไป​ลบ ต่อ​แถว ถูกต้อง แต่​ถ้า​เผลอ​เขียน keepdims=False จะ​ได้ shape (N,) ซึ่ง NumPy จะ broadcast ผิด​แกน (ไป​จับ​กับ column) ผล​คือ softmax normalize ผิด​ทิศ code ไม่ error แต่​คำ​ตอบ​ผิด​เงียบๆ — loss ดู​แปลกๆ แต่​หา​สาเหตุ​ยาก จำ​ไว้: ทั้ง .max(...) และ .sum(...) ใน​สูตร​นี้​ต้อง​ใส่ keepdims=True เสมอ

รายละเอียด​ว่า​ทำไม “ลบ​ค่า​คงที่​แล้ว softmax ไม่​เปลี่ยน” อยู่​ใน Goodfellow §4.1 (numerical computation — overflow/underflow) — คณิต​ล้วน​ที่​ไม่​เน่า

ตอน​นี้ probs บอกว่า model คิด​ว่า​ตัว​ถัด​ไป​น่า​จะ​เป็น​อะไร​ด้วย​ความ​มั่นใจ​เท่าไร เรา​วัด “ความ​ผิด” ด้วย negative log likelihood คือ Cross-EntropyCross-Entropy(aka NLL / negative log likelihood) วัด​ระยะ​ห่าง​ระหว่าง​ที่​ทาย​กับ​คำ​ตอบ​จริง; ยิ่ง​ทาย​มั่น​และ​ถูก ยิ่ง​ต่ำProcess (สอง​ชื่อ ของ​เดียวกัน​เมื่อ​คำ​ตอบ​จริง​เป็น one-hot): หยิบ​ความ​น่า​จะ​เป็น​ที่ model ให้​กับ คำ​ตอบ​จริง ys ของ​แต่ละ​ตัวอย่าง ใส่ log แล้ว​เติม​เครื่องหมาย​ลบ เฉลี่ย​ทั้ง​ชุด:

loss = -np.log(probs[np.arange(num), ys]).mean() # [[Loss Function]] เป็นสเกลาร์

ทำไม​ต้อง​มี​เครื่องหมาย​ลบ? เพราะ prob ∈ (0, 1) ทำให้ log(prob) < 0 เสมอ เติม​ลบ​เข้าไป loss จึง เป็น​บวก และ​เรา​ต้องการ minimize มัน — ยิ่ง model ให้​ความ​น่า​จะ​เป็น​กับ​คำ​ตอบ​จริง​สูง (prob → 1) log ยิ่ง​เข้า​ใกล้ 0 loss ยิ่ง​เล็ก

❌ ตัด​เครื่องหมาย​ลบ = กลับ​ทิศ​เงียบๆ

ถ้า​ลืม​เครื่องหมาย​ลบ (loss = np.log(...).mean()) code ยัง​รัน​ได้​ปกติ ไม่ error แต่​ตอน gradient descent มัน​จะ maximize ค่า​นี้​แทน = ดัน​ความ​น่า​จะ​เป็น​ของ​คำ​ตอบ​จริง​ให้​เข้า​ใกล้ 0 model ยิ่ง​ฝึก​ยิ่ง​แย่​ลง เครื่องหมาย​ลบ​ตัว​เดียว​คือ​เส้น​แบ่ง​ระหว่าง “เรียนรู้” กับ “เรียน​กลับ​หัว”

shape ที่​ต้อง​จำ (จะ​ซ้ำ​ไป​ถึง​บท6 ตอน​ใช้ F.cross_entropy):

ตัวแปรshapeคือ​อะไร
logits(N, 27)คะแนน​ดิบ (ยัง​ไม่ softmax)
ys(N,)index ของ class ที่​ถูก (ไม่ใช่ one-hot)
lossสเกลาร์ตัวเลข​เดียว​ที่​เรา​จะ​ทำให้​เล็ก​ลง

probs[np.arange(num), ys] คือ advanced indexing: หยิบ probs[0, ys[0]], probs[1, ys[1]], … พร้อม​กัน​ทั้ง N แถว — แต่ละ​แถว​หยิบ​มา​ช่อง​เดียว​คือ​ช่อง​ของ​คำ​ตอบ​จริง

เส้น D — คณิต​เป็น​พื้น​ให้​ยืน ไม่ใช่​กำแพง​กั้น​ทาง

“เรา​สอน ‘สัญชาตญาณ’ ของ gradient กับ chain rule พอ​ให้ code มี​ความหมาย ไม่ใช่​พิสูจน์​ทฤษฎีบท — อยาก​ลึก​เชิง​พิสูจน์ เปิด Goodfellow บท​ที่​อ้าง​ไว้; คณิต​ตรง​นี้​เป็น​พื้น​ให้​ยืน ไม่ใช่​กำแพง​กั้น​ทาง”

แปล​ว่า: ว่า​ทำไม cross-entropy = maximum likelihood (คำ​ตอบ​จริง​เป็น one-hot → H(p,q) = −log q_y) อยู่​ใน Goodfellow §5.5 (maximum likelihood estimation) — คณิต​ล้วน​ที่ “นิ่ง” ไม่​เน่า เรา​หยิบ​มา​แค่​สัญชาตญาณ​พอ​ให้ code มี​ความหมาย ใคร​อยาก​ได้​บท​พิสูจน์​เต็ม เปิด​หัวข้อ​ที่​อ้าง​ไว้

เรา​มี loss ที่​เป็น function ของ W แล้ว งาน​ของ gradient descent คือ​หา GradientGradientทิศ+ขนาด​ที่ loss จะ​เพิ่ม​ถ้า​ขยับ weight นิด​หนึ่ง; เรา​ขยับ​สวน​ทาง​มันProcess dW (ทิศ​ที่​ทำให้ loss เพิ่ม) แล้ว​ขยับ W สวน​ทาง มัน​ที​ละ​ก้าว​เล็กๆ

gradient ของ softmax+cross-entropy เทียบ​กับ logits คลี​นอ​ย่าง​น่า​ตกใจ — มัน​คือ probs ลบ​ด้วย one-hot ของ​คำ​ตอบ​จริง แล้ว​เฉลี่ย จาก​นั้น​ย้อน​ผ่าน logits = xenc @ W ได้ dW:

lr = 50 # learning rate (bigram net ชอบ lr สูง)
reg = 0.01 # ความแรงของ L2 regularization
for step in range(200):
# --- forward ---
logits = xenc @ W
logits -= logits.max(axis=1, keepdims=True)
counts = np.exp(logits)
probs = counts / counts.sum(axis=1, keepdims=True)
loss = -np.log(probs[np.arange(num), ys]).mean() + reg * (W**2).mean()
# --- backward (คำนวณ dW สดใหม่ทุกก้าว) ---
dlogits = probs.copy()
dlogits[np.arange(num), ys] -= 1 # probs − one-hot(คำตอบจริง)
dlogits /= num # เฉลี่ยทั้ง batch
dW = xenc.T @ dlogits + reg * 2.0 / W.size * W # + gradient ของ reg
# --- update: ขยับสวนทาง gradient ---
W -= lr * dW
if step % 20 == 0:
print(step, float(loss))

loss จะ​เริ่ม​สูง (จุด​อ้างอิง​เชิง​ทฤษฎี: ถ้า logits เท่า​กัน​หมด softmax แจกแจง​เท่า​กัน loss = −ln(1/27) ≈ 3.2958; W สุ่ม​มัก​เริ่ม​สูง​กว่า​นั้น​นิดหน่อย) แล้ว​ค่อยๆ ลด​ลง​เข้า​ใกล้ ≈2.45–2.47ไล่​ทัน​พื้น 2.4541 ของ count model (ตัวเลข​ต่อ​ก้าว​ที่​คุณ​เห็นจะ​ขยับ​เล็กน้อย​ตาม seed — ใน​การ​รัน​ครั้ง​หนึ่ง​ราวๆ นี้ อย่า​ยึด​เป๊ะ)

เส้น B — สร้าง​เพื่อ​เข้าใจ ไม่ใช่​เพื่อ deploy

“เรา​สร้าง​เพื่อ​เข้าใจ ไม่ใช่​เพื่อ deploy — micrograd กับ nano-GPT ตัว​จิ๋ว​ใน​คอร์ส​นี้​รัน​บน CPU เครื่อง​เดียว​ได้; แต่ LLM จริง​กิน​ทั้ง GPU cluster, ข้อมูล​ระดับ​เท​ระ, เวลา​เป็น​เดือน — สเกล​ที่​คอร์ส​นี้ ‘ไม่’ จำลอง”

แปล​ว่า: bigram net 27×27 ตัว​นี้​คือ ทั้ง​ไอเดีย ของ​การ​ฝึก model (forward → วัด loss → หา gradient → ขยับ weight) ครบ​ทุก​ชิ้น แต่​มัน​ไม่ใช่ language model จริง — มัน​มอง​ย้อน​แค่​ตัว​เดียว จำ context ไม่​ได้​เลย เลข 2.45 คือ​เพดาน​ของ “มอง​ตัว​เดียว” ไม่ใช่​ขีด​จำกัด​ของ deep learning บท​หน้า​เรา​ขยาย context แล้ว​ทะลุ​เพดาน​นี้​ทันที

❌ กับดัก reset grad (จะ​กลับ​มา​เต็มๆ ในบท6)

ที่​นี่​เรา​คำนวณ dW ใหม่​สด​ทุก​ก้าว จึง​ไม่มี​ปัญหา gradient สะสม แต่​พอ​ถึง​บท6 ที่​ใช้ autograd ของ PyTorch, gradient จะ บวก​สะสม ทับ​ของ​ก้าว​ก่อน​โดย design — ต้อง​สั่ง optimizer.zero_grad() (หรือ W.grad = None) ทุก​ก้าว​ก่อน backward() เสมอ ลืม​เมื่อไร gradient ของ​ทุก​ก้าว​จะ​กอง​รวม​กัน อัปเดต​เพี้ยน​หมด นี่​คือ bug class เดียว​กับ​ที่​บท2 เตือน​ไว้ (p.grad = 0 ก่อน​วน​ใหม่) — จำหลัก “gradient สด​ทุก​ก้าว” ไว้​ให้​ดี

ฝึก​เสร็จ​แล้ว เอา​ไป​ใช้​ยังไง? SamplingSamplingสุ่ม​ตัว​ถัด​ไป​จาก​ความ​น่า​จะ​เป็น​ที่ model ให้ เพื่อ "สร้าง" ข้อความ​ใหม่Process — เริ่ม​จาก token . แล้ว​วน​ทาย​ตัว​ถัด​ไป สุ่ม จาก​แถว​ความ​น่า​จะ​เป็น​ที่ softmax ให้ (ไม่ใช่​หยิบ​ตัว​ที่​น่า​จะ​เป็น​ที่สุด​แบบ argmax — argmax จะ​ให้​ชื่อ​ซ้ำๆ ตัว​เดียว​ทุก​ครั้ง) จนกว่า​จะ​สุ่ม​เจอ . อีก​ครั้ง = ชื่อ​จบ:

for _ in range(5): # สร้าง 5 ชื่อ
ix = 0 # เริ่มที่ '.' (index 0)
out = []
while True:
xenc = np.zeros((1, 27)); xenc[0, ix] = 1.0
logits = xenc @ W
logits -= logits.max(axis=1, keepdims=True)
counts = np.exp(logits)
p = (counts / counts.sum(axis=1, keepdims=True))[0] # (27,) แถวความน่าจะเป็น
ix = np.random.choice(27, p=p) # สุ่มตาม p — ไม่ใช่ argmax
if ix == 0: # สุ่มเจอ '.' → จบชื่อ
break
out.append(itos[ix])
print(''.join(out))

ชื่อ​ที่​ได้​จาก bigram จะ ดูมั่วๆ — อ่าน​ออก​เสียง​ได้​บ้าง​แต่​ไม่​เหมือน​ชื่อ​จริง​เท่าไร (เพราะ​มอง​ย้อน​แค่​ตัว​เดียว) นั่น​คือ​สิ่ง​ที่​คาด​ไว้ และ​เป็น​แรง​จูงใจ​ให้​เรา​ขยาย context ใน​บท​ถัด​ไป

ปิด​ท้าย​ด้วย​ข้อสังเกต​ที่​ลึก​ที่สุด​ของ​บท: เรา​เดิน​สอง​เส้นทาง — นับตรงๆ (count) กับ ฝึก weight (net) — แล้ว ได้​ผล​เท่า​กัน (loss ≈2.45 ทั้ง​คู่) นี่​ไม่ใช่​เรื่อง​บังเอิญ: gradient descent ค่อยๆ ดัน​แถว softmax ของ W ให้​เข้า​ใกล้​ตาราง probability ที่ count นับ​ได้ — เน็ต ค้น​พบ สิ่ง​ที่​การ​นับ​ให้​มาฟรีๆ

แล้ว L2 regularization (reg * (W**2).mean()) เล่น​บท​อะไร? มัน​ดึง W ให้​เข้า​ใกล้ 0 → logits เข้า​ใกล้​กัน → softmax เข้า​ใกล้ แจกแจง​เท่า​กัน ซึ่ง​เป็น​ผล​เดียว​กับ Laplace smoothing ที่​เรา +1 ใน​ตาราง count เป๊ะ: reg แรง ↔ smoothing มาก (แถว​เรียบ​ขึ้น มั่นใจ​น้อย​ลง), reg เบา ↔ smoothing น้อย สอง​ปุ่ม​นี้​คือ​ปุ่ม​เดียวกัน​ใน​คนละ​มุมมอง

นี่​คือ​บทเรียน​ที่​จะ​ติดตัว​ไป​ทั้ง​คอร์ส: model ที่​ฝึก​ด้วย gradient descent ไม่​ได้​ทำ​อะไร​วิเศษ — มัน​แค่​ค้นหา​คำ​ตอบ​ที่​บางที​เราคำนวณตรงๆ ได้​อยู่​แล้ว เพียง​แต่​พอ​ปัญหา​ใหญ่​เกิน​กว่า​จะ “นับ” ได้ (เช่น มอง​ย้อน 3 ตัว, 8 ตัว, ทั้ง​ประโยค) การ​ฝึก weight คือ​ทาง​เดียว​ที่​เหลือ — และ​นั่น​คือ​เรื่อง​ของบทถัดๆ ไป

เรา​สร้าง language model ตัว​แรก​จาก​ศูนย์​สอง​เส้นทาง​ที่​บรรจบ​กัน: count model (นับ + Laplace smoothing → floor ≈2.4541) และ neural network ชั้น​เดียว (one-hot → @W → stable softmax → NLL → gradient descent → ≈2.45) กับดัก​ที่​ต้อง​จำ: off-by-one ของ stoi (.→0, vocab 27), log(0) ที่ smoothing/reg แก้, เครื่องหมาย​ลบ​ของ NLL ที่​ทำให้ loss เป็น​บวก, keepdims=True ที่​กัน softmax normalize ผิด​แกน, และ​หลัก “gradient สด​ทุก​ก้าว” ที่​จะ​กลาย​เป็น zero_grad() ในบท6 ปิด​ท้าย​ด้วย​ความ​จริง​ที่​ว่า​เน็ต​แค่ ค้น​พบ count model เดิม และ L2 reg คือ smoothing ใน​อีก​รูป​หนึ่ง

บท​หน้า​เรา​ทลาย​เพดาน “มอง​ตัว​เดียว”: ขยาย context เป็น 3 ตัว แล้ว​สร้าง EmbeddingEmbeddingตาราง weight ที่​แปลง token เป็น​เวกเตอร์​หนาแน่น​ที่​เรียนรู้​ได้ (สร้าง​ด้วย​มือ​ที่​นี่ เพื่อ​เห็น​ว่า​เป็น​แค่ lookup table)Architecture ด้วย​มือ — ตาราง weight ที่​แปลง token เป็น​เวกเตอร์​หนาแน่น (ตาม​เปเปอร์ Bengio 2003) นี่​คือ MLP ตัว​แรก​ของ​คอร์ส และ​เป็น​จุด​ที่ loss จะ​ทะลุ 2.45 ลง​ไป​ได้​จริง


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • Karpathy — nn-zero-to-hero: makemore_part1_bigrams.ipynb (S1, commit 73c3fcc741f0ec104ca850b1fb0df90e7e8d4cde, 2024-02-20; เข้าถึง 2026-07-23) — ต้นทาง​ของ bigram/count/net; code ต้นฉบับ​เป็น PyTorch เรา​เขียน​ใหม่​เป็น pure NumPy ตาม quarantine ของ​บท2–5 (เส้น A: repo ที่​ยัง​มี​ชีวิต จึง​อ้าง​ด้วย commit SHA + วัน​ที่​ดึง)
  • Karpathy — makemore (names.txt, 32,033 ชื่อ) (S3, commit 988aa59e4d8fefa526d06f3b453ad116258398d4, 2022-11-20; เข้าถึง 2026-07-23) — dataset ที่​ใช้​ทั้ง​บท
  • Goodfellow, Bengio, Courville — Deep Learning (MIT Press, 2016) (S8, §4.1 numerical computation / §5.5 maximum likelihood) — ตำรา​คณิต​ที่ “นิ่ง” ใช้​อ้าง​เฉพาะ​คณิต​ไม่​เน่า (softmax stability, cross-entropy = MLE) ติด​ป้าย “stale-on-modern-practice” ทุก​จุด​ที่​แตะ​ของ​หลัง​ปี 2016 (เส้น A/D)

เช็กความเข้าใจ — บทที่ 3

ข้อ 1 / 3

ทำไม code ถึงตั้ง stoi ของตัวอักษรเป็น {s: i+1} (บวกหนึ่ง) แล้วค่อยตั้ง stoi['.'] = 0 แยกต่างหาก?