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

[LLM 7/10] Model Distillation: คำตอบที่ผิดของครู คือส่วนที่มีค่าที่สุด

· อ่าน 28 นาที
Kobkrit Viriyayudhakorn
CEO, iApp Technology

บทที่ 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 ระดับ ซึ่งส่งข้อมูลจากครูละเอียดขึ้นเรื่อย ๆ:

  1. SeqKD — ให้ครู generate คำตอบ แล้ว SFT นักเรียนบนคำตอบนั้น (ตัวอย่างจาก distribution)
  2. Logit KD — ให้นักเรียนเลียนแบบ distribution ของครูทั้งก้อน ทีละตำแหน่ง token (ตัว distribution เอง)
  3. 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

LKD=(1α)LCE(y, softmax(zS))+αT2DKL(softmax(zT/T)  softmax(zS/T))\mathcal{L}_{\text{KD}} = (1-\alpha)\,\mathcal{L}_{\text{CE}}\big(y,\ \text{softmax}(z_S)\big) + \alpha\, T^2\,\mathbb{D}_{\text{KL}}\Big(\text{softmax}(z_T/T)\ \big\|\ \text{softmax}(z_S/T)\Big)
  • zS,zTz_S, z_T = logits ของนักเรียนและครู ที่ตำแหน่ง token เดียวกัน บน input เดียวกัน
  • yy = เฉลยจริง (hard label) — พจน์แรกคือ cross-entropy ปกติ เหมือน SFT ทุกประการ
  • TT = temperature หารเข้าไปใน logits ทั้งสองฝั่ง ก่อน softmax
  • α\alpha = น้ำหนักของพจน์ soft (เราใช้ 0.9 — ฟังครูเป็นหลัก มีเฉลยจริงคอยกันหลุด)

สังเกตทิศของ KL: ครูอยู่หน้า นี่คือ forward KL ที่บังคับนักเรียนให้แผ่ความน่าจะเป็น ครอบคลุมทุกที่ที่ครูให้น้ำหนัก จำจุดนี้ไว้ เดี๋ยวสมการ 3.4 จะทำให้มันกลายเป็น "จุดหนึ่งบนเส้น"

แล้ว T2T^2 ที่คูณอยู่หน้า KL มาจากไหน โค้ด KD แทบทุกชิ้นบนอินเทอร์เน็ตมีตัวคูณนี้ แต่น้อยชิ้นมากที่อธิบายว่าทำไม — และถ้าไม่เข้าใจมัน คุณจะจูน T แบบผิด ๆ โดยไม่รู้ตัว

3.2 อนุมานที่มาของ T2T^2 — สองบรรทัดที่แยก "เข้าใจ" ออกจาก "ก๊อปมา"

บรรทัดที่ 1 — gradient ของพจน์ soft ต่อ logit ของนักเรียนหนึ่งตัว คือ gradient มาตรฐานของ softmax-CE บวก chain rule ผ่าน z/Tz/T ซึ่งคาย 1/T1/T ออกมาหนึ่งตัว:

LsoftzS,i=1T(qi(T)pi(T)),q(T)=softmax(zS/T),p(T)=softmax(zT/T)\frac{\partial \mathcal{L}_{\text{soft}}}{\partial z_{S,i}} = \frac{1}{T}\Big(q_i^{(T)} - p_i^{(T)}\Big), \qquad q^{(T)} = \text{softmax}(z_S/T),\quad p^{(T)} = \text{softmax}(z_T/T)

บรรทัดที่ 2 — เมื่อ TT ใหญ่ softmax จะแบนลงรอบ ๆ uniform: softmax(z/T)i1K+zizˉKT\text{softmax}(z/T)_i \approx \tfrac{1}{K} + \tfrac{z_i - \bar z}{KT} (โดย KK = ขนาด vocab) ดังนั้นผลต่าง qi(T)pi(T)q^{(T)}_i - p^{(T)}_i ก็หดลงตาม 1/T1/T อีกชั้นหนึ่ง:

LsoftzS,i(zS,izˉS)(zT,izˉT)KT2    1T2\frac{\partial \mathcal{L}_{\text{soft}}}{\partial z_{S,i}} \approx \frac{(z_{S,i} - \bar z_S) - (z_{T,i} - \bar z_T)}{K\,T^2} \;\propto\; \frac{1}{T^2}

gradient ของพจน์ soft สเกลตาม 1/T21/T^2 ขณะที่พจน์ hard CE ไม่ขึ้นกับ TT เลย ถ้าไม่คูณ T2T^2 คืน การขยับ T จาก 1 เป็น 4 จะเท่ากับแอบหาร learning rate ของพจน์ soft ด้วย ~16 คุณจะสรุปว่า "T สูงแล้วไม่เวิร์ก" ทั้งที่จริง ๆ คุณแค่เผลอปิด soft loss ของตัวเองไปเฉย ๆ การคูณ T2T^2 ทำให้สเกลของ gradient แทบไม่ขึ้นกับ T — ค่า α\alpha ที่จูนไว้จึงมีความหมายเดิมทุก T

ของแถมจากบรรทัดที่ 2: ในลิมิตเดียวกัน soft loss ลดรูปเป็นการ match logits ที่ถูก center แบบ MSE — KD คือ "logit regression แบบนุ่ม" ที่ให้น้ำหนักส่วนหัวของ distribution มากกว่าส่วนหาง

3.3 SeqKD — baseline ราคาถูก (Kim & Rush, 2016)

LSeqKD=Ey^πT[tlogπS(y^tx,y^<t)]\mathcal{L}_{\text{SeqKD}} = -\,\mathbb{E}_{\hat y \sim \pi_T}\left[\sum_t \log \pi_S(\hat y_t \mid x, \hat y_{<t})\right]

อ่านแล้วคุ้นไหมครับ — นี่คือ 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 เป็น:

DJSD(β)(PQ)=βDKL(PM)+(1β)DKL(QM),M=βP+(1β)Q\mathbb{D}^{(\beta)}_{\text{JSD}}(P\,\|\,Q) = \beta\,\mathbb{D}_{\text{KL}}(P\,\|\,M) + (1-\beta)\,\mathbb{D}_{\text{KL}}(Q\,\|\,M), \qquad M = \beta P + (1-\beta) Q

โดย PP = ครู, QQ = นักเรียน ค่า β\beta กวาดจากขั้วหนึ่งไปอีกขั้วหนึ่ง:

  • β0\beta \to 0forward KL DKL(PQ)\mathbb{D}_{\text{KL}}(P\|Q) — mass-covering: นักเรียนต้องแผ่คลุมทุกโหมดของครู
  • β1\beta \to 1reverse KL DKL(QP)\mathbb{D}_{\text{KL}}(Q\|P) — mode-seeking: นักเรียนเลือกยึดบางโหมดที่ตัวเองไหว

พูดให้ชัดที่สุด: reverse KL ที่บทที่ 6 เลือกใช้ ไม่ใช่ของแปลกจากอีกโลก — มันคือจุด β=1\beta = 1 บนเส้นเดียวกันนี้ ส่วน KD คลาสสิกของ Hinton คือจุด β=0\beta = 0 สองบทนี้จึงเป็นสมาชิก ครอบครัวเดียวกันที่ต่างกันแค่ว่า "ใครต้องขยับเข้าหาใคร" — นักเรียนที่เล็กกว่าครูมากมัก ได้ประโยชน์จากฝั่ง mode-seeking เพราะความจุไม่พอจะคลุมทุกโหมดของครูอยู่แล้ว

4. เห็นภาพสมการ (Visualize)

Temperature เปิดเผย dark knowledge

แผนภูมิแท่งเปรียบเทียบ softmax ของ logit เดียวกันที่ temperature สี่ค่า พร้อมค่า entropy ต่อ T แสดงให้เห็นว่าคำตอบผิดที่สมเหตุสมผลโผล่ขึ้นมาเมื่อ T สูงขึ้น ขณะที่ token ไร้สาระยังอยู่ที่พื้นแผนภูมิแท่งเปรียบเทียบ softmax ของ logit เดียวกันที่ temperature สี่ค่า พร้อมค่า entropy ต่อ T แสดงให้เห็นว่าคำตอบผิดที่สมเหตุสมผลโผล่ขึ้นมาเมื่อ T สูงขึ้น ขณะที่ token ไร้สาระยังอยู่ที่พื้น

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" กับ "!" ยังนอนอยู่ที่พื้นตามที่ควรจะเป็น

ตัวคูณ T2T^2 ที่หายไป มองเห็นได้ในกราฟเดียว

กราฟลอการิทึมของขนาด gradient เทียบกับ temperature แสดงเส้นที่ไม่มีตัวคูณ T สองซึ่งลดลงตามหนึ่งส่วน T กำลังสอง และเส้นที่คูณ T กำลังสองแล้วซึ่งแบนราบ พร้อมเส้นอ้างอิงหนึ่งส่วน T กำลังสองกราฟลอการิทึมของขนาด gradient เทียบกับ temperature แสดงเส้นที่ไม่มีตัวคูณ T สองซึ่งลดลงตามหนึ่งส่วน T กำลังสอง และเส้นที่คูณ T กำลังสองแล้วซึ่งแบนราบ พร้อมเส้นอ้างอิงหนึ่งส่วน T กำลังสอง

Figure 7.2ขนาด gradient ของ soft loss ต่อ logit ของนักเรียน คำนวณตรง ๆ จากสูตร (q−p)/T บน logit vector คู่เดิม — ไม่มี T² gradient ไหลลงตาม 1/T² (เส้นแดง) ใส่ T² คืนแล้วสเกลนิ่งตลอดช่วง T (เส้นเขียว)

นี่คือสมการ 3.2 ในรูปที่ตาเห็น: เส้นแดงคือสิ่งที่เกิดขึ้นถ้าคุณลืม T2T^2 — พอกวาดหา T ที่ดีที่สุด คุณกำลังกวาด learning rate ของ soft loss ไปพร้อมกันโดยไม่ตั้งใจ ผลการทดลองทั้งตารางจะอ่านไม่ออก เพราะสองตัวแปรพันกันอยู่ เส้นเขียวคือเหตุผลเดียวที่เราจูน T ได้อย่างสะอาด

ครูกับนักเรียนมองตำแหน่งเดียวกัน ต่างกันแค่ไหน

ลองส่องของจริง: top-5 ของครูเทียบนักเรียนที่ตำแหน่ง token ภาษาไทยจากโน้ตบุ๊ก (มุมมอง "ก่อน" คือนักเรียน มุมมอง "หลัง" คือครู — ช่องว่างระหว่างสองมุมมองนี้คือสิ่งที่ logit KD พยายามปิด):

View

Promptทักทายเป็นภาษาไทย

สวัสดีครั ผมชื่อโมเดลภาษาไทย

Hover or focus a token to see its probability and the top-5 alternatives the model considered.
Mean logprob before-1.881perplexity 6.56
Mean logprob after-0.393perplexity 1.48
Tokens improved9 / 9
Rendered clusters9from 9 tokens

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() โกหกคุณบน T4

torch รุ่นใหม่ตอบ 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 เป็นไปได้")
ทำไม tokenizer ไม่ตรงกัน = logit KD เป็นไปไม่ได้ (ไม่ใช่แค่ "ยาก")

KL ในสมการ 3.1 เทียบ distribution สองก้อนมิติต่อมิติ: มิติที่ ii ของครูต้องหมายถึง token เดียวกับมิติที่ ii ของนักเรียน ถ้า vocab ไม่ตรง คุณกำลังเทียบความน่าจะเป็นของ "กรุงเทพ" กับความน่าจะเป็นของ token อื่นที่บังเอิญได้เลขช่องเดียวกัน — ตัวเลขจะไหลออกมาสวยงามและไร้ความหมายสิ้นเชิง

ซ้ำร้ายกว่านั้น ประโยคเดียวกันจะถูกหั่นเป็น token คนละชุด ตำแหน่งที่ tt ของครูกับนักเรียน จึงชี้ไปคนละจุดของข้อความ เทียบกันไม่ได้ตั้งแต่แกนเวลา

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 ไม่ได้จริง ๆ ลองคิดเลขดู:

แผนภูมิแท่งสเกลลอการิทึมเปรียบเทียบขนาด tensor ของ logits ครูแบบเต็ม vocabulary 622 เมกะไบต์ กับแบบ top-64 ขนาด 0.79 เมกะไบต์แผนภูมิแท่งสเกลลอการิทึมเปรียบเทียบขนาด tensor ของ logits ครูแบบเต็ม vocabulary 622 เมกะไบต์ กับแบบ top-64 ขนาด 0.79 เมกะไบต์

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 บนดิสก์

top-64 ตัดข้อมูลทิ้งไปเท่าไหร่

ที่ 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:

  1. TH-INSTR จากชุดวัด KobEval-TH — คะแนนการทำตามคำสั่งภาษาไทย ก่อน/หลัง พร้อม Wilson 95% CI วัดทั้งครู นักเรียนดิบ และนักเรียนทั้งสามแถว บนข้อสอบชุดเดียวกัน
  2. Gap closed — ตัวเลขเดียวที่สรุปทั้งบท:
gap closed=studentafterstudentbeforeteacherstudentbefore\text{gap closed} = \frac{\text{student}_{\text{after}} - \text{student}_{\text{before}}}{\text{teacher} - \text{student}_{\text{before}}}

ทำไมไม่รายงานคะแนนดิบ — เพราะ "คะแนนขึ้น 4 จุด" ไม่มีความหมายถ้าไม่รู้ว่าช่องว่างทั้งหมด มีให้ปิดกี่จุด metric นี้ตอบตรงคำถามจริงของเรา: ช่องว่างระหว่างนักเรียนกับครู ปิดไปแล้วกี่เปอร์เซ็นต์ 0% คือไม่ขยับ 100% คือไล่ทันครูพอดี 3. tok/sec ของนักเรียน ก่อนและหลังกลั่น — ควรเท่าเดิมเป๊ะ เพราะสถาปัตยกรรมกับจำนวน พารามิเตอร์ไม่เปลี่ยนแม้แต่ตัวเดียว และนี่แหละคือประเด็นของทั้งบท: คุณภาพขยับเข้าหาครู แต่ latency ไม่ขยับไปไหนเลย — ถ้าอยากได้ประโยคเดียวไว้เล่าให้ทีมฟัง ประโยคนั้นคือ "ได้คุณภาพ (บางส่วนของ) ครู ที่ราคาของนักเรียน" 4. TH-SAFE spot check — ครูส่งทุกอย่างผ่าน distribution รวมทั้งอคติและนิสัยเสียของมันด้วย เราจึงวัดนักเรียนหลังกลั่นบนชุดคำถามปลอดภัยภาษาไทย แล้วรายงานตามจริงว่ารับอะไรติดมาบ้าง (รายละเอียดในกล่องข้อจำกัดท้ายบท)

ตัวเลขจริงในตารางถัดไปปล่อยเป็น ? ไว้ — มันต้องมาจากการรันโน้ตบุ๊กของคุณเอง ไม่ใช่จากบทความ

Prompt
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆ

base

Thai 18%41 tokens
The sky appears blue because of Rayleigh scattering. ท้องฟ้า is blue เพราะ light scatter ครับ. Shorter wavelengths scatter more than longer ones.

sft

Thai 99%78 tokens
ท้องฟ้าเป็นสีฟ้าเพราะแสงอาทิตย์กระทบกับโมเลกุลของอากาศแล้วเกิดการกระเจิงแบบเรย์ลี ซึ่งแสงสีน้ำเงินที่มีความยาวคลื่นสั้นกว่าจะกระเจิงได้มากกว่าแสงสีแดง เราจึงมองเห็นท้องฟ้าเป็นสีฟ้าครับ

Showing the built-in sample.

9. เปรียบเทียบ (Comparison)

โมเดลTH-INSTR (95% CI)Gap closedtok/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 แม้แต่ครั้งเดียว — และคุณจะรู้ได้จากตารางนี้เท่านั้น

แผนภูมิแท่งแนวนอนแสดงตัวอย่างการอ่านค่า gap closed ของสามวิธี โดยมีเส้นศูนย์เปอร์เซ็นต์คือนักเรียนก่อนเทรน และเส้นประเขียวที่หนึ่งร้อยเปอร์เซ็นต์คือครูแผนภูมิแท่งแนวนอนแสดงตัวอย่างการอ่านค่า gap closed ของสามวิธี โดยมีเส้นศูนย์เปอร์เซ็นต์คือนักเรียนก่อนเทรน และเส้นประเขียวที่หนึ่งร้อยเปอร์เซ็นต์คือครู

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. ลืมตัวคูณ T2T^2 ไม่มี 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_sizemax_length → เลิกให้ครูอยู่บนการ์ด (precompute แบบ offline ให้จบก่อน แล้วปล่อย del teacher; torch.cuda.empty_cache()) — ลำดับสุดท้ายนี้คือ โครงสร้างของโน้ตบุ๊กเราอยู่แล้ว

10. สรุป (Summary)

  • Dark knowledge อยู่ในคำตอบที่ผิดของครู — การจัดอันดับเหนือทางเลือกผิด ๆ เข้ารหัส โครงสร้างความคล้ายที่ hard label ไม่มีวันบอก และ temperature คือปุ่มที่เปิดเผยมัน
  • ตัวคูณ T2T^2 ไม่ใช่เครื่องราง — gradient ของ soft loss สเกลตาม 1/T21/T^2 คูณคืนเพื่อให้จูน 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)

  1. Hinton et al. (2015). Distilling the Knowledge in a Neural Network — KD ต้นฉบับ: temperature และตัวประกอบ T²
  2. Kim et al. (2016). Sequence-Level Knowledge Distillation — sequence-level KD -- baseline ที่ถูกที่สุดในหัวข้อ 9
  3. Agarwal et al. (2023). On-Policy Distillation of Language Models: Learning from Self-Generated Mistakes — GKD: กรอบ JSD ที่รวม forward/reverse KL เข้าด้วยกัน
  4. Gu et al. (2023). MiniLLM: On-Policy Distillation of Large Language Models — MiniLLM: เหตุผลว่าทำไมต้องใช้ reverse KL
  5. Sanh et al. (2019). DistilBERT, a distilled version of BERT: smaller, faster, cheaper and lighter — ตัวอย่างการกลั่นที่ใช้งานจริงในวงกว้าง
  6. Chay-intr et al. (2025). LLaVAC: Fine-tuning LLaVA as a Multimodal Sentiment Classifier — LLaVAC: ตัวอย่าง fine-tune โมเดล multimodal สำหรับงานไทย

บทความ โค้ด และโน้ตบุ๊กในซีรีส์นี้เผยแพร่ภายใต้สัญญาอนุญาต CC BY-NC-SA 4.0 — นำไปใช้และดัดแปลงต่อได้ โดยอ้างอิงที่มา ไม่ใช้เพื่อการค้า และเผยแพร่ต่อด้วยสัญญาเดียวกัน (โมเดลและชุดข้อมูลของบุคคลที่สามที่อ้างถึง ยังคงใช้สัญญาของเจ้าของเดิม)