[LLM 7/10] Model Distillation: คำตอบที่ผิดของครู คือส่วนที่มีค่าที่สุด
บทที่ 6 เรากลั่น "บริบท" เข้าไปในน้ำหนักของโมเดลตัวเดิม บทนี้เราจะกลั่น "โมเดลทั้งตัว" ลงไปในโมเดลที่เล็กกว่า สองบทนี้เป็นคู่กันโดยตั้งใจ: context distillation เปลี่ยนสิ่งที่โมเดลรู้โดยไม่ต้องบอกมันอีกต่อไป — model distillation เปลี่ยนขนาดของโมเดลโดยพยายามไม่เปลี่ยนสิ่งที่มันทำได้ และหัวใจของบทนี้ขัดสัญชาตญาณอย่างแรง: สิ่งที่มีค่าที่สุดที่ครูส่งให้นักเรียนได้ ไม่ใช่คำตอบที่ถูก แต่คือวิธีที่ครูผิด — และปุ่มที่ชื่อ temperature คือสิ่งที่ทำให้เรามองเห็นมัน
Open in Colab07_model_distillation.ipynb
1. ปัญหา (Problem statement)
สมมติว่าคุณเดินทางมาครบหกบท จนได้ Qwen3-1.7B ที่ตอบโจทย์ภาษาไทยขององค์กรได้น่าพอใจ แล้ววันหนึ่งฝ่าย infrastructure ถามคำถามเดียวที่โมเดลตอบไม่ได้: "ค่า GPU เดือนละเท่าไหร่"
โมเดล 1.7B กิน VRAM เกือบ 3 เท่าของ 0.6B และตอบช้ากว่าราว 2.5 เท่า ที่ 100 คำขอต่อวินาที ส่วนต่างนี้ไม่ใช่รายละเอียด — มันคือจำนวนการ์ดที่ต้องซื้อเพิ่มทุกเดือนตลอดอายุระบบ
| ทางเลือก | คุณภาพ | ต้นทุนตอนใช้งาน |
|---|---|---|
| Deploy ครู 1.7B ตรง ๆ | ดีที่สุด | VRAM ~3 เท่า ช้ากว่า ~2.5 เท่า จ่ายตลอดอายุระบบ |
| Deploy นักเรียน 0.6B ตรง ๆ | ตกลงชัดเจน | ถูกและเร็ว |
| SFT นักเรียนด้วยเฉลยจริง | ดีขึ้นบ้าง | ถูกและเร็ว |
| Model distillation | ขยับเข้าหาครู | ถูกและเร็ว เท่านักเรียนเป๊ะ |
คำถามคือ แถวสุดท้ายรู้อะไรที่แถว SFT ไม่รู้ — เทรนเหมือนกัน ข้อมูลชุดเดียวกัน ต่างกันตรงไหน
คำตอบอยู่ที่ปริมาณข้อมูลต่อหนึ่ง token ฉลากแข็ง (hard label) คือ one-hot vector: บอกแค่ว่า "คำตอบคือ 3" จบ แต่ distribution ของครูที่ถูกทำให้นุ่มด้วย temperature บอกว่า "คำตอบคือ 3 — แต่ 8 ก็พอเป็นไปได้อยู่ ส่วน 'แมว' นี่ไร้สาระสิ้นเชิง" การจัดอันดับเหนือคำตอบที่ผิดทั้งหมดนี่แหละที่ Hinton เรียกว่า dark knowledge มันเข้ารหัสโครงสร้างความคล้ายของโลกเอาไว้ (เลข 3 ใกล้เลข 8 มากกว่าใกล้แมว) และมันหายไปหมดเกลี้ยงเมื่อคุณเก็บแต่ argmax
ทุกตำแหน่ง token ครูมีตัวเลขให้ 151,936 ตัว (ขนาด vocab ของ Qwen3) — ฉลากแข็งเก็บมาแค่ตัวเดียว
2. เราจะทำอะไร (Solution)
เราจะเอา Qwen/Qwen3-1.7B เป็นครู และ Qwen/Qwen3-0.6B-Base เป็นนักเรียน แล้วกลั่นด้วยกัน 3 ระดับ ซึ่งส่งข้อมูลจากครูละเอียดขึ้นเรื่อย ๆ:
- SeqKD — ให้ครู generate คำตอบ แล้ว SFT นักเรียนบนคำตอบนั้น (ตัวอย่างจาก distribution)
- Logit KD — ให้นักเรียนเลียนแบบ distribution ของครูทั้งก้อน ทีละตำแหน่ง token (ตัว distribution เอง)
- GKD (ทางเลือกเสริม) — ให้นักเรียนสุ่มคำตอบของตัวเอง แล้วครูให้คะแนน distribution แบบ on-policy
และที่ขาดไม่ได้คือแถวควบคุม: SFT นักเรียนด้วยเฉลยจริงบนข้อมูลชุดเดียวกัน จำนวน step เท่ากัน ถ้าไม่มีแถวนี้ เราจะแยกไม่ออกเลยว่ากำไรมาจาก "distribution ของครู" หรือมาจาก "การเทรนเพิ่มเฉย ๆ"
ฉลากแข็งบอกว่า "คำตอบคือ 3" — distribution ของครูบอกว่า "3 แต่ 8 ก็เกือบใช่ และ 'แมว' เป็นไปไม่ได้" การจัดอันดับเหนือคำตอบที่ผิดคือ dark knowledge และ temperature คือปุ่มที่เปิดเผยมัน ที่ T = 1 ความรู้นี้ถูกบีบจนมองไม่เห็น (ครูมั่นใจ 0.97) พอยก T ขึ้น มันจึงกลายเป็น signal ที่เทรนได้จริง
วิธีนี้ไม่ใช่เทคนิคห้องทดลอง — มันคือวิธีที่โมเดล "เล็กแต่เก่ง" ส่วนใหญ่ในโลกถูกสร้างจริง Qwen3-0.6B ที่เราใช้กันมาทั้งซีรีส์ ก็ถูกฝึกด้วย strong-to-weak distillation จากรุ่นใหญ่ของตระกูลมันเอง บทนี้เรากำลังทำสิ่งเดียวกัน ในขนาดที่ Colab ฟรีรับไหว
3. สมการ (Equation)
3.1 KD loss ของ Hinton
- = logits ของนักเรียนและครู ที่ตำแหน่ง token เดียวกัน บน input เดียวกัน
- = เฉลยจริง (hard label) — พจน์แรกคือ cross-entropy ปกติ เหมือน SFT ทุกประการ
- = temperature หารเข้าไปใน logits ทั้งสองฝั่ง ก่อน softmax
- = น้ำหนักของพจน์ soft (เราใช้ 0.9 — ฟังครูเป็นหลัก มีเฉลยจริงคอยกันหลุด)
สังเกตทิศของ KL: ครูอยู่หน้า นี่คือ forward KL ที่บังคับนักเรียนให้แผ่ความน่าจะเป็น ครอบคลุมทุกที่ที่ครูให้น้ำหนัก จำจุดนี้ไว้ เดี๋ยวสมการ 3.4 จะทำให้มันกลายเป็น "จุดหนึ่งบนเส้น"
แล้ว ที่คูณอยู่หน้า KL มาจากไหน โค้ด KD แทบทุกชิ้นบนอินเทอร์เน็ตมีตัวคูณนี้ แต่น้อยชิ้นมากที่อธิบายว่าทำไม — และถ้าไม่เข้าใจมัน คุณจะจูน T แบบผิด ๆ โดยไม่รู้ตัว
3.2 อนุมานที่มาของ — สองบรรทัดที่แยก "เข้าใจ" ออกจาก "ก๊อปมา"
บรรทัดที่ 1 — gradient ของพจน์ soft ต่อ logit ของนักเรียนหนึ่งตัว คือ gradient มาตรฐานของ softmax-CE บวก chain rule ผ่าน ซึ่งคาย ออกมาหนึ่งตัว:
บรรทัดที่ 2 — เมื่อ ใหญ่ softmax จะแบนลงรอบ ๆ uniform: (โดย = ขนาด vocab) ดังนั้นผลต่าง ก็หดลงตาม อีกชั้นหนึ่ง:
gradient ของพจน์ soft สเกลตาม ขณะที่พจน์ hard CE ไม่ขึ้นกับ เลย ถ้าไม่คูณ คืน การขยับ T จาก 1 เป็น 4 จะเท่ากับแอบหาร learning rate ของพจน์ soft ด้วย ~16 คุณจะสรุปว่า "T สูงแล้วไม่เวิร์ก" ทั้งที่จริง ๆ คุณแค่เผลอปิด soft loss ของตัวเองไปเฉย ๆ การคูณ ทำให้สเกลของ gradient แทบไม่ขึ้นกับ T — ค่า ที่จูนไว้จึงมีความหมายเดิมทุก T
ของแถมจากบรรทัดที่ 2: ในลิมิตเดียวกัน soft loss ลดรูปเป็นการ match logits ที่ถูก center แบบ MSE — KD คือ "logit regression แบบนุ่ม" ที่ให้น้ำหนักส่วนหัวของ distribution มากกว่าส่วนหาง
3.3 SeqKD — baseline ราคาถูก (Kim & Rush, 2016)
อ่านแล้วคุ้นไหมครับ — นี่คือ SFT ธรรมดาบนคำตอบที่ครู generate ไม่มีอะไรมากกว่านั้น แทนที่จะส่ง distribution ทั้งก้อน ครูส่ง "ตัวอย่างหนึ่งตัว" ที่สุ่มจาก distribution ของตัวเองมาให้
ข้อดีที่มักถูกมองข้าม: SeqKD ไม่สนใจว่า tokenizer ตรงกันหรือไม่ เพราะมันส่งข้อความ ไม่ได้ส่ง logits โมเดล open-source จำนวนมากที่โฆษณาว่า "distilled from GPT-4" แท้จริงคือ SeqKD ล้วน ๆ — เก็บคำตอบครูผ่าน API แล้ว SFT นี่คือเหตุผลที่มันเป็น baseline ที่เราต้องวัดให้ได้ก่อนไปวิธีที่แพงกว่า
3.4 GKD และ generalized JSD — เส้นที่เชื่อมบทนี้กับบทที่ 6
Logit KD ตามสมการ 3.1 มีจุดอ่อนเชิงโครงสร้าง: นักเรียนเรียนบนประโยคที่คนอื่นเขียน (teacher forcing) แต่ตอนใช้งานจริงมันต้อง generate ต่อจากคำตอบของตัวเอง — ความคลาดเคลื่อนสะสมนี้เรียกว่า exposure bias GKD (Agarwal et al., 2023) แก้ด้วยการให้นักเรียนสุ่มคำตอบเอง แล้วครูให้คะแนนบน token เหล่านั้น พร้อมทั้ง generalize ระยะทางระหว่าง distribution เป็น:
โดย = ครู, = นักเรียน ค่า กวาดจากขั้วหนึ่งไปอีกขั้วหนึ่ง:
- → forward KL — mass-covering: นักเรียนต้องแผ่คลุมทุกโหมดของครู
- → reverse KL — mode-seeking: นักเรียนเลือกยึดบางโหมดที่ตัวเองไหว
พูดให้ชัดที่สุด: reverse KL ที่บทที่ 6 เลือกใช้ ไม่ใช่ของแปลกจากอีกโลก — มันคือจุด บนเส้นเดียวกันนี้ ส่วน KD คลาสสิกของ Hinton คือจุด สองบทนี้จึงเป็นสมาชิก ครอบครัวเดียวกันที่ต่างกันแค่ว่า "ใครต้องขยับเข้าหาใคร" — นักเรียนที่เล็กกว่าครูมากมัก ได้ประโยชน์จากฝั่ง mode-seeking เพราะความจุไม่พอจะคลุมทุกโหมดของครูอยู่แล้ว
4. เห็นภาพสมการ (Visualize)
Temperature เปิดเผย dark knowledge
Figure 7.1logit vector จริง 10 ช่องของครู หลังบริบท '7 × 8 = ' ถูก softmax ที่ T = 1, 2, 4, 8 — คำตอบถูก ('56') อยู่อันดับหนึ่งเสมอ แต่การจัดอันดับเหนือคำตอบผิด (54 ≻ 48 ≻ 63 ≻ … ≻ cat) โผล่ให้เห็นก็ต่อเมื่อยก T ขึ้น
จุดที่ต้องอ่านให้เห็น: temperature ไม่ได้เพิ่มข้อมูลอะไรเลย — logits ชุดเดิมเป๊ะ ๆ มันแค่เปลี่ยนว่าข้อมูลที่มีอยู่แล้วจะมองเห็นได้แค่ไหน ที่ T = 1 คำตอบผิดที่ดีที่สุดมีความน่าจะเป็น แค่ 0.015 — gradient ที่ไหลผ่านมันแทบเป็นศูนย์ ที่ T = 8 การจัดอันดับทั้งแถวกลายเป็น signal ที่นักเรียนเรียนได้จริง ในขณะที่ "cat" กับ "!" ยังนอนอยู่ที่พื้นตามที่ควรจะเป็น
ตัวคูณ ที่หายไป มองเห็นได้ในกราฟเดียว
Figure 7.2ขนาด gradient ของ soft loss ต่อ logit ของนักเรียน คำนวณตรง ๆ จากสูตร (q−p)/T บน logit vector คู่เดิม — ไม่มี T² gradient ไหลลงตาม 1/T² (เส้นแดง) ใส่ T² คืนแล้วสเกลนิ่งตลอดช่วง T (เส้นเขียว)
นี่คือสมการ 3.2 ในรูปที่ตาเห็น: เส้นแดงคือสิ่งที่เกิดขึ้นถ้าคุณลืม — พอกวาดหา T ที่ดีที่สุด คุณกำลังกวาด learning rate ของ soft loss ไปพร้อมกันโดยไม่ตั้งใจ ผลการทดลองทั้งตารางจะอ่านไม่ออก เพราะสองตัวแปรพันกันอยู่ เส้นเขียวคือเหตุผลเดียวที่เราจูน T ได้อย่างสะอาด
ครูกับนักเรียนมองตำแหน่งเดียวกัน ต่างกันแค่ไหน
ลองส่องของจริง: top-5 ของครูเทียบนักเรียนที่ตำแหน่ง token ภาษาไทยจากโน้ตบุ๊ก (มุมมอง "ก่อน" คือนักเรียน มุมมอง "หลัง" คือครู — ช่องว่างระหว่างสองมุมมองนี้คือสิ่งที่ logit KD พยายามปิด):
Promptทักทายเป็นภาษาไทย
สวัสดีครับ ผมชื่อโมเดลภาษาไทย
Showing the built-in sample.
5. เตรียมสภาพแวดล้อม (Environment)
เปิด Colab เลือก Runtime → Change runtime type → T4 GPU (แผนฟรีพอ แต่บทนี้ VRAM ตึงเป็นพิเศษ เพราะครูกับนักเรียนต้องอยู่บนการ์ดพร้อมกัน)
Colab T4 คือสถาปัตยกรรม Turing (SM 7.5) ซึ่ง ไม่รองรับ bfloat16 และ ไม่รองรับ FlashAttention-2
แต่ config.json ของตระกูล Qwen3 ระบุ torch_dtype: bfloat16 เอาไว้
ดังนั้น torch_dtype="auto" คือกับดัก โค้ดจะพังหรือช้าผิดปกติโดยไม่บอกสาเหตุ
torch_dtype=torch.float16 # ไม่ใช่ bfloat16
attn_implementation="sdpa" # ไม่ใช่ flash_attention_2
fp16=True # ใน TrainingArguments (ไม่ใช่ bf16=True)
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True (นับ emulation!)
is_bf16_supported() โกหกคุณบน T4torch รุ่นใหม่ตอบ True บน T4 เพราะนับ การจำลอง (emulation) ว่ารองรับด้วย ซึ่งช้ากว่า fp16 มาก
ให้เช็ค compute capability ≥ 8.0 (Ampere ขึ้นไป) แทน — นี่คือบั๊กจริงที่เจอตอนรันโน้ตบุ๊กบน Colab จริง ๆ
โหลดสองโมเดลพร้อมกัน — งบ VRAM ที่ตึงที่สุดในซีรีส์
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
teacher = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-1.7B", # ครู: instruct ~3.4 GB (fp16)
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda().eval() # .eval() เสมอ — ครูไม่เรียนอะไรทั้งนั้น
student = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B-Base", # นักเรียน: base ~1.2 GB (fp16)
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda()
น้ำหนักสองก้อนรวม ~4.6 GB ฟังดูสบาย แต่ตอนเทรนต้องเผื่อ activations ของทั้งสองโมเดล บวก optimizer ของ LoRA บวก logits ชั่วคราว — บน T4 16 GB จึงเหลือที่ให้ batch size แค่ 2 แล้วใช้ gradient accumulation ทดแทน นี่คือราคาของการมีครูนั่งอยู่บนการ์ดด้วยกัน (หัวข้อ 6 จะแสดงวิธี "ไล่ครูลงจากการ์ด" ด้วยการ precompute logits แบบ offline)
เซลล์ assert ที่ต้องมาก่อนทุกอย่าง
tok_t = AutoTokenizer.from_pretrained("Qwen/Qwen3-1.7B")
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B-Base")
assert teacher.config.vocab_size == student.config.vocab_size == 151_936
assert tok_t.get_vocab() == tok.get_vocab()
print("vocab ตรงกันทุกช่อง — logit KD เป็นไปได้")
KL ในสมการ 3.1 เทียบ distribution สองก้อนมิติต่อมิติ: มิติที่ ของครูต้องหมายถึง token เดียวกับมิติที่ ของนักเรียน ถ้า vocab ไม่ตรง คุณกำลังเทียบความน่าจะเป็นของ "กรุงเทพ" กับความน่าจะเป็นของ token อื่นที่บังเอิญได้เลขช่องเดียวกัน — ตัวเลขจะไหลออกมาสวยงามและไร้ความหมายสิ้นเชิง
ซ้ำร้ายกว่านั้น ประโยคเดียวกันจะถูกหั่นเป็น token คนละชุด ตำแหน่งที่ ของครูกับนักเรียน จึงชี้ไปคนละจุดของข้อความ เทียบกันไม่ได้ตั้งแต่แกนเวลา
Qwen3 ทั้งตระกูลใช้ tokenizer เดียวกัน เราจึงรอด แต่ถ้าครูของคุณคือ GPT-4 หรือ Typhoon ที่ vocab ไม่ตรงกับนักเรียน ทางเดียวที่เหลือคือ SeqKD (สมการ 3.3) ซึ่งส่งข้อความ ไม่ส่ง logits
6. เตรียมข้อมูล (Data)
เราใช้ 3,000 prompt จาก airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k
ชุดข้อมูล instruction ภาษาไทยที่มีเฉลยอ้างอิงมาให้ครบ:
from datasets import load_dataset
ds = load_dataset("airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k",
split="train")
ds = ds.shuffle(seed=42).select(range(3000))
จากข้อมูลก้อนเดียวนี้ เราสร้างวัตถุดิบ 3 ชิ้น:
ชิ้นที่ 1 — เฉลยจริง ใช้ตรง ๆ จากชุดข้อมูล เป็นทั้งข้อมูลของแถวควบคุม (SFT) และเป็นประโยคที่ใช้เทรน logit KD — จงใจให้สองแถวนี้เห็นข้อความเดียวกันทุกตัวอักษร เพื่อให้ความต่างเดียวที่เหลือคือ "มี distribution ของครูหรือไม่"
ชิ้นที่ 2 — คำตอบของครู สำหรับ SeqKD: ให้ครู generate แบบ batch ทีละ 16 prompt
(max_new_tokens=192, do_sample=False) ใช้เวลาราว 20–25 นาที ทำครั้งเดียวเก็บลงดิสก์
ชิ้นที่ 3 — top-64 logits ของครู สำหรับ logit KD: forward ครูผ่านประโยคของชิ้นที่ 1 แล้วเก็บเฉพาะ 64 อันดับแรกของแต่ละตำแหน่ง
ทำไมต้อง top-64 — เพราะเก็บทั้ง vocab ไม่ได้จริง ๆ ลองคิดเลขดู:
Figure 7.3คณิตศาสตร์ของหน่วยความจำ: tensor logits เต็ม vocab ของครูหนึ่ง batch (4 × 512 × 151,936 × fp16) คือ 622 MB — top-64 เหลือ 0.79 MB เล็กลง 791 เท่า และถ้าเก็บ offline ทั้ง 3,000 ตัวอย่าง: 467 GB เทียบกับ 0.59 GB
K = 64
@torch.no_grad() # สำคัญที่สุดในเซลล์นี้ — ดูกับดักข้อ 3 ในหัวข้อ 9
def teacher_topk(input_ids, attention_mask):
z = teacher(input_ids=input_ids,
attention_mask=attention_mask).logits # [B, L, 151936]
val, idx = z.topk(K, dim=-1) # [B, L, 64]
return val.half().cpu(), idx.int().cpu()
รายละเอียดเล็ก ๆ ที่สอนอะไรได้เยอะ: index ต้องเป็น int32 เพราะ vocab 151,936 เกินเพดานของ uint16 (65,535) ไปเท่าตัว — ดิสก์ที่ใช้เก็บ index จึงใหญ่กว่าตัวค่า logits เสียอีก (4 ไบต์ ต่อ 2 ไบต์) รวมทั้งชุด 3,000 ตัวอย่าง × 512 ตำแหน่ง × 64 อันดับ ≈ 590 MB บนดิสก์
ที่ T = 2 มวลความน่าจะเป็นของครูกระจุกอยู่ในหัว distribution อย่างหนัก โน้ตบุ๊กจะพิมพ์ ค่า coverage จริงให้ดู (มวลรวมของ top-64 หลัง softmax ที่ T = 2 — โดยทั่วไปเกิน 99%) สิ่งที่เราทิ้งคือหางยาว 151,872 token ที่แต่ละตัวได้ความน่าจะเป็นจิ๋วมาก แลกกับการที่ทั้งไฟล์เล็กลง 791 เท่า — เป็นการประมาณที่วัดได้ ไม่ใช่การเดา
7. โค้ดหลัก (Main code)
7.1 LoRA บนนักเรียน + กับดัก fp16 เดิมจากบทที่ 1
from peft import LoraConfig, get_peft_model
student = get_peft_model(student, LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05, task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
))
for p in student.parameters(): # กล่อง fp16 จากบทที่ 1 ในเวอร์ชัน LoRA:
if p.requires_grad: # cast เฉพาะพารามิเตอร์ adapter เป็น fp32
p.data = p.data.float() # ไม่งั้นเจอ "Attempting to unscale FP16 gradients."
7.2 เขียน KD loss เองด้วยมือ — สมการ 3.1 ทีละบรรทัด
import torch.nn.functional as F
def kd_loss(z_s, t_val, t_idx, labels, T=2.0, alpha=0.9):
"""z_s: [B, L, V] logits นักเรียน — t_val/t_idx: [B, L, 64] top-64 ของครู (อ่านจากดิสก์)"""
z_s, t_val, t_idx = z_s[:, :-1], t_val[:, :-1], t_idx[:, :-1]
tgt = labels[:, 1:] # ตำแหน่ง t ทำนาย token ที่ t+1
mask = tgt.ne(-100) # กัน prompt และ padding ปนเข้า loss
# ── พจน์ hard: cross-entropy กับเฉลยจริง เหมือน SFT ทุกประการ ──
ce = F.cross_entropy(z_s.flatten(0, 1).float(), tgt.flatten(),
ignore_index=-100)
# ── พจน์ soft: KL(ครู ‖ นักเรียน) บนแกน top-64 — หาร T "ทั้งสองฝั่ง" ──
p_t = F.softmax(t_val.float() / T, dim=-1) # renormalize บน 64 มิติ
log_q = F.log_softmax(z_s.gather(-1, t_idx.long()).float() / T, dim=-1)
kl = (p_t * (p_t.clamp_min(1e-9).log() - log_q)).sum(-1) # KL ต่อตำแหน่ง
kl = (kl * mask).sum() / mask.sum().clamp_min(1) # เฉลี่ยเฉพาะ token คำตอบ
return (1 - alpha) * ce + alpha * (T ** 2) * kl # ← T² จากสมการ 3.2
ทั้งบทความอัดแน่นอยู่ในบรรทัดสุดท้ายบรรทัดเดียว: (1 - alpha) * ce คือเฉลยจริงที่กันนักเรียนหลุด
alpha * (T ** 2) * kl คือ dark knowledge ของครูพร้อมตัวคูณที่เราเพิ่งอนุมานมากับมือ
7.3 Trainer ที่ป้อน logits ครูจากดิสก์
from transformers import TrainingArguments, Trainer
class KDTrainer(Trainer):
def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
t_val = inputs.pop("teacher_val") # มาจากดิสก์ ไม่ใช่จากครูบนการ์ด
t_idx = inputs.pop("teacher_idx")
out = model(input_ids=inputs["input_ids"],
attention_mask=inputs["attention_mask"])
loss = kd_loss(out.logits, t_val, t_idx, inputs["labels"])
return (loss, out) if return_outputs else loss
args = TrainingArguments(
output_dir="kd-out",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # effective batch = 16
num_train_epochs=1,
learning_rate=1e-4, # LoRA บนนักเรียน — จูนเฉพาะ adapter
lr_scheduler_type="cosine",
warmup_ratio=0.05,
gradient_checkpointing=True,
fp16=True, # T4 ไม่มี bf16
logging_steps=10,
remove_unused_columns=False, # ← ห้ามลืม ไม่งั้น Trainer โยน
) # teacher_val/teacher_idx ทิ้งเงียบ ๆ
remove_unused_columns=False คือบรรทัดที่คนพลาดบ่อยที่สุดในบทนี้: โดยปกติ Trainer
จะทิ้งคอลัมน์ที่ signature ของโมเดลไม่รู้จัก — ซึ่งรวมถึง logits ครูของเราด้วย
อาการที่เห็นคือ KeyError: 'teacher_val' ตอน step แรก โชคดีที่มันพังดัง ไม่ได้พังเงียบ
ส่วน SeqKD ไม่ต้องมีอะไรใหม่เลย — ใช้ Trainer ธรรมดากับข้อมูลชิ้นที่ 2
(คำตอบของครู) แล้วเทรนเหมือน SFT ปกติ และแถวควบคุมก็คือ Trainer เดียวกัน
บนเฉลยจริง เวลาเทรนสอง regime หลัก (SeqKD + logit KD) รวมราว ~17 นาที บน T4
(แถวละ 8–9 นาที) แถวควบคุมกินเพิ่มอีก ~8 นาที
7.4 ทางเลือกเสริม (ข้ามได้ทั้งหัวข้อ): GKD แบบ on-policy ด้วย TRL
# รันเฉพาะเมื่อเวลาเหลือ — on-policy ช้ากว่า offline หลายเท่า เพราะนักเรียน
# ต้อง generate ระหว่างเทรน และครูต้องนั่งอยู่บนการ์ดตลอดเวลา
from trl import GKDConfig, GKDTrainer
cfg = GKDConfig(
output_dir="gkd-out",
beta=0.5, # กึ่งกลางเส้น JSD ของสมการ 3.4
lmbda=0.5, # ครึ่งหนึ่งของ batch ใช้คำตอบที่นักเรียนสุ่มเอง
max_new_tokens=128,
per_device_train_batch_size=1,
gradient_accumulation_steps=8,
learning_rate=1e-4,
max_steps=60, # แค่ชิมรส (~25 นาที) ไม่ใช่การเทรนจริง
fp16=True,
)
trainer = GKDTrainer(model=student, teacher_model=teacher,
args=cfg, train_dataset=gkd_ds, processing_class=tok)
trainer.train()
8. ผลลัพธ์ (Results)
โน้ตบุ๊กวัด 4 อย่างแล้วเขียนลง results.json:
- TH-INSTR จากชุดวัด KobEval-TH — คะแนนการทำตามคำสั่งภาษาไทย ก่อน/หลัง พร้อม Wilson 95% CI วัดทั้งครู นักเรียนดิบ และนักเรียนทั้งสามแถว บนข้อสอบชุดเดียวกัน
- Gap closed — ตัวเลขเดียวที่สรุปทั้งบท:
ทำไมไม่รายงานคะแนนดิบ — เพราะ "คะแนนขึ้น 4 จุด" ไม่มีความหมายถ้าไม่รู้ว่าช่องว่างทั้งหมด มีให้ปิดกี่จุด metric นี้ตอบตรงคำถามจริงของเรา: ช่องว่างระหว่างนักเรียนกับครู ปิดไปแล้วกี่เปอร์เซ็นต์ 0% คือไม่ขยับ 100% คือไล่ทันครูพอดี 3. tok/sec ของนักเรียน ก่อนและหลังกลั่น — ควรเท่าเดิมเป๊ะ เพราะสถาปัตยกรรมกับจำนวน พารามิเตอร์ไม่เปลี่ยนแม้แต่ตัวเดียว และนี่แหละคือประเด็นของทั้งบท: คุณภาพขยับเข้าหาครู แต่ latency ไม่ขยับไปไหนเลย — ถ้าอยากได้ประโยคเดียวไว้เล่าให้ทีมฟัง ประโยคนั้นคือ "ได้คุณภาพ (บางส่วนของ) ครู ที่ราคาของนักเรียน" 4. TH-SAFE spot check — ครูส่งทุกอย่างผ่าน distribution รวมทั้งอคติและนิสัยเสียของมันด้วย เราจึงวัดนักเรียนหลังกลั่นบนชุดคำถามปลอดภัยภาษาไทย แล้วรายงานตามจริงว่ารับอะไรติดมาบ้าง (รายละเอียดในกล่องข้อจำกัดท้ายบท)
ตัวเลขจริงในตารางถัดไปปล่อยเป็น ? ไว้ — มันต้องมาจากการรันโน้ตบุ๊กของคุณเอง ไม่ใช่จากบทความ
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
9. เปรียบเทียบ (Comparison)
| โมเดล | TH-INSTR (95% CI) | Gap closed | tok/sec | เวลาเทรน |
|---|---|---|---|---|
| ครู Qwen3-1.7B | ? (เพดาน) | 100% โดยนิยาม | ~ช้ากว่านักเรียน 2.5 เท่า | — |
| นักเรียน 0.6B base | ? (พื้น) | 0% โดยนิยาม | baseline | — |
| นักเรียน + SFT บนเฉลยจริง (ตัวควบคุม) | ? | ? | เท่า base | ~8 นาที |
| นักเรียน + SeqKD | ? | ? | เท่า base | ~8 นาที |
| นักเรียน + logit KD (T=2, α=0.9) | ? | ? | เท่า base | ~9 นาที |
ถ้าไม่มีแถว "SFT บนเฉลยจริง" ตารางนี้พิสูจน์อะไรไม่ได้เลย เพราะทุกแถวที่เหลือ ได้ทั้ง "การเทรนเพิ่ม" และ "ข้อมูลจากครู" พร้อมกัน — จะเคลมว่า dark knowledge มีผลจริง ต้องแยกสองอย่างนี้ออกจากกันให้ได้
แถวควบคุมเทรนบนประโยคเดียวกัน จำนวน step เท่ากัน ไฮเปอร์พารามิเตอร์เดียวกันกับแถว logit KD ต่างกันข้อเดียว: ไม่มี distribution ของครู ดังนั้น ส่วนต่างระหว่างสองแถวนี้คือมูลค่าของ dark knowledge ล้วน ๆ ถ้าสองแถวนี้เท่ากัน แปลว่า KD ทั้งหมดที่ทำมาไม่คุ้มค่า teacher forward แม้แต่ครั้งเดียว — และคุณจะรู้ได้จากตารางนี้เท่านั้น
Figure 7.4วิธีอ่าน gap closed: 0% คือนักเรียนก่อนเทรน 100% คือครู — ระยะห่างระหว่างแถวควบคุม (ส้ม) กับแถว logit KD คือส่วนที่อธิบายได้ด้วย distribution ของครูเท่านั้น (ตัวเลขในภาพเป็นภาพประกอบกลไก ไม่ใช่ผลวัดจริง — ผลจริงมาจากโน้ตบุ๊ก)
รูปแบบที่คุณควรจะเห็น: logit KD ≻ SeqKD ≻ SFT ควบคุม ≻ base และ tok/sec สี่แถวล่างเท่ากันหมด ถ้าเห็นอย่างอื่น ให้ตีความแบบนี้:
- แถวควบคุมดีพอ ๆ กับ logit KD → สัญญาณ KD ไม่ได้เพิ่มอะไรบนงานนี้ ลอง T สูงขึ้น (dark knowledge ยังถูกบีบอยู่) หรือเพิ่ม α หรือครูกับนักเรียนใกล้กันเกินไป
- SeqKD ชนะ logit KD → เกิดได้จริงเมื่อเฉลยจริงในชุดข้อมูลเขียนแย่กว่าคำตอบครู (logit KD ของเราเทรนบนเฉลยจริง) — นี่คือข้อมูล ไม่ใช่ความล้มเหลว รายงานมันตรง ๆ
- ทุกแถวแทบไม่ขยับ → 3,000 ตัวอย่างอาจน้อยไปสำหรับช่องว่างครู-นักเรียนคู่นี้ ดู loss curve ก่อนสรุป และอ่านกล่องข้อจำกัดท้ายบท
กับดักที่ต้องระวัง
1. ลืมตัวคูณ ไม่มี error ใด ๆ โผล่มาเลย แค่ผลการกวาด T ของคุณกลายเป็นเรื่องแต่ง เพราะทุกครั้งที่ขยับ T คุณแอบขยับ learning rate ของ soft loss ไปด้วย (รูปที่ 7.2) — bug ที่เงียบที่สุดของบทนี้
2. ใส่ temperature ฝั่งเดียว
เขียน softmax(z_t / T) แต่ลืมหาร T ฝั่งนักเรียน — นักเรียนจะถูกบังคับให้เลียนแบบ
distribution แบน ๆ ของครูด้วย logits คม ๆ ของตัวเอง ผลคือมันเรียนรู้ที่จะ "แบนจริง ๆ"
แล้วตอนใช้งาน (ซึ่งไม่มี T) คำตอบจะจืดและกระจายผิดปกติ สมการ 3.1 หาร T ทั้งสองฝั่งเสมอ
3. ไม่ detach logits ของครู
ถ้า forward ครูโดยไม่มี torch.no_grad() autograd จะเก็บ activation ของครูทั้งตัวไว้รอ
backward ที่ไม่มีวันมาถึง — VRAM บวมเงียบ ๆ จน OOM โดยชี้ไปที่บรรทัดอื่น
เส้นทาง offline ของเราปลอดภัยโดยโครงสร้าง (logits อยู่ในดิสก์ ไม่มี graph ให้เก็บ)
นี่คือเหตุผลที่สามของการ precompute นอกเหนือจากเวลาและหน่วยความจำ
4. ปล่อยให้ padding ปนเข้า KL
ตำแหน่ง padding ก็มี distribution ของครูเหมือนกัน — และมันคือขยะ ถ้าไม่ mask ออก
(บรรทัด mask = tgt.ne(-100) ในหัวข้อ 7.2) ค่าเฉลี่ย KL จะถูกเจือจางด้วยตำแหน่งที่
ไม่มีความหมาย แถมสัดส่วนการเจือจางต่างกันตามความยาวประโยคใน batch — loss จะสั่น
แบบหาสาเหตุไม่เจอ
5. สองโมเดลบนการ์ดเดียว = งบ batch ที่หายไป
ครู 3.4 GB นั่งทับที่ที่เคยเป็นของ batch ใหญ่ ๆ ถ้า OOM ให้ลดตามลำดับ:
per_device_train_batch_size → max_length → เลิกให้ครูอยู่บนการ์ด (precompute แบบ offline
ให้จบก่อน แล้วปล่อย del teacher; torch.cuda.empty_cache()) — ลำดับสุดท้ายนี้คือ
โครงสร้างของโน้ตบุ๊กเราอยู่แล้ว
10. สรุป (Summary)
- Dark knowledge อยู่ในคำตอบที่ผิดของครู — การจัดอันดับเหนือทางเลือกผิด ๆ เข้ารหัส โครงสร้างความคล้ายที่ hard label ไม่มีวันบอก และ temperature คือปุ่มที่เปิดเผยมัน
- ตัวคูณ ไม่ใช่เครื่องราง — gradient ของ soft loss สเกลตาม คูณคืนเพื่อให้จูน T ได้โดยไม่แอบเปลี่ยน learning rate ของตัวเอง
- SeqKD คือ SFT บนคำตอบครู — baseline ที่ถูกที่สุด และเป็นทางเดียวเมื่อ tokenizer ไม่ตรงกัน
- Logit KD ต้องการ vocab เดียวกัน — assert ก่อนเสมอ เพราะ KL เทียบมิติต่อมิติ
- Top-64 คือวิศวกรรม ไม่ใช่ทฤษฎี — logits เต็ม vocab หนึ่ง batch คือ tensor 622 MB เก็บ 64 อันดับแรกเหลือ 0.79 MB โดยเสีย coverage ไม่ถึง 1%
- forward KL, reverse KL และ JSD ของ GKD คือเส้นเดียวกัน — ปุ่ม β กวาดจาก mass-covering (บทนี้) ไป mode-seeking (บทที่ 6)
- แถวควบคุม SFT คือสิ่งที่ทำให้ตารางผลมีความหมาย — ไม่มีมัน คุณแยก "ครูช่วย" ออกจาก "เทรนเพิ่มเฉย ๆ" ไม่ได้
- gap closed คือ metric ที่ตอบคำถามจริง — ปิดช่องว่างครู-นักเรียนไปกี่เปอร์เซ็นต์ ที่ tok/sec เท่าเดิมเป๊ะ
ช่องว่าง 1.7B → 0.6B เป็นช่องว่างที่แคบ ครูของเราไม่ได้เก่งกว่านักเรียนแบบทิ้งห่าง กำไรที่วัดได้จึงย่อมแคบตาม — อย่าเอาตัวเลข gap closed จากคู่นี้ไปเทียบกับงานกลั่น 70B → 7B ที่ช่องว่างกว้างกว่าหลายเท่า การทดลองนี้พิสูจน์กลไกและวิธีวัด ไม่ใช่ตัวเลขสุดท้าย
ครูที่ใหญ่กว่านี้ไม่พอดีกับ T4 — 7B fp16 กิน ~14 GB แค่ตัวเดียวก็เกือบเต็มการ์ดแล้ว ทางออกไม่ใช่การ์ดใหญ่ขึ้นเสมอไป แต่คือสิ่งที่เราฝึกทำในบทนี้พอดี: precompute top-K logits แบบ offline บนเครื่องเช่ารายชั่วโมงครั้งเดียว แล้วเทรนนักเรียนที่ไหนก็ได้จากไฟล์ 590 MB โครงสร้าง offline ที่ดูเป็นการประนีประนอมบน Colab แท้จริงคือวิธีที่งานสเกลจริงเขาทำกัน
การกลั่นถ่ายทอดทุกอย่าง รวมทั้งอคติและความผิดพลาดของครู นักเรียนไม่มีกลไกแยกแยะว่า ส่วนไหนของ distribution คือความรู้ ส่วนไหนคือนิสัยเสีย ถ้าครูเกลียดการตอบสั้น นักเรียนจะรับมา ถ้าครูหลุดภาษาอังกฤษกลางประโยคไทยในบริบทไหน นักเรียนก็มีแนวโน้มรับมาด้วย โน้ตบุ๊กจึงวัดนักเรียนหลังกลั่นบน TH-SAFE แล้วรายงานผลเทียบกับครูตรง ๆ — ถ้าตัวเลขบอกว่ารับอะไรติดมา จงเขียนมันลงรายงาน ไม่ใช่ลบแถวนั้นทิ้ง เรื่องนี้คือสะพานไปบทหน้า: โมเดลที่รับทุกอย่างมาจากครูโดยไม่ตั้งคำถาม ต้องมีรั้วของตัวเอง
บทต่อไป: Guardrails — นักเรียนของเราเพิ่งรับทั้งความรู้และนิสัยของครูมาเต็ม ๆ บทหน้าเราจะสร้างรั้วรอบโมเดล: จับ input อันตรายก่อนถึงตัวโมเดล จับ output อันตรายก่อนถึงผู้ใช้ และวัด trade-off ระหว่างความปลอดภัยกับความน่าใช้ด้วยตัวเลขจริง
อ้างอิง (References)
- Hinton et al. (2015). Distilling the Knowledge in a Neural Network — KD ต้นฉบับ: temperature และตัวประกอบ T²
- Kim et al. (2016). Sequence-Level Knowledge Distillation — sequence-level KD -- baseline ที่ถูกที่สุดในหัวข้อ 9
- Agarwal et al. (2023). On-Policy Distillation of Language Models: Learning from Self-Generated Mistakes — GKD: กรอบ JSD ที่รวม forward/reverse KL เข้าด้วยกัน
- Gu et al. (2023). MiniLLM: On-Policy Distillation of Large Language Models — MiniLLM: เหตุผลว่าทำไมต้องใช้ reverse KL
- Sanh et al. (2019). DistilBERT, a distilled version of BERT: smaller, faster, cheaper and lighter — ตัวอย่างการกลั่นที่ใช้งานจริงในวงกว้าง
- Chay-intr et al. (2025). LLaVAC: Fine-tuning LLaVA as a Multimodal Sentiment Classifier — LLaVAC: ตัวอย่าง fine-tune โมเดล multimodal สำหรับงานไทย
บทความ โค้ด และโน้ตบุ๊กในซีรีส์นี้เผยแพร่ภายใต้สัญญาอนุญาต CC BY-NC-SA 4.0 — นำไปใช้และดัดแปลงต่อได้ โดยอ้างอิงที่มา ไม่ใช้เพื่อการค้า และเผยแพร่ต่อด้วยสัญญาเดียวกัน (โมเดลและชุดข้อมูลของบุคคลที่สามที่อ้างถึง ยังคงใช้สัญญาของเจ้าของเดิม)
