micrograd — สร้าง autograd engine แล้วไล่ backprop ย้อนกลับด้วยมือ
บทที่แล้ววางแผนที่ไว้แล้ว — บทนี้เริ่มลงมือ เราจะสร้าง AutogradAutogradgraph ที่จำว่าค่าไหนมาจากค่าไหน (computational graph) แล้วไล่ chain rule ให้อัตโนมัติ — คือสิ่งที่ micrograd เป็นProcess engine ตัวแรกด้วยมือ นั่นคือ micrograd ของ Karpathy: code ~150 บรรทัดที่ทำสิ่งเดียวกับหัวใจของ PyTorch — จำว่าค่าไหนมาจากค่าไหน แล้วไล่ BackpropagationBackpropagationเดินย้อนจาก loss หาว่า weight แต่ละตัวมีส่วนผิดแค่ไหน (คือ gradient)Process ย้อนกลับหา gradient ให้อัตโนมัติ ทั้งหมดเป็น pure Python ไม่มี framework (ไม่มี import torch แม้แต่บรรทัดเดียว — โควตานี้จะคงไปจนถึงบท5)
ที่พิเศษของ micrograd คือมันเล็กพอจะอ่านจบทั้ง file ในหนึ่งนั่ง แต่ครบพอจะเทรนเน็ตจริงได้ พอเข้าใจ 150 บรรทัดนี้ คุณจะเข้าใจว่า loss.backward() ในบท6–8 (และใน model ที่ agent ของคุณเรียกผ่าน API) จริงๆ แล้วทำอะไร ข้างใน
code ลงมือของบทนี้อยู่ใน repo kaen-nn-from-scratch (code ตัวอย่างกำลังจัดทำ) ที่ folder 02-micrograd/ — pure Python ล้วน รันด้วย python 02-micrograd/engine_demo.py ได้เลย ไม่ต้องลง dependency อะไร class Value ที่ลงในบทนี้คือของจริงจาก micrograd (engine.py) แบบไล่บรรทัดตรง — คุณ copy ไปวางแล้วรันตามได้ทันที
“ของสดใช้ repo, ของนิ่งใช้ตำรา — code ของ Karpathy คือ repo ที่ยังมีชีวิต เราจึงอ้างด้วย commit SHA + วันที่ดึง ไม่ใช่เลขรุ่น; ส่วนคณิตที่ไม่เน่าเราอ้าง Goodfellow-Bengio-Courville (MIT Press, 2016)”
แปลว่า: micrograd คือ counter-example ที่ “นิ่ง” ของเส้นนี้ — commit ล่าสุดของมันคือ c911406e ลงวันที่ 2020-04-18 และไม่ขยับมาราว 6 ปี เราจึง pin มันได้สนิท (code ในบทนี้ = file นั้นเป๊ะ) ต่างจาก PyTorch ในบท6 ที่ออกรุ่นใหม่ทุกไตรมาส — ตัว code สด เราถึงอ้างด้วย SHA + วันที่ดึง ไม่ใช่เลขรุ่น
Value: ค่าหนึ่งตัวที่จำที่มาของตัวเองได้
หัวข้อที่มีชื่อว่า “Value: ค่าหนึ่งตัวที่จำที่มาของตัวเองได้”หัวใจของ micrograd คือ class เดียว: Value มันห่อ scalar (เลขตัวเดียว) หนึ่งค่า แต่แทนที่จะเป็นแค่ตัวเลขเปล่าๆ มันจำเพิ่มอีกสามอย่าง — (1) grad ช่องเก็บ GradientGradientทิศ+ขนาดที่ loss จะเพิ่มถ้าขยับ weight นิดหนึ่ง; เราขยับสวนทางมันProcess ของค่านี้เทียบกับ loss (2) _prev เซตของ node ต้นทางที่สร้างมันขึ้นมา และ (3) _backward closure ที่รู้วิธีไหล gradient จากตัวมันกลับเข้าต้นทาง สามอย่างนี้แหละที่ทำให้เราวาด computational graph ขึ้นมาโดยอัตโนมัติทุกครั้งที่คำนวณ
class Value: """ เก็บค่า scalar หนึ่งตัว พร้อม gradient ของมัน """
def __init__(self, data, _children=(), _op=''): self.data = data self.grad = 0 # เริ่มที่ 0 เสมอ — เดี๋ยวจะเห็นว่าทำไม (ไม่ใช่บังเอิญ) self._backward = lambda: None # closure: ไหล gradient จาก out กลับเข้า input self._prev = set(_children) # node ต้นทาง (ใครสร้างค่านี้ขึ้นมา) self._op = _op # ป้ายว่าเกิดจาก op อะไร (ไว้ debug/วาด graph)_children=() เริ่มเป็น tuple ว่าง — ค่าที่เราพิมพ์เองด้วยมือ (เช่น Value(3.0)) คือ “ใบไม้” ของ graph ไม่มีต้นทาง ส่วนค่าที่เกิดจากการคำนวณ (เช่น a + b) จะเก็บ a กับ b ไว้ใน _children เพื่อให้ backward รู้ว่าต้องไหล gradient กลับไปที่ใคร
5 primitive op กับ local derivative ของแต่ละตัว
หัวข้อที่มีชื่อว่า “5 primitive op กับ local derivative ของแต่ละตัว”ทุก op ทำสองงานพร้อมกัน: (1) Forward PassForward Passป้อน input เดินหน้าผ่านเน็ตจนได้ผลลัพธ์/lossProcess — คำนวณค่าใหม่ (2) ผูก closure _backward ที่ถือ local derivative ของ op นั้นไว้ หัวใจคือ Chain RuleChain Ruleกฎลูกโซ่: คูณอนุพันธ์ต่อกันทีละ node — หัวใจที่ทำให้ backprop เป็นไปได้Process: gradient ที่ไหลเข้า input = (local derivative ของ op) × (gradient ที่ไหลเข้า output) code ข้างล่างนี้คือ engine.py ตัวจริงแบบไล่บรรทัด — สังเกตว่าทุก _backward ใช้ += ไม่ใช่ = (เดี๋ยวจะเจาะว่าทำไมมันคือ bug อันดับหนึ่ง):
def __add__(self, other): other = other if isinstance(other, Value) else Value(other) out = Value(self.data + other.data, (self, other), '+') def _backward(): self.grad += out.grad # d(a+b)/da = 1 other.grad += out.grad # d(a+b)/db = 1 out._backward = _backward return out
def __mul__(self, other): other = other if isinstance(other, Value) else Value(other) out = Value(self.data * other.data, (self, other), '*') def _backward(): self.grad += other.data * out.grad # d(a*b)/da = b other.grad += self.data * out.grad # d(a*b)/db = a out._backward = _backward return out
def __pow__(self, other): assert isinstance(other, (int, float)), "only supporting int/float powers for now" out = Value(self.data ** other, (self,), f'**{other}') def _backward(): self.grad += (other * self.data ** (other - 1)) * out.grad # d(a^n)/da = n·a^(n-1) out._backward = _backward return out
def relu(self): out = Value(0 if self.data < 0 else self.data, (self,), 'ReLU') def _backward(): self.grad += (out.data > 0) * out.grad # 1 ถ้า a>0, ไม่งั้น 0 out._backward = _backward return outสังเกต __pow__: assert isinstance(other, (int, float)) — มันรองรับ เลขชี้กำลังที่เป็นค่าคงที่เท่านั้น (a ** 2 ได้, a ** b เมื่อ b เป็น Value จะ raise) เพราะสูตร n·a^(n-1) ใช้ได้ต่อเมื่อ n คงที่ ส่วน __sub__ / __neg__ / __truediv__ ไม่มี _backward ของตัวเอง — เพราะมันถูก ประกอบ จาก + * ** (เช่น a / b คือ a * b**-1) chain rule จึงไหลผ่านมันได้ฟรีๆ
ตารางสรุป local derivative ที่ต้องจำ (สังเกตหลาย op คำนวณ grad จาก out ที่มีอยู่แล้ว — ไม่ต้องคำนวณ forward ซ้ำ):
| op | forward | local derivative |
|---|---|---|
a + b | a.data + b.data | ∂/∂a = 1, ∂/∂b = 1 |
a * b | a.data * b.data | ∂/∂a = b, ∂/∂b = a |
a ** n (n คงที่) | a.data ** n | n · an−1 |
a.relu() | max(0, a.data) | 1 ถ้า a > 0, ไม่งั้น 0 |
a.tanh() | tanh(a.data) | 1 − out² |
a.exp() | e^(a.data) | out |
.backward(): ไล่ย้อนแบบ reverse-topological
หัวข้อที่มีชื่อว่า “.backward(): ไล่ย้อนแบบ reverse-topological”ตอนนี้เรามี graph ที่แต่ละ node ถือ _backward ของตัวเองไว้แล้ว เหลือแค่ “เรียกมันตามลำดับที่ถูกต้อง” — และลำดับนี้แหละที่ห้ามผิด backward() ทำสามสเต็ป:
def backward(self): # 1) เรียง node แบบ topological — ลูกก่อน พ่อทีหลัง (visited กันนับซ้ำ sub-expression) topo, visited = [], set() def build_topo(v): if v not in visited: visited.add(v) for child in v._prev: build_topo(child) topo.append(v) # append หลังไล่ลูกครบแล้วเท่านั้น build_topo(self)
# 2) seed: dL/dL = 1 (ถ้าลืมบรรทัดนี้ grad ทุกตัวจะเป็น 0) self.grad = 1
# 3) เดินย้อน — พ่อก่อนลูก — เรียก _backward ทีละ node for v in reversed(topo): v._backward()
# --- op ที่ประกอบจาก + * ** (ไม่มี _backward เป็นของตัวเอง) --- def __neg__(self): return self * -1 def __sub__(self, other): return self + (-other) def __radd__(self, other): return self + other def __rmul__(self, other): return self * other def __truediv__(self, other): return self * other ** -1 def __repr__(self): return f"Value(data={self.data}, grad={self.grad})"ทำไมต้อง reverse-topological? เพราะ _backward ของ node หนึ่งจะไหล gradient เข้าลูกของมันได้ ก็ต่อเมื่อ out.grad ของมันครบสมบูรณ์แล้ว — และ out.grad จะครบก็ต่อเมื่อ ทุก node ปลายทางที่ใช้มัน ไหล gradient กลับมาหมดแล้ว การเดิน reversed(topo) (พ่อก่อนลูก) รับประกันเงื่อนไขนี้พอดี ถ้าเดินสลับลำดับ gradient จะไหลออกทั้งที่ยังนับไม่ครบ → ผิดเงียบๆ
2 invariant ที่ห้ามพลาดใน block นี้: (1) topo.append(v) อยู่ หลัง loop ไล่ลูก (ไม่ใช่ก่อน) (2) self.grad = 1 เป็นการจุดชนวน ถ้าลืม gradient ทุกตัวจะเป็น 0 เพราะทุกอย่างคูณด้วย out.grad ที่ยังเป็น 0
bug อันดับหนึ่ง: += ไม่ใช่ = (fan-out ใน DAG)
หัวข้อที่มีชื่อว่า “bug อันดับหนึ่ง: += ไม่ใช่ = (fan-out ใน DAG)”นี่คือจุดที่ผู้เริ่มต้นพลาดกันมากที่สุด และเป็นเหตุผลที่ grad เริ่มต้นที่ 0 เสมอ ลองดูตัวอย่างสั้นที่สุด — d = a * a:
a = Value(3.0)d = a * a # a "แตกออกเป็นสองขา" ของตัวคูณ (fan-out)d.backward()print(a.grad) # 6.0 == 2 * a.data (= 2 × 3.0)ใน graph a ตัวเดียวป้อนเข้า node * ทั้ง self และ other — มันคือ input เดียวกันสองขา multivariable chain rule บอกว่า gradient ที่มาจากหลายเส้นทางต้อง บวกกัน ตอน _backward ของ * รัน มันทำ self.grad += other.data * out.grad แล้ว other.grad += self.data * out.grad — แต่ self กับ other คือ a ตัวเดียวกัน! a.grad จึงถูกบวกสองครั้ง: 3×1 + 3×1 = 6 พอดี 2 * a.data
ทีนี้ดู version bug — ถ้าเผลอใช้ =:
# ❌ version bug: ใช้ = แทน += ใน _backward ของ __mul__def _backward(): self.grad = other.data * out.grad # ❌ ทับ ไม่ใช่บวกสะสม other.grad = self.data * out.grad # ❌# ผล: การเขียนขาที่สองไปทับขาแรก → a.grad เหลือ 3.0 (== a.data) ไม่ใช่ 6.0# ครึ่งหนึ่งของ gradient หายไปเงียบ ๆ — เน็ตยังรันได้ แต่เรียนผิดflowchart LR a["a = Value(3.0)"] mul["node '*'<br/>out = a × a = 9"] d["d (= out)<br/>seed: d.grad = 1"] a -->|"forward"| mul a -->|"forward · ขาที่ 2"| mul mul -->|"forward"| d d -.->|"backward เริ่มที่นี่"| mul mul -.->|"a.grad += 3 × 1"| a mul -.->|"a.grad += 3 × 1 · ขาที่ 2"| a classDef seed fill:#7c2d12,stroke:#431407,color:#fed7aa; classDef node fill:#fde68a,stroke:#92400e,color:#451a03; class d seed; class a,mul node;
คำบรรยายภาพ: บทเรียนหลักของ micrograd ในภาพเดียว — a ตัวเดียวป้อนเข้า node * ทั้งสองขา (fan-out) ตอน forward ตอน backward gradient จึงไหลกลับมาที่ a สองครั้ง ต้อง += (บวกสะสม) ให้เป็น 2 × a.data = 6 ถ้าเผลอใช้ = ขาที่สองจะทับขาแรกเหลือแค่ 3 นี่คือเหตุผลที่ grad ต้องเริ่มต้นที่ 0 เสมอ — เพราะเราตั้งใจให้มันเป็น “ตัวสะสม” ที่รับ gradient จากทุกเส้นทาง
tanh กับ exp — add-on ของผู้เรียน (ไม่ได้อยู่ใน engine.py)
หัวข้อที่มีชื่อว่า “tanh กับ exp — add-on ของผู้เรียน (ไม่ได้อยู่ใน engine.py)”Karpathy เพิ่ม tanh กับ exp สดในวิดีโอ เพื่อสาธิต ไม่ได้อยู่ใน file engine.py ที่ commit ไว้ (ตัว file จริงมีแค่ + * ** ReLU) เราจึงแยกออกมาให้ชัดว่ามันเป็น add-on — แปะเข้า class ทีหลังด้วยมือ:
import math
# --- add-on ของผู้เรียน: ไม่ได้อยู่ใน engine.py จริง (Karpathy เพิ่มสดในวิดีโอ) ---def tanh(self): t = math.tanh(self.data) out = Value(t, (self,), 'tanh') def _backward(): self.grad += (1 - t * t) * out.grad # d(tanh)/dx = 1 - tanh(x)^2 = 1 - out^2 out._backward = _backward return out
def exp(self): out = Value(math.exp(self.data), (self,), 'exp') def _backward(): self.grad += out.data * out.grad # d(e^x)/dx = e^x = out out._backward = _backward return out
Value.tanh = tanh # แปะเข้า class ทีหลัง — ย้ำสายตาว่ามันเป็น add-on ไม่ใช่ของเดิมValue.exp = expทั้งคู่เป็นตัวอย่างสวยของ “local derivative คำนวณจาก out ที่มีอยู่แล้ว”: tanh ใช้ 1 - out², exp ใช้ out ตรงๆ — ไม่ต้องคำนวณ forward ซ้ำเลย
ตรวจงานด้วย numerical gradient (central difference)
หัวข้อที่มีชื่อว่า “ตรวจงานด้วย numerical gradient (central difference)”เขียน _backward เองด้วยมือเสี่ยงพิมพ์ผิด (สลับ self/other, ลืม +=) เราตรวจได้ด้วยนิยามอนุพันธ์ตรงๆ — central difference: ขยับ input ทีละนิด ±h แล้วดูว่า output ขยับเท่าไร (f(x+h) − f(x−h)) / (2h) ถ้าใกล้กับ gradient ที่ backprop ให้ ก็ “น่าจะถูก”:
def f(a, b): return a * b + b ** 3 # ใช้ได้ทั้งกับ Value และ float
# analytic: ให้ micrograd ไล่ backpropa, b = Value(2.0), Value(-3.0)loss = f(a, b)loss.backward()# a.grad, b.grad ถูกเติมแล้ว (analytic: da = b = -3, db = a + 3b^2 = 29)
# numerical: central difference บนค่า float ล้วนh = 1e-5num_a = (f(2.0 + h, -3.0) - f(2.0 - h, -3.0)) / (2 * h)num_b = (f(2.0, -3.0 + h) - f(2.0, -3.0 - h)) / (2 * h)
assert abs(a.grad - num_a) < 1e-4 # ต่างกันนิดเดียว → "น่าจะถูก"assert abs(b.grad - num_b) < 1e-4ระวังกรอบความคิด: assert ผ่าน ไม่ได้พิสูจน์ว่า backprop ถูก — มันเป็น sanity check ที่บอกว่า “ที่จุดนี้ ทิศทางนี้ ตัวเลขสองวิธีตรงกัน” เท่านั้น (เลือก h เล็กเกินก็เจอ floating-point error, ใหญ่เกินก็ไม่ใช่อนุพันธ์แล้ว) มันจับ bug ได้เยอะ แต่ไม่ใช่บทพิสูจน์
“เราสอน ‘สัญชาตญาณ’ ของ gradient กับ chain rule พอให้ code มีความหมาย ไม่ใช่พิสูจน์ทฤษฎีบท — อยากลึกเชิงพิสูจน์ เปิด Goodfellow บทที่อ้างไว้; คณิตตรงนี้เป็นพื้นให้ยืน ไม่ใช่กำแพงกั้นทาง”
แปลว่า: อยากได้ chain rule กับ backprop แบบครบเชิงพิสูจน์ (รวมถึงเหตุผลว่าทำไม reverse-mode ถึงคุ้มค่ากว่า forward-mode สำหรับเน็ต) เปิด Goodfellow-Bengio-Courville, Deep Learning (MIT Press, 2016) §6.5.1–6.5.3 (chain rule + การไล่ backprop บน computational graph) และ §6.5.6 (algorithm ทั่วไป) — คณิตส่วนนี้เป็น “คณิตที่ไม่เน่า” จึงอ้างตำราปี 2016 ได้สนิท ไม่ต้องติดป้าย stale
กับดัก zero_grad ใน loop เทรน → ชี้ไปบท6
หัวข้อที่มีชื่อว่า “กับดัก zero_grad ใน loop เทรน → ชี้ไปบท6”เพราะทุก _backward ใช้ += gradient จึง สะสม — ดีตอน fan-out ใน1 pass แต่กลายเป็นกับดักตอนเทรนหลายๆ ก้าว: ถ้าไม่ล้าง grad ให้เป็น 0 ก่อนเริ่ม pass ใหม่ gradient ของก้าวนี้จะไปบวกทับกับก้าวก่อน → อัปเดตเพี้ยน (โครง loop เทรนแบบร่าง):
for step in range(100): loss = forward(params) # เดินหน้าคำนวณ loss for p in params: p.grad = 0 # ❗ ล้าง grad ก่อน backward — ไม่งั้นสะสมข้ามก้าว loss.backward() # ไล่ย้อน เติม grad ชุดใหม่ for p in params: p.data += -0.01 * p.grad # ก้าวสวนทาง gradient (gradient descent)bug คลาสสิกเดียวกันนี้จะกลับมาในบท6 ตอนใช้ PyTorch — ตรงนั้นเราจะเรียก optimizer.zero_grad() แทน loop p.grad = 0 ด้วยมือ แต่ เจตนาเหมือนกันเป๊ะ: ล้างตัวสะสมก่อน backprop รอบใหม่ ลืมเมื่อไร gradient ก็ทับซ้อนเมื่อนั้น
“เราสร้างเพื่อเข้าใจ ไม่ใช่เพื่อ deploy — micrograd กับ nano-GPT ตัวจิ๋วในคอร์สนี้รันบน CPU เครื่องเดียวได้; แต่ LLM จริงกินทั้ง GPU cluster, ข้อมูลระดับเทระ, เวลาเป็นเดือน — สเกลที่คอร์สนี้ ‘ไม่’ จำลอง”
แปลว่า: micrograd เป็น scalar engine — บวก/คูณทีละค่า ไม่มี tensor, ไม่มี batching, ไม่มี double-backprop (grad ของ grad), ไม่ optimize อะไรให้เร็ว มันมีไว้ให้คุณ เห็น backprop ชัดๆ ไม่ใช่ให้ใช้เทรนของจริง บท6 เราถึงย้ายไป PyTorch ที่ทำ tensor + autograd แบบใช้งานจริงบนสเกลใหญ่ — ความช้าของ micrograd คือ design เพื่อการสอน ไม่ใช่ข้อบกพร่อง
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”micrograd สอนทั้งหัวใจของ deep learning ใน class เดียว: Value ห่อ scalar พร้อม grad/_prev/_backward เพื่อวาด computational graph อัตโนมัติ; แต่ละ primitive op (+ * ** ReLU, และ add-on tanh/exp) ถือ local derivative ของตัวเองไว้ใน closure; .backward() สร้าง topo แบบ ลูกก่อนพ่อ แล้วเดิน reversed เพื่อไล่ chain rule จาก loss ย้อนกลับ โดย seed self.grad = 1 เป็นชนวน invariant ที่ห้ามพลาดสามข้อ — += ไม่ใช่ = (fan-out ต้องบวกสะสม, จึงเป็นเหตุที่ grad เริ่มที่ 0), reverse-topological order (พ่อก่อนลูก), และ ล้าง grad ทุกก้าว ใน loop เทรน — ทั้งสามจะตามเราไปถึง PyTorch ในบท6 แบบไม่เปลี่ยนแก่น
บทหน้าเราเอา engine ตัวนี้ (หรือ NumPy เทียบเท่า) ไปสร้างของจริงชิ้นแรก: bigram language model — ทายตัวอักษรถัดไปจากตัวก่อนหน้า ด้วย one-hot → softmax → cross-entropy แล้วเทรนด้วย gradient descent จริงๆ นี่คือครั้งแรกที่ backprop ที่เราเพิ่งสร้างจะได้ “เรียน” อะไรบางอย่างจากข้อมูลจริง
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- Karpathy — micrograd (
engine.py) (S2, commitc911406e5ace8742e5841a7e0df113ecb5d54685, 2020-04-18; เข้าถึง 2026-07-23) — classValueในบทนี้คือ file นี้แบบไล่บรรทัด; repo ที่ “นิ่ง” มา ~6 ปี จึง pin ด้วย SHA + วันที่ดึงได้สนิท (เส้น A) - Goodfellow, Bengio, Courville — Deep Learning (MIT Press, 2016), §6.5.1–6.5.3 และ §6.5.6 (S8) — chain rule, การไล่ backprop บน computational graph และ algorithm ทั่วไป; อ้างเฉพาะคณิตที่ไม่เน่า (เส้น D)
- Rumelhart, Hinton, Williams — Learning representations by back-propagating errors, Nature 323, 533–536 (1986) (S10) — เปเปอร์ที่ทำให้ backprop เป็นที่นิยมในวงการ neural network (ไอเดีย reverse-mode มีมาก่อนหน้านั้น — Linnainmaa 1970, Werbos 1974)
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3ทำไมทุก _backward ใน micrograd ต้องใช้ += ไม่ใช่ = (และทำไม grad จึงเริ่มต้นที่ 0)?