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

ทำให้​เทรน​ติด — 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

📦 kaen-nn-from-scratch

code บท​นี้​อยู่​ใน repo kaen-nn-from-scratch (code ตัวอย่าง​กำลัง​จัด​ทำ) — pure NumPy ล้วน ไม่มี import torch ต่อยอด​ตรง​จาก MLP ในบท4 ทุก snippet รัน​บน CPU ได้​ทันที

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 เรา​เลื่อน​ไป​ทำ​จริง​ตอน​มี​เครื่องมือ

ก่อน​อื่น​วาง​ฉาก​จาก​บท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['.'] = 0
itos = {i: s for s, i in stoi.items()}
V = len(stoi) # 27
block_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, 200
fan_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 เกือบ uniform
W2 = rng.standard_normal((n_hidden, V)) * 0.01
b2 = np.zeros(V)
print(loss_of(C, W1, b1, W2, b2, ix)) # ตอนนี้เริ่มใกล้ ~3.2958 แล้ว
ทำไม​แค่ 'หรี่' ไม่ 'ดับ' (ทำไม​ไม่​ตั้ง W2 = 0 ไป​เลย)

เพราะ​ถ้า W2 = 0 ทั้ง​ก้อน logits จะ​เป็น 0 เป๊ะ และ สมมาตร​จน​เกิน​ไป — เรา​แค่​อยาก​ให้​เริ่ม “อ่อนโยน” (softmax เกือบ uniform) ไม่ใช่​ตัด​สัญญาณ​ทิ้ง คูณ 0.01 ทำให้ logits จิ๋ว​แต่​ยัง​มี​ความ​ต่าง​เล็กๆ ให้ gradient จับ​ทิศ​ได้​ตั้งแต่​ก้าว​แรก

แก้ last layer แล้ว loss เริ่ม​ถูก​จุด แต่​ยัง​มี​อาการ​ที่​สอง — คราว​นี้​อยู่​ที่ ชั้น​ซ่อน ลอง​วัด​สุขภาพ​ของ activation h = tanh(hpreact) ด้วย init ดิบ (W1 = standard_normal, b1 สุ่ม):

# ❌ W1 ใหญ่เกิน → hpreact กว้าง → tanh อิ่มตัวชนขอบ ±1
W1 = 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 + b1
h = np.tanh(hpreact)
print((np.abs(h) > 0.99).mean()) # สัดส่วน activation ที่ 'อิ่มตัว' — สูงมาก
dead = (np.abs(h) > 0.99).all(axis=0) # neuron ที่อิ่มตัว 'ทุกตัวอย่าง' ใน batch
print(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 ของ tanh
W1 = rng.standard_normal((fan_in, n_hidden)) * gain / np.sqrt(fan_in) # ≈ ×0.304
b1 = np.zeros(n_hidden)
hpreact = C[Xtr[ix]].reshape(-1, fan_in) @ W1 + b1
h = 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:

Activationgainสูตร variance ที่​ตั้งใจต้นทาง
tanh5/3 ≈ 1.667Glorot 2010 (S11)
ReLU√2 ≈ 1.414Var = 2/fan_in (Kaiming/He)He 2015 (S12)
linear / sigmoid1Var = 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 เลย​หาย​ไป​ครึ่ง ต้อง​ชดเชย​กลับ

เส้น A/D — init เคย​เป็น​เรื่อง​คอ​ขาด​บาด​ตาย วัน​นี้​เบา​ลง (แต่​ยัง​ต้อง​เข้าใจ)

“ของสด​ใช้ 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 มัน​ด้วย​มือ​ทุก​ครั้ง

สอง​อาการ​แรกวัด​ได้​จาก 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)

แทนที่​จะ​พยายาม 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.001
bngain = 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) เฉลี่ยตามแกน batch
var = hpreact.var(axis=0, keepdims=True) # (1, 200) ต่อ feature
xhat = (hpreact - mu) / np.sqrt(var + eps) # normalize: mean 0, var 1
hpreact = bngain * xhat + bnbias # เลื่อน+ขยายกลับ (เรียนได้)
h = np.tanh(hpreact)
# อัปเดต running stats — ไม่เกี่ยวกับ gradient เลย เก็บไว้ใช้ตอน eval
bnmean_running = (1 - momentum) * bnmean_running + momentum * mu
bnvar_running = (1 - momentum) * bnvar_running + momentum * var
bias=False บน​ชั้น​ก่อน BN — b1 ไร้​ผล ลบ​ทิ้ง​เลย

สังเกต​ว่า​บรรทัด 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) @ W1
xhat = (hpreact - bnmean_running) / np.sqrt(bnvar_running + eps) # running, ไม่ใช่ batch!
hpreact = bngain * xhat + bnbias
flowchart 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

ทำไม 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 ≠ torch.var (biased vs unbiased)

numpy.var หาร​ด้วย N (biased, ddof=0) ส่วน function torch.var หาร​ด้วย N−1 (unbiased, ddof=1) โดย default ถ้า​คุณ​เอา BatchNorm ที่​เขียน​มือ​ด้วย NumPy ไป​เทียบ​เลข​กับ PyTorch แล้ว​เจอ​ว่า “ไม่​ตรง​กัน​นิดๆ” — นี่แหละ​คือ​กับดัก ให้​ระบุ ddof ให้​ตรง​กัน​ก่อน​เทียบ (จะ​ทำให้​ตรง ให้​ใช้​สถิติ​แบบ biased ทั้ง​คู่​ตอน normalize)

จุด​ที่​ต้อง​เข้าใจ​ให้​ลึก​ที่สุด​ของ BatchNorm คือ: มัน​ทำให้ output ของ​ตัวอย่าง​หนึ่ง ขึ้น​กับ​ตัวอย่าง​อื่น​ใน batch เดียวกัน (ผ่าน mu/var ที่​ใช้​ร่วม​กัน) — เรา​เรียก​ว่า BatchNorm couple ตัวอย่าง​ใน​ก้อน​เข้า​ด้วย​กัน คุณสมบัติ​นี้​เป็น​ดาบสอง​คม:

เส้น B — coupling ของ BatchNorm: เป็น​ทั้ง​ของ​แถม​และ​ราคา

“เรา​สร้าง​เพื่อ​เข้าใจ ไม่ใช่​เพื่อ 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()


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

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

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

ข้อ 1 / 3

ทำไมตอน init ถึงคูณ W2 ด้วย 0.01 และตั้ง b2 = 0 บนชั้นสุดท้าย?