ทำให้เทรนติด — activation, initialization, batch normalization
บท4 คุณสร้าง MLPMLPเน็ตชั้นซ่อนแบบเชื่อมทุกจุด (Bengio 2003) — ก้าวจาก bigram ไป model ที่ "จำ context" ได้Architecture แบบ Bengio ขึ้นมาแล้วมันเทรนได้จริง — dev loss ลงมาแตะราว ~2.1–2.2 ชนะ bigram floor (~2.4541) แต่ระหว่างทางมันมักจะ ฝืด: loss ก้าวแรกๆ พุ่งสูงลิ่วแล้วดิ่งลงเป็นรูป “ไม้ฮอกกี้” อยู่หลายร้อยก้าวก่อนจะเริ่มเรียนของจริง, บาง neuron ดูเหมือนไม่ขยับเลย, และถ้าตั้ง learning rate ผิดนิดเดียวมันก็ระเบิดหรือค้าง
บทนี้เราจะเลิกมองเน็ตเป็นกล่องดำ แล้วเปิดฝาดูข้างในตอนเทรน — วัด สุขภาพ ของแต่ละชั้นเป็นตัวเลขและ histogram แล้วแก้ทีละอาการ ทุกอาการเป็น pattern เดิม: version ดิบที่พัง → ตั้งชื่ออาการ → แก้ด้วย code บรรทัดเดียว → ดูมันหาย สามเสาที่คุมทั้งหมดคือ Activation FunctionActivation Functionfunction บีบไม่เชิงเส้น (tanh/ReLU) ที่ทำให้เน็ตเรียนของซับซ้อนได้Architecture, InitializationInitializationตั้งค่า weight เริ่มต้นให้ดี — เริ่มผิด เน็ตตายตั้งแต่ก้าวแรกProcess และ Batch NormalizationBatch Normalizationปรับสเกลค่ากลางชั้นให้นิ่ง เทรนง่ายขึ้น (ป้าย: practice สมัยใหม่มีทางเลือกอื่น เช่น LayerNorm)Process
code บทนี้อยู่ใน repo kaen-nn-from-scratch (code ตัวอย่างกำลังจัดทำ) — pure NumPy ล้วน ไม่มี import torch ต่อยอดตรงจาก MLP ในบท4 ทุก snippet รันบน CPU ได้ทันที
กติกาของบทนี้: NumPy ทำ forward, gradient ค่อยว่ากันบท6
หัวข้อที่มีชื่อว่า “กติกาของบทนี้: NumPy ทำ forward, gradient ค่อยว่ากันบท6”Karpathy สอนเรื่องนี้ (makemore part 3) ด้วย PyTorch — เขา loss.backward() แล้วหยิบ .grad ของทุกชั้นมาพล็อตเป็น histogram ตรงๆ แต่คอร์สนี้ quarantine framework ไว้จนถึงบท6 เราจึงต้องแยกให้ชัด:
- สิ่งที่เราลงมือทำใน NumPy บทนี้: forward pass, การ init weight, forward ของ BatchNorm, และการอัปเดต running stats — ทั้งหมดนี้ ไม่ต้องใช้ gradient คำนวณตรงๆ ได้
- สิ่งที่เรา อ่าน แต่ยังไม่ลงมือคำนวณเอง: gradient histogram กับ update:data ratio — สองอันนี้ต้องมี gradient ของทุกชั้น ซึ่งเราจะได้เครื่องมือคำนวณมันจริงๆ (
loss.backward()) ในบท6 บทนี้เราสอน วิธีอ่าน พล็อตพวกนี้ให้เป็น เพื่อพอถึงบท6 คุณจะรู้ว่ากำลังดูอะไร
นี่คือการรักษา quarantine ให้เรื่องบท2–5 สะอาด ไม่ใช่การหลบเลี่ยง — diagnostic ที่ต้องใช้ backprop เราเลื่อนไปทำจริงตอนมีเครื่องมือ
อาการ 1 — loss รูป “ไม้ฮอกกี้” → W2 *= 0.01; b2 = 0
หัวข้อที่มีชื่อว่า “อาการ 1 — loss รูป “ไม้ฮอกกี้” → W2 *= 0.01; b2 = 0”ก่อนอื่นวางฉากจากบท4 ให้ครบ (dataset + minibatch หนึ่งก้อน):
import numpy as np
words = open('names.txt').read().splitlines() # 32,033 ชื่อchars = sorted(set(''.join(words)))stoi = {s: i + 1 for i, s in enumerate(chars)}; stoi['.'] = 0itos = {i: s for s, i in stoi.items()}V = len(stoi) # 27block_size = 3
def build_dataset(ws): X, Y = [], [] for w in ws: context = [0] * block_size for ch in w + '.': X.append(context); Y.append(stoi[ch]) context = context[1:] + [stoi[ch]] # เลื่อน context return np.array(X), np.array(Y)
rng = np.random.default_rng(2147483647) # บท5 ใช้ API ใหม่ default_rng (numpy แนะนำ); บท4 ใช้ np.random.seed เดิม — generator ต่างกัน เลขจึงไม่ตรงเป๊ะข้ามบท (loss hedge ไว้แล้ว)Xtr, Ytr = build_dataset(words) # เอา full set มาก่อน (ตัวอย่าง)
n_embd, n_hidden = 10, 200fan_in = block_size * n_embd # 30 (ต้องตรงกับแถวแรกของ W1)
def loss_of(C, W1, b1, W2, b2, ix): emb = C[Xtr[ix]] # (batch, 3, 10) hpreact = emb.reshape(-1, fan_in) @ W1 + b1 # (batch, 200) h = np.tanh(hpreact) logits = h @ W2 + b2 # (batch, 27) logits = logits - logits.max(axis=1, keepdims=True) # softmax แบบ stable probs = np.exp(logits); probs /= probs.sum(axis=1, keepdims=True) return -np.log(probs[np.arange(len(ix)), Ytr[ix]]).mean()ทีนี้ init แบบ ไม่คิดอะไร (version ดิบ) — สุ่มทุกตัวด้วย standard_normal เหมือนๆ กัน:
# ❌ version ดิบ: last layer สุ่มเต็มที่ → logits เริ่มต้นสุดโต่ง มั่นใจผิด ๆC = rng.standard_normal((V, n_embd))W1 = rng.standard_normal((fan_in, n_hidden))b1 = rng.standard_normal(n_hidden)W2 = rng.standard_normal((n_hidden, V))b2 = rng.standard_normal(V)
ix = rng.integers(0, Xtr.shape[0], size=32)print(loss_of(C, W1, b1, W2, b2, ix)) # ในการรันครั้งหนึ่งเริ่มที่ราว ~27อาการ: logits เริ่มต้นมีค่าสุดโต่งกระจายกว้าง softmax เลย “มั่นใจ” ใน class มั่วๆ ตั้งแต่ก้าวแรก loss จึงพุ่งขึ้นไปราว ~27 (ในการรันครั้งหนึ่ง) แล้วเน็ตต้องเสียหลายร้อยก้าวแรกไป ดัด logits ที่มั่นใจผิดให้แบนลงก่อน ค่อยเริ่มเรียนของจริง — บน graph loss มันคือรูป “ไม้ฮอกกี้” (พุ่ง แล้วหักดิ่ง แล้วค่อยลาด)
ตรรกะของค่าที่ควรจะเป็น: ตอนเริ่มต้นเน็ตยัง “ไม่รู้อะไรเลย” มันควรเดาว่าทุกตัวอักษรถัดไปมีโอกาสเท่าๆ กัน = 1/27 ต่อ class loss ที่ถูกต้องของการเดาแบบ uniform คือ −ln(1/27) = ln 27 ≈ 3.2958 (นี่เป็นเลขจากคณิตล้วน ไม่ใช่ผลรัน จึงระบุเป๊ะได้) เป้าคือทำให้ logits เริ่มต้นใกล้ 0 เพื่อให้ softmax เกือบ uniform loss เริ่มที่ ~3.2958 พอดี — ไม่เสียก้าวแรกไปฟรีๆ
แก้บรรทัดเดียว: หรี่ last layer ให้ “ไม่มั่นใจ” — คูณ W2 ด้วย 0.01 และตั้ง b2 = 0:
# ✅ init ที่ 'ไม่มั่นใจ' — logits เริ่มใกล้ 0 → softmax เกือบ uniformW2 = rng.standard_normal((n_hidden, V)) * 0.01b2 = np.zeros(V)print(loss_of(C, W1, b1, W2, b2, ix)) # ตอนนี้เริ่มใกล้ ~3.2958 แล้วเพราะถ้า W2 = 0 ทั้งก้อน logits จะเป็น 0 เป๊ะ และ สมมาตรจนเกินไป — เราแค่อยากให้เริ่ม “อ่อนโยน” (softmax เกือบ uniform) ไม่ใช่ตัดสัญญาณทิ้ง คูณ 0.01 ทำให้ logits จิ๋วแต่ยังมีความต่างเล็กๆ ให้ gradient จับทิศได้ตั้งแต่ก้าวแรก
อาการ 2 — tanh อิ่มตัว / neuron ตาย → gain / √fan_in
หัวข้อที่มีชื่อว่า “อาการ 2 — tanh อิ่มตัว / neuron ตาย → gain / √fan_in”แก้ last layer แล้ว loss เริ่มถูกจุด แต่ยังมีอาการที่สอง — คราวนี้อยู่ที่ ชั้นซ่อน ลองวัดสุขภาพของ activation h = tanh(hpreact) ด้วย init ดิบ (W1 = standard_normal, b1 สุ่ม):
# ❌ W1 ใหญ่เกิน → hpreact กว้าง → tanh อิ่มตัวชนขอบ ±1W1 = rng.standard_normal((fan_in, n_hidden))b1 = rng.standard_normal(n_hidden) * 0.1
emb = C[Xtr[ix]]hpreact = emb.reshape(-1, fan_in) @ W1 + b1h = np.tanh(hpreact)
print((np.abs(h) > 0.99).mean()) # สัดส่วน activation ที่ 'อิ่มตัว' — สูงมากdead = (np.abs(h) > 0.99).all(axis=0) # neuron ที่อิ่มตัว 'ทุกตัวอย่าง' ใน batchprint(dead.sum()) # จำนวน neuron ที่ 'ตาย' ทั้ง batchอาการ: W1 ตัวใหญ่ทำให้ preactivation กระจายกว้าง (std หลายเท่าของ 1) พอผ่าน tanh ค่าส่วนใหญ่เลยไปกองที่ขอบ ±1 ถ้าพล็อต plt.hist(h.ravel(), 50) จะได้รูปตัว U (กองสองข้าง กลางกลวง) — นั่นคือสัญญาณอันตราย
ทำไมมันร้ายแรง: อนุพันธ์ของ tanh คือ 1 − h² ถ้า |h| ≈ 1 แล้ว 1 − h² ≈ 0 — gradient ที่ไหลผ่าน neuron นั้นจะถูกคูณด้วยเกือบศูนย์ ถ้า neuron ตัวไหน “อิ่มตัวทุกตัวอย่างใน batch” (คือ column นั้น dead == True) มันจะได้ gradient ≈ 0 ตลอด → weight ไม่เคยขยับ → เราเรียกว่า neuron ตาย มันกินที่ในเน็ตแต่ไม่เคยเรียนอะไรเลย
แก้บรรทัดเดียว: ตั้ง std ของ W1 ให้พอดีกับ fan_in — คูณด้วย gain / √fan_in:
# ✅ init คุม variance: preactivation std ~1 → tanh ไม่อิ่มตัวgain = 5 / 3 # gain ของ tanhW1 = rng.standard_normal((fan_in, n_hidden)) * gain / np.sqrt(fan_in) # ≈ ×0.304b1 = np.zeros(n_hidden)
hpreact = C[Xtr[ix]].reshape(-1, fan_in) @ W1 + b1h = np.tanh(hpreact)print((np.abs(h) > 0.99).mean()) # ต่ำลงมาก — histogram เป็นระฆังกลาง ๆ ไม่กองขอบตรรกะเบื้องหลัง: ผลรวมของ fan_in พจน์อิสระ ทำให้ variance ของ preactivation โตขึ้นราวๆ fan_in เท่า ดังนั้นถ้าอยากให้ preactivation มี variance ~1 (เขต “ตรงกลาง” ที่ tanh ยังไม่อิ่มตัว) ต้องหด std ของ weight ลงด้วย 1/√fan_in ส่วน gain ชดเชยการที่ nonlinearity เองก็บีบ variance ลงไปด้วย — ค่าต่างกันตาม activation:
| Activation | gain | สูตร variance ที่ตั้งใจ | ต้นทาง |
|---|---|---|---|
| tanh | 5/3 ≈ 1.667 | — | Glorot 2010 (S11) |
| ReLU | √2 ≈ 1.414 | Var = 2/fan_in (Kaiming/He) | He 2015 (S12) |
| linear / sigmoid | 1 | Var = 2/(fan_in+fan_out) (Xavier/Glorot) | Glorot 2010 (S11) |
Xavier/Glorot ใช้ทั้ง fan_in และ fan_out (เพราะคิดทั้งขา forward และ backward), Kaiming/He ใช้ 2/fan_in เพราะ ReLU ตัดค่าลบทิ้งครึ่ง1 variance เลยหายไปครึ่ง ต้องชดเชยกลับ
“ของสดใช้ repo, ของนิ่งใช้ตำรา — code ของ Karpathy คือ repo ที่ยังมีชีวิต เราจึงอ้างด้วย commit SHA + วันที่ดึง ไม่ใช่เลขรุ่น; ส่วนคณิตที่ไม่เน่าเราอ้าง Goodfellow-Bengio-Courville (MIT Press, 2016)”
“เราสอน ‘สัญชาตญาณ’ ของ gradient กับ chain rule พอให้ code มีความหมาย ไม่ใช่พิสูจน์ทฤษฎีบท — อยากลึกเชิงพิสูจน์ เปิด Goodfellow บทที่อ้างไว้; คณิตตรงนี้เป็นพื้นให้ยืน ไม่ใช่กำแพงกั้นทาง”
แปลว่า: สมัยก่อน (ก่อน ~2015) การ init ให้เป๊ะแบบ Xavier/He คือเส้นแบ่งระหว่าง “เน็ตลึกเทรนติด” กับ “เทรนไม่ติดเลย” — แต่ วันนี้มันเป็นภาระน้อยลงมาก เพราะ residual connection + normalization (BatchNorm/LayerNorm) + optimizer อย่าง Adam ช่วยกลบความไวต่อ init ไปเยอะ ทฤษฎีเชิงลึกของ init อยู่ใน Goodfellow §8.4 — ⚠️ timeless-math เท่านั้น: เนื้อหา §8.4 เขียนปี 2016 จึง “เก่า” (stale-on-modern-practice) ในแง่ว่าเทคนิคหลังปีนั้นทำให้ init เป๊ะจำเป็นน้อยลง เราเรียนมันเพื่อ เข้าใจว่าทำไม ไม่ใช่เพราะต้อง tune มันด้วยมือทุกครั้ง
อาการ 3 — gradient histogram (vanishing / exploding) [อ่านตอนนี้ ลงมือบท6]
หัวข้อที่มีชื่อว่า “อาการ 3 — gradient histogram (vanishing / exploding) [อ่านตอนนี้ ลงมือบท6]”สองอาการแรกวัดได้จาก forward ล้วน แต่อาการที่สามต้องดู gradient ของแต่ละชั้น — ซึ่งต้องมี backward() ที่เราจะสร้างจริงในบท6 บทนี้เราสอนแค่ วิธีอ่าน:
ไอเดียคือพล็อต histogram ของ gradient ที่ไหลเข้าแต่ละชั้น แล้วเทียบความกว้าง (std) ข้ามชั้น
- สุขภาพดี: histogram ของทุกชั้น “หน้าตาเหมือนกัน” — std ใกล้เคียงกันตั้งแต่ชั้นลึกสุดถึงชั้นตื้นสุด แปลว่าสัญญาณ gradient เดินทางย้อนกลับได้ทั่วถึงทุกชั้น
- vanishing: std หดเล็กลงเรื่อยๆ เมื่อย้อนลึกเข้าไป → ชั้นต้นๆ แทบไม่ได้ gradient → เรียนช้ามากหรือไม่เรียนเลย (มักมาคู่กับ tanh อิ่มตัวในอาการ 2)
- exploding: std โตขึ้นเรื่อยๆ เมื่อย้อนลึก → gradient บวมจนอัปเดตกระโดดเกินเหตุ loss เด้งหรือกลายเป็น NaN
init ที่คุม variance (อาการ 2) กับ normalization (หัวข้อ BatchNorm ข้างล่าง) คือเครื่องมือหลักที่ทำให้ histogram ของทุกชั้น “หน้าตาเหมือนกัน” พอถึงบท6 เมื่อมี .grad ให้หยิบจริง คุณจะพล็อตอันนี้เองได้
อาการ 4 — update:data ratio ≈ −3 (diagnostic ตัวเด็ด) [อ่านตอนนี้ ลงมือบท6]
หัวข้อที่มีชื่อว่า “อาการ 4 — update:data ratio ≈ −3 (diagnostic ตัวเด็ด) [อ่านตอนนี้ ลงมือบท6]”อีก1 diagnostic ที่ต้องใช้ gradient — และเป็นตัวที่ Karpathy เรียกว่า “ตัวเด็ด” เพราะมันบอกได้ทันทีว่า learning rate เหมาะหรือไม่ โดยไม่ต้องเดา สำหรับแต่ละ parameter p ให้ดูอัตราส่วน log10( std(lr · p.grad) / std(p.data) ) ≈ −3 แปลว่า ขนาดของก้าวที่ขยับ weight ควรอยู่ราว 1/1000 ของขนาด weight เอง ในแต่ละก้าว
- สูงกว่า −3 มาก (เช่น −1) → แต่ละก้าวขยับ weight แรงเกินไป → เทรนไม่นิ่ง/ระเบิด → lr สูงไป
- ต่ำกว่า −3 มาก (เช่น −6) → ขยับจิ๋วจนแทบไม่ขยับ → เรียนช้าเป็นเต่า → lr ต่ำไป
−3 ไม่ใช่กฎเหล็กแต่เป็น “เข็มทิศ”: พล็อตค่านี้ของทุกชั้นข้ามหลายก้อน แล้วดูว่าชั้นไหนหลุดกรอบ — ชั้นที่ ratio ผิดปกติคือชั้นที่ init หรือ lr กำลังมีปัญหา (อีกครั้ง: การคำนวณจริงรอบท6 ที่มี p.grad)
Batch Normalization — normalize preactivation ตามแกน batch
หัวข้อที่มีชื่อว่า “Batch Normalization — normalize preactivation ตามแกน batch”แทนที่จะพยายาม init ให้ preactivation มี variance ~1 แล้วภาวนาว่ามันจะอยู่ทรงตลอดการเทรน Batch NormalizationBatch Normalizationปรับสเกลค่ากลางชั้นให้นิ่ง เทรนง่ายขึ้น (ป้าย: practice สมัยใหม่มีทางเลือกอื่น เช่น LayerNorm)Process (Ioffe–Szegedy 2015, S13) แก้ปัญหาแบบตรงไปตรงมา: บังคับ normalize preactivation ให้ mean 0 / var 1 ทุกก้าว โดยคิดสถิติ ตามแกน batch แยกทีละ feature จากนั้นค่อยให้เน็ต “เลื่อน+ขยาย” กลับด้วย parameter ที่เรียนได้ gamma (bngain) กับ beta (bnbias)
ตำแหน่งวางคือ Linear → BatchNorm → nonlinearity — normalize ก่อน ผ่าน tanh
eps, momentum = 1e-5, 0.001bngain = np.ones((1, n_hidden)) # gamma init 1 (ขยาย)bnbias = np.zeros((1, n_hidden)) # beta init 0 (เลื่อน — คือ bias ตัวจริง)bnmean_running = np.zeros((1, n_hidden))bnvar_running = np.ones((1, n_hidden))
# --- โหมด TRAIN: normalize ด้วยสถิติของ batch ---hpreact = C[Xtr[ix]].reshape(-1, fan_in) @ W1 # ไม่มี b1! (ดูหมายเหตุ bias=False)mu = hpreact.mean(axis=0, keepdims=True) # (1, 200) เฉลี่ยตามแกน batchvar = hpreact.var(axis=0, keepdims=True) # (1, 200) ต่อ featurexhat = (hpreact - mu) / np.sqrt(var + eps) # normalize: mean 0, var 1hpreact = bngain * xhat + bnbias # เลื่อน+ขยายกลับ (เรียนได้)h = np.tanh(hpreact)
# อัปเดต running stats — ไม่เกี่ยวกับ gradient เลย เก็บไว้ใช้ตอน evalbnmean_running = (1 - momentum) * bnmean_running + momentum * mubnvar_running = (1 - momentum) * bnvar_running + momentum * varสังเกตว่าบรรทัด hpreact = ... @ W1 ไม่มี + b1 เพราะ BatchNorm ลบ mu ออกทันทีในบรรทัดถัดมา — bias ที่คุณบวกเข้าไปจะถูก mu หักล้างหมดพอดี (mean(x + b) = mean(x) + b แล้วก็โดนลบทิ้ง) มันจึงเป็น parameter ที่ไม่มีวันมีผลต่อ output — เปลือง gradient เปล่าๆ ให้ตั้ง bias=False บนชั้น Linear ก่อน BN ไปเลย beta (bnbias) คือ bias ตัวจริง ที่ทำหน้าที่เลื่อนค่าแทน
ตอน eval ห้ามใช้สถิติของ batch เด็ดขาด — ต้องใช้ bnmean_running / bnvar_running ที่สะสมไว้ตอนเทรน:
# --- โหมด EVAL: ใช้ running stats ที่ 'ตรึงไว้' ไม่แตะ batch ---# Xdev = input ของ dev split (สร้างจาก build_dataset เหมือนบท4) — วางเป็นตัวแทนไว้ให้เห็นว่า eval ป้อน 'ข้อมูลที่ไม่เคยเห็น'hpreact = C[Xdev].reshape(-1, fan_in) @ W1xhat = (hpreact - bnmean_running) / np.sqrt(bnvar_running + eps) # running, ไม่ใช่ batch!hpreact = bngain * xhat + bnbiasflowchart TB
X["hpreact = emb @ W1<br/>(bias=False — BN หัก mean ทิ้งอยู่แล้ว)"]
X --> TR
X --> EV
subgraph TR["โหมด train — ใช้สถิติของ 'batch'"]
MU["mu, var = mean/var ของ batch (แกน 0)"]
N1["xhat = (hpreact − mu) / √(var+eps)"]
UP["สะสมเข้า running stats<br/>bnmean_running, bnvar_running"]
MU --> N1
MU --> UP
end
subgraph EV["โหมด eval — ใช้ running stats ที่ 'ตรึงไว้'"]
RN["ดึง bnmean_running, bnvar_running"]
N2["xhat = (hpreact − run_mean) / √(run_var+eps)"]
RN --> N2
end
N1 --> OUT["out = bngain·xhat + bnbias → tanh"]
N2 --> OUT
classDef base fill:#fde68a,stroke:#92400e,color:#451a03;
classDef warn fill:#7c2d12,stroke:#431407,color:#fed7aa;
class X,MU,N1,UP,RN,N2 base;
class OUT warn;
คำบรรยายภาพ: forward ของ BatchNorm แตกเป็นสองโหมดจากจุดเดียวกัน — ตอน train คำนวณ mean/var จาก batch ปัจจุบันเพื่อ normalize แล้วสะสมค่าเข้า running stats ไปเรื่อยๆ; ตอน eval ทิ้งสถิติของ batch ไปเลยแล้วดึง running stats ที่ตรึงไว้มาใช้แทน ทั้งสองเส้นจบที่ bngain·xhat + bnbias เหมือนกันก่อนเข้า tanh การสลับสองโหมดนี้คือ bug คลาสสิกของ BatchNorm
bug คลาสสิก: ใช้ batch stats ตอน eval แล้วเจอ batch ขนาด 1
หัวข้อที่มีชื่อว่า “bug คลาสสิก: ใช้ batch stats ตอน eval แล้วเจอ batch ขนาด 1”ทำไม eval ถึง ต้อง ใช้ running stats? เพราะตอน deploy คุณมักทำนายทีละ ตัวอย่างเดียว ลองดูว่าเกิดอะไรถ้าเผลอใช้ batch stats กับ batch ขนาด 1:
# ❌ bug คลาสสิก: normalize ตัวอย่างเดียวด้วยสถิติของตัวมันเองx1 = hpreact[:1] # batch ขนาด 1 → (1, 200)mu1 = x1.mean(axis=0, keepdims=True) # = ตัวมันเองvar1 = x1.var(axis=0, keepdims=True) # var ของจุดเดียว = 0 ทุก feature!xhat1 = (x1 - mu1) / np.sqrt(var1 + eps) # (x − x)/√eps = 0 → สัญญาณหายเกลี้ยงอาการ: var ของตัวอย่างเดียวเป็น 0 (เพราะไม่มีอะไรให้กระจาย) ตัวเศษ x1 − mu1 ก็เป็น 0 ด้วย → output กลายเป็น bnbias เท่ากันหมดไม่ว่า input จะเป็นอะไร = สัญญาณหายเกลี้ยง ยิ่งไปกว่านั้น ถ้า batch เล็กแต่ >1 var จะจิ๋วๆ → หารด้วย √(var+eps) ที่เกือบศูนย์ → ค่าพองจนเพี้ยน (eps กันหารศูนย์แบบ literal ได้ แต่กันความไร้ความหมายไม่ได้) และที่แย่ในเชิงตรรกะที่สุด: ผลทำนายของตัวอย่างหนึ่งจะ ขึ้นกับว่ามีเพื่อนตัวไหนอยู่ใน batch เดียวกัน — ไม่ deterministic
ทางแก้คือ running stats นั่นคือ: มันคือค่าเฉลี่ยสถิติของ ทั้ง training distribution ที่ตรึงไว้แล้ว ใช้ได้กับ input ทีละตัวโดยไม่ต้องพึ่งเพื่อนใน batch และให้ผลเดิมทุกครั้ง
numpy.var หารด้วย N (biased, ddof=0) ส่วน function torch.var หารด้วย N−1 (unbiased, ddof=1) โดย default ถ้าคุณเอา BatchNorm ที่เขียนมือด้วย NumPy ไปเทียบเลขกับ PyTorch แล้วเจอว่า “ไม่ตรงกันนิดๆ” — นี่แหละคือกับดัก ให้ระบุ ddof ให้ตรงกันก่อนเทียบ (จะทำให้ตรง ให้ใช้สถิติแบบ biased ทั้งคู่ตอน normalize)
BatchNorm มีของแถม แต่ก็มีราคา → สะพานไป LayerNorm
หัวข้อที่มีชื่อว่า “BatchNorm มีของแถม แต่ก็มีราคา → สะพานไป LayerNorm”จุดที่ต้องเข้าใจให้ลึกที่สุดของ BatchNorm คือ: มันทำให้ output ของตัวอย่างหนึ่ง ขึ้นกับตัวอย่างอื่นใน batch เดียวกัน (ผ่าน mu/var ที่ใช้ร่วมกัน) — เราเรียกว่า BatchNorm couple ตัวอย่างในก้อนเข้าด้วยกัน คุณสมบัตินี้เป็นดาบสองคม:
“เราสร้างเพื่อเข้าใจ ไม่ใช่เพื่อ deploy — micrograd กับ nano-GPT ตัวจิ๋วในคอร์สนี้รันบน CPU เครื่องเดียวได้; แต่ LLM จริงกินทั้ง GPU cluster, ข้อมูลระดับเทระ, เวลาเป็นเดือน — สเกลที่คอร์สนี้ ‘ไม่’ จำลอง”
แปลว่า: การ couple ของ BatchNorm มีทั้งด้านดีและด้านร้าย — ของแถม (feature): เพราะ mean/var เขย่าตามองค์ประกอบของแต่ละ batch มันจึงฉีด “สัญญาณรบกวน” เบาๆ เข้าไป กลายเป็น regularizer ที่ช่วยกัน overfit โดยบังเอิญ ราคา (cost): (1) bug เงียบจากการสลับ train/eval, (2) เปราะกับ batch เล็ก (var ไม่นิ่ง), (3) train กับ eval ทำงานคนละสูตร (batch stats vs running stats) — พฤติกรรมไม่เหมือนกันเป๊ะ ทั้งหมดนี้คือ สเกลและความยุ่งของจริง ที่คอร์สนี้พาไปดูให้เห็นกลไก ไม่ได้พาไปรับมือในระดับ production
coupling นี้เองคือเหตุผลที่ Transformer ไม่ใช้ BatchNorm แต่ใช้ LayerNorm — LayerNorm normalize ตาม แกน feature ของตัวอย่างเดียว (ไม่ยุ่งกับเพื่อนใน batch เลย) จึงไม่มี coupling, ไม่มีปัญหา batch เล็ก, และ train กับ eval ใช้สูตรเดียวกันเป๊ะ เราจะเจอ LayerNorm เต็มๆ ในบท7 ตอนต่อ Transformer — ตอนนี้แค่จำสะพานนี้ไว้: ปัญหา coupling ของ BatchNorm → คือเหตุผลที่ Transformer เลือก LayerNorm
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้เปลี่ยนเน็ต “เทรนได้แต่ฝืด” ให้ “เทรนติดลื่น” ด้วยการมองเข้าไปข้างในแล้วแก้ทีละอาการ: (1) init loss ผิดรูปไม้ฮอกกี้ → หรี่ last layer W2*=0.01, b2=0 ให้เริ่มที่ ~3.2958; (2) tanh อิ่มตัว/neuron ตาย → gain/√fan_in คุม variance ของ activation; (3) gradient histogram ต้องหน้าตาเหมือนกันทุกชั้น และ (4) update:data ratio ≈ −3 บอก lr เหมาะหรือไม่ — สองอันหลังต้องมี gradient ซึ่งเราเลื่อนไปลงมือจริงบท6 ตามกติกา quarantine; แล้วปิดด้วย Batch Normalization ที่บังคับ normalize ตามแกน batch (train ใช้สถิติ batch + สะสม running stats, eval ใช้ running stats ที่ตรึงไว้ — สลับกันเมื่อไรพังเมื่อนั้น โดยเฉพาะ batch ขนาด 1), พร้อมกฎ bias=False ก่อน BN และเชิงอรรถ biased-var; coupling ของ BatchNorm เป็นทั้งของแถมและราคา และเป็นสะพานตรงไป LayerNorm ในบท7
บทหน้าเราจะ graduate จากเขียนมือไป PyTorch — ทุกอย่างที่เราไล่ทำเองใน NumPy (topo backward, การ init, การ zero grad) จะกลายเป็นบรรทัดเดียวของ framework และที่ค้างไว้บทนี้ — การพล็อต gradient histogram กับ update:data ratio จริงๆ — จะทำได้ทันทีเมื่อมี loss.backward()
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- Ioffe & Szegedy — Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift (arXiv:1502.03167) (S13, ICML 2015) — ต้นทางของ BatchNorm: สูตร normalize ตามแกน batch,
gamma/betaที่เรียนได้, และ running stats สำหรับ inference - Glorot & Bengio — Understanding the difficulty of training deep feedforward neural networks (S11, AISTATS 2010) — Xavier/Glorot init:
Var = 2/(fan_in+fan_out); ที่มาของ gain 5/3 สำหรับ tanh - He, Zhang, Ren, Sun — Delving Deep into Rectifiers (arXiv:1502.01852) (S12, ICCV 2015) — Kaiming/He init:
Var = 2/fan_inสำหรับ ReLU (gain √2) - Karpathy — nn-zero-to-hero:
lectures/makemore/makemore_part3_bn.ipynb(S1, commit73c3fcc741f0ec104ca850b1fb0df90e7e8d4cde, 2024-02-20; เข้าถึง 2026-07-23) — ต้นแบบของบทนี้ (เขียนด้วย PyTorch) — เรา reimplement เป็น NumPy และ เลื่อน ส่วนคำนวณ gradient (histogram + update:data ratio) ไปบท6 เพื่อรักษา quarantine (เส้น A: อ้างด้วย SHA + วันที่ดึง ไม่ใช่เลขรุ่น) - Goodfellow, Bengio, Courville — Deep Learning (MIT Press, 2016) (S8, §6.3 hidden units/activations · §8.2, §8.4 parameter initialization · §8.7.1 Batch Normalization) — ⚠️ timeless-math เท่านั้น และ stale-on-modern-practice: §8.4 เขียนปี 2016 — เทคนิคหลังปีนั้น (residual + normalization + Adam) ทำให้ init เป๊ะจำเป็นน้อยลง อ้างเพื่อเข้าใจ ทำไม ไม่ใช่คู่มือ tune
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ทำไมตอน init ถึงคูณ W2 ด้วย 0.01 และตั้ง b2 = 0 บนชั้นสุดท้าย?