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 ในตัวอย่างที่เล็กพอจะจับต้องได้ทั้งก้อน
code ลงมือของบทนี้อยู่ใน repo kaen-nn-from-scratch (code ตัวอย่างกำลังจัดทำ) file 03_bigram.py — pure Python + NumPy เท่านั้น ไม่มี import torch ตามกติกา framework-quarantine ของบท2–5 รันบน CPU เครื่องเดียวได้ในไม่กี่วินาที
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 บทนี้
ขั้นที่ 0 — stoi / itos กับ off-by-one ที่แบกงานทั้งหมด
หัวข้อที่มีชื่อว่า “ขั้นที่ 0 — stoi / itos กับ off-by-one ที่แบกงานทั้งหมด”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 0itos = {i: s for s, i in stoi.items()} # ตารางย้อนกลับvocab_size = len(stoi) # = 27. คือ token พิเศษที่ใช้เป็นทั้ง ขอบเริ่ม และ ขอบจบ ของชื่อ (เช่น emma มองเป็น .emma.) — มันบอก model ว่า “ตัวแรกของชื่อคืออะไร” และ “ชื่อจบตรงไหน”
ถ้าเขียน 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 ว่างไว้ให้ .
ขั้นที่ 1 — count model: นับตรงๆ ก่อน
หัวข้อที่มีชื่อว่า “ขั้นที่ 1 — count model: นับตรงๆ ก่อน”วิธีที่ตรงไปตรงมาที่สุด: สร้างตาราง 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 สำคัญ!)บาง 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, 0for w in words: chs = ['.'] + list(w) + ['.'] for c1, c2 in zip(chs, chs[1:]): log_likelihood += np.log(P[stoi[c1], stoi[c2]]) n += 1nll = -log_likelihood / nprint(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 เริ่มสุ่ม — นี่คือทั้ง modelforward 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
ขั้นที่ 3 — softmax เสถียรเชิงตัวเลข
หัวข้อที่มีชื่อว่า “ขั้นที่ 3 — softmax เสถียรเชิงตัวเลข”logits เป็นคะแนนดิบ ติดลบก็ได้ บวกก็ได้ เราต้องแปลงเป็นความน่าจะเป็น (บวกทั้งหมด รวมได้ 1) ด้วย SoftmaxSoftmaxแปลงคะแนนดิบ (logits) ให้เป็นความน่าจะเป็นที่รวมได้ 1Process: ยกกำลัง e แล้วหารด้วยผลรวมของแถว แต่ถ้าทำตรงๆ np.exp(logits) จะระเบิดเมื่อ logit ใหญ่ (exp(1000) = inf) เคล็ดลับคือ softmax ไม่แคร์ค่าคงที่ที่บวกทั้งแถว — เราจึงลบ max ของแต่ละแถวออกก่อน ให้ค่าที่ใหญ่สุดกลายเป็น e^0 = 1 พอดี:
logits = xenc @ Wlogits -= logits.max(axis=1, keepdims=True) # STABILITY: ลบ max ต่อแถวcounts = np.exp(logits) # ตอนนี้ไม่มีทาง overflowprobs = counts / counts.sum(axis=1, keepdims=True) # (N, 27) แต่ละแถวรวม = 1logits.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) — คณิตล้วนที่ไม่เน่า
ขั้นที่ 4 — NLL คือ cross-entropy: เครื่องหมายและ shape
หัวข้อที่มีชื่อว่า “ขั้นที่ 4 — NLL คือ cross-entropy: เครื่องหมายและ shape”ตอนนี้ 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 แถว — แต่ละแถวหยิบมาช่องเดียวคือช่องของคำตอบจริง
“เราสอน ‘สัญชาตญาณ’ ของ gradient กับ chain rule พอให้ code มีความหมาย ไม่ใช่พิสูจน์ทฤษฎีบท — อยากลึกเชิงพิสูจน์ เปิด Goodfellow บทที่อ้างไว้; คณิตตรงนี้เป็นพื้นให้ยืน ไม่ใช่กำแพงกั้นทาง”
แปลว่า: ว่าทำไม cross-entropy = maximum likelihood (คำตอบจริงเป็น one-hot → H(p,q) = −log q_y) อยู่ใน Goodfellow §5.5 (maximum likelihood estimation) — คณิตล้วนที่ “นิ่ง” ไม่เน่า เราหยิบมาแค่สัญชาตญาณพอให้ code มีความหมาย ใครอยากได้บทพิสูจน์เต็ม เปิดหัวข้อที่อ้างไว้
ขั้นที่ 5 — gradient descent: ฝึก W เอง
หัวข้อที่มีชื่อว่า “ขั้นที่ 5 — gradient descent: ฝึก W เอง”เรามี 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 — ในการรันครั้งหนึ่งราวๆ นี้ อย่ายึดเป๊ะ)
“เราสร้างเพื่อเข้าใจ ไม่ใช่เพื่อ 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 แล้วทะลุเพดานนี้ทันที
ที่นี่เราคำนวณ dW ใหม่สดทุกก้าว จึงไม่มีปัญหา gradient สะสม แต่พอถึงบท6 ที่ใช้ autograd ของ PyTorch, gradient จะ บวกสะสม ทับของก้าวก่อนโดย design — ต้องสั่ง optimizer.zero_grad() (หรือ W.grad = None) ทุกก้าวก่อน backward() เสมอ ลืมเมื่อไร gradient ของทุกก้าวจะกองรวมกัน อัปเดตเพี้ยนหมด นี่คือ bug class เดียวกับที่บท2 เตือนไว้ (p.grad = 0 ก่อนวนใหม่) — จำหลัก “gradient สดทุกก้าว” ไว้ให้ดี
ขั้นที่ 6 — sampling: ให้ model สร้างชื่อใหม่
หัวข้อที่มีชื่อว่า “ขั้นที่ 6 — sampling: ให้ model สร้างชื่อใหม่”ฝึกเสร็จแล้ว เอาไปใช้ยังไง? 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 ในบทถัดไป
ขั้นที่ 7 — เน็ต “ค้นพบ” count model เดิม
หัวข้อที่มีชื่อว่า “ขั้นที่ 7 — เน็ต “ค้นพบ” count model เดิม”ปิดท้ายด้วยข้อสังเกตที่ลึกที่สุดของบท: เราเดินสองเส้นทาง — นับตรงๆ (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, commit73c3fcc741f0ec104ca850b1fb0df90e7e8d4cde, 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, commit988aa59e4d8fefa526d06f3b453ad116258398d4, 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 แยกต่างหาก?