[LLM 2/10] SFT + LoRA: สอนโมเดลให้เป็นผู้ช่วย ด้วยการเทรน 1.69% ของพารามิเตอร์
บทที่แล้วเราใช้ Continue Pretraining ใส่ความรู้เข้าไปในน้ำหนักโมเดล แต่โมเดลที่ "รู้" ไม่ได้แปลว่าโมเดลที่ "ตอบ" — โมเดล base มีอาชีพเดียวคือเขียนข้อความต่อ บทนี้จะสอน SFT (Supervised Fine-Tuning) ด้วย LoRA: เทคนิคที่เทรนแค่ราว 1.7% ของพารามิเตอร์ แต่เปลี่ยนพฤติกรรมของโมเดลได้ทั้งตัว — จบใน ~15 นาทีบน Colab ฟรี และสิ่งที่ได้กลับมาคือไฟล์ adapter ขนาดราว 40 MB ที่จะกลายเป็นกระดูกสันหลังของบทที่เหลือทั้งซีรีส์
Open in Colab02_sft_lora.ipynb
1. ปัญหา (Problem statement)
เอาโมเดล base แท้ ๆ อย่าง Qwen3-0.6B-Base จากบทที่แล้วมาพิมพ์ใส่ว่า "ช่วยแนะนำอาหารไทยให้หน่อยครับ" สิ่งที่ได้กลับมามักไม่ใช่คำตอบ แต่เป็นการเขียนต่อ — มันอาจแต่งคำถามเพิ่มอีกสามข้อ เขียนต่อเป็นบทความท่องเที่ยว หรือเปลี่ยนไปเป็นภาษาอังกฤษกลางทาง เพราะสิ่งเดียวที่มันเคยถูกเทรนคือ "ข้อความแบบนี้บนอินเทอร์เน็ต มักตามด้วยอะไร"
ความสามารถในการตอบ — รับคำสั่ง ตอบตรงประเด็น แล้วหยุด — ไม่ได้มากับ pretraining มันมาจาก SFT: การเทรนต่อด้วยคู่ (คำสั่ง, คำตอบที่ดี) หลายพันถึงหลายล้านคู่ โมเดล instruct ทุกตัวที่คุณเคยใช้ ล้วนผ่านขั้นนี้มาแล้วทั้งนั้น
แต่พอจะลงมือทำเอง จะเจอปัญหาซ้อนอยู่สองชั้น:
ชั้นแรก — ต้นทุนของ full fine-tuning ถ้าเทรนทุกพารามิเตอร์ คุณจะได้โมเดลใหม่ทั้งก้อน (~1.2 GB ต่อหนึ่งงานสำหรับโมเดล 0.6B) องค์กรที่มีสิบงาน — สรุปเอกสาร, ร่างจดหมาย, ตอบลูกค้า, จัดหมวดเรื่องร้องเรียน — ต้องเก็บสิบสำเนา และการขยับน้ำหนักทุกตัวด้วย learning rate สูง ๆ คือสูตรลบความรู้ที่เพิ่งใส่ไปในบทที่ 1 (จำกล่อง learning rate ได้ไหมครับ)
ชั้นที่สอง — ภาษาไทย แม้แต่ตัวที่ผ่านการ post-train มาแล้วอย่าง Qwen3-0.6B
ก็ยังมีอาการที่เราเห็นซ้ำ ๆ ทั้งซีรีส์: ถามเป็นไทย แล้วคำตอบไหลไปเป็นภาษาอังกฤษกลางประโยค
(นี่คือที่มาของ metric th_ratio ประจำซีรีส์) เพราะข้อมูล SFT ที่มันเคยเห็นเป็นภาษาอังกฤษเป็นหลัก
บทนี้แก้ทั้งสองชั้นพร้อมกัน: SFT ด้วยข้อมูลคำสั่งภาษาไทย และทำผ่าน LoRA แทน full fine-tuning
2. เราจะทำอะไร (Solution)
เราจะเอา Qwen3-0.6B มาเทรนด้วยคู่คำสั่ง-คำตอบภาษาไทย 4,000 ตัวอย่าง โดยใช้ loss ตัวเดียวกับบทที่ 1 เป๊ะ ๆ บวกของใหม่สองชิ้น:
- Completion mask — คิด loss เฉพาะ token ฝั่งคำตอบ ไม่คิดฝั่งคำถาม (หัวข้อ 3.1 จะอธิบายว่าทำไมข้ามข้อนี้แล้วพังแบบตลก ๆ)
- LoRA (Low-Rank Adaptation) — แช่แข็งน้ำหนักเดิมทั้งหมด แล้วเทรนเมทริกซ์เล็ก ๆ สองตัวที่วางทับแต่ละเลเยอร์แทน
คุณไม่ได้กำลังเทรนน้ำหนักโมเดล — คุณกำลังเทรน "ส่วนแก้" (correction) อันดับต่ำที่วางทับน้ำหนักเดิม
นี่คือเหตุผลที่ adapter มีขนาดแค่ ~40 MB ไม่ใช่ 1.2 GB, เหตุผลที่คุณเก็บ adapter ยี่สิบตัวไว้สลับใช้บน base ตัวเดียวได้ (ยี่สิบงาน = 0.8 GB ไม่ใช่ 24 GB), และเหตุผลที่ reference model ในบทที่ 4 (DPO) มีต้นทุน VRAM เพิ่มศูนย์ไบต์ — แค่ปิด adapter ก็ได้โมเดลตั้งต้นกลับคืนมาเป๊ะ ๆ
3. สมการ (Equation)
3.1 SFT loss กับ completion mask
- = ตัวอย่างหนึ่งคู่ — คือส่วนคำสั่ง (รวม chat template) และ คือลำดับ token ของตัวอย่างที่โมเดลเห็นจริงตอนเทรน
- = token ตำแหน่งที่ และ = token ทั้งหมดก่อนหน้า
- = ความน่าจะเป็นที่โมเดลพารามิเตอร์ ทำนาย
- = completion mask — เป็น 1 เฉพาะ token ฝั่งคำตอบ และเป็น 0 บน token ของ prompt
ลองถอด ออก (คือตั้ง ทุกตำแหน่ง) สมการนี้จะกลายเป็น objective ของ CPT ในบทที่ 1 ทันที SFT คือ CPT บนข้อความที่ถูกจัดฉากเป็นบทสนทนา บวกกับ mask หนึ่งตัว — ไม่มีอะไรมากกว่านั้น
แต่ mask หนึ่งตัวนี้คือครึ่งหนึ่งของความสำเร็จ เพราะ คือ default ที่พลาดกันง่ายที่สุด
(หลาย pipeline รวมถึง SFTTrainer ถ้าไม่ใส่ collator ให้ถูก จะเทรนแบบนี้เงียบ ๆ)
และในข้อมูลคำสั่งภาษาไทยทั่วไป token ฝั่ง prompt กินราว 60% ของตัวอย่าง
แปลว่า gradient ส่วนใหญ่ของคุณกำลังสอนโมเดลให้หัดเขียนคำถามของผู้ใช้ ไม่ใช่หัดตอบ
ผลข้างเคียงที่ตามมาจะได้เห็นในหัวข้อ 9
3.2 LoRA: เทรนส่วนแก้ ไม่ใช่น้ำหนัก
แทนที่จะอัปเดตเมทริกซ์น้ำหนัก ตรง ๆ LoRA ตรึง ไว้ แล้วเรียนรู้ส่วนต่างที่เป็นผลคูณของเมทริกซ์เล็กสองตัว:
- = น้ำหนักเดิมของเลเยอร์ ถูกแช่แข็ง ไม่รับ gradient เลย
- และ = เมทริกซ์ adapter สองตัวที่เราเทรนจริง
- = rank ของส่วนแก้ — ปุ่มหลักของ LoRA (บทนี้ใช้ )
- = ตัวคูณ scale — ผลคูณ ถูกคูณด้วย เสมอ (บทนี้ ดังนั้น )
รายละเอียดสองข้อในนิยามนี้สำคัญกว่าที่หน้าตามันบอก:
ถูก initialize เป็นศูนย์ทั้งเมทริกซ์ ดังนั้น ณ step แรก — การเทรนเริ่มจากโมเดล base เป๊ะ ๆ ไม่มีช่วงที่โมเดลถูกรบกวนด้วยน้ำหนักสุ่มเลย (ส่วน เป็น Gaussian สุ่ม — ถ้าตั้งศูนย์ทั้งคู่ gradient ของทั้งคู่จะเป็นศูนย์ตลอดกาล เพราะต่างฝ่ายต่างคูณกับศูนย์)
ตัวหาร ใน ทำให้ขนาดของ update ไม่ขึ้นกับ rank — เพิ่ม เป็นสองเท่า ผลรวม มีพจน์มากขึ้นสองเท่า แต่ถูกหารกลับพอดี คุณจึงกวาดหา ได้โดยไม่ต้องจูน learning rate ใหม่ทุกครั้ง
และเมื่อเทรนเสร็จ คุณเลือกได้สองทาง: merge ( แล้วได้โมเดลเดียวที่ไม่มี latency เพิ่ม) หรือเก็บแยก — ทางที่สองคือทางที่ซีรีส์นี้เลือก เพราะ adapter ที่ถอด-ใส่ได้คือสิ่งที่บทที่ 4 ใช้สร้าง reference model ฟรี ๆ
3.3 สัดส่วนพารามิเตอร์ที่เทรน — ตัวเลขที่ต้องเช็ค ไม่ใช่ท่อง
ต่อเมทริกซ์ขนาด หนึ่งตัว adapter มีพารามิเตอร์ ตัว คิดเป็นสัดส่วน
- = มิติของเมทริกซ์น้ำหนักเดิม
- = rank ของ adapter
ลองแทนค่าจริงของ Qwen3-0.6B (hidden 1024, intermediate 3072, 28 เลเยอร์ ติด adapter ทั้ง 7 เมทริกซ์: q, k, v, o, gate, up, down) จะได้พารามิเตอร์ที่เทรนได้ 10,092,544 ตัว จากฐาน 596,049,920 ตัว = 1.69%
บทความ LoRA แทบทุกชิ้นพูดว่า "เทรนต่ำกว่า 1% ของพารามิเตอร์" ตัวเลขนั้นจริงที่สเกล 7B ขึ้นไป แต่ไม่จริงกับโมเดลเล็ก เพราะพารามิเตอร์ adapter โตแบบ — เชิงเส้นกับ hidden size ขณะที่พารามิเตอร์ฐานโตแบบ — กำลังสอง ยิ่งโมเดลเล็ก adapter จึงยิ่งเป็นสัดส่วนที่ใหญ่
สังเกตด้วยว่า 1.69% ต่ำกว่าสัดส่วนต่อเมทริกซ์ (~2.1–2.3%) — เพราะตัวหารรวม embedding
ราว 156 ล้านพารามิเตอร์ที่เราไม่ได้ติด adapter เข้าไปด้วย เลขพวกนี้เช็คได้ด้วยเลขคณิตล้วน ๆ
และโน้ตบุ๊กจะให้ peft พิมพ์ค่าจริงให้ดูกับตาในหัวข้อ 7 — เชื่อ print ไม่ใช่บล็อก (รวมถึงบล็อกนี้)
4. เห็นภาพสมการ (Visualize)
mask หนึ่งตัว เปลี่ยนทิศของ gradient ทั้งก้อน
Figure 2.1การบัญชี token ล้วน ๆ ของคู่ถาม-ตอบไทยทั่วไป (prompt 180 + คำตอบ 120 token): ถ้าไม่ mask, 60% ของพจน์ใน loss คือการหัดเขียนคำถามของผู้ใช้
ภาพนี้ไม่มีอะไรลึกไปกว่าการนับ token แต่การนับนี่แหละที่คนข้าม: prompt ภาษาไทย (รวม system message กับ chat template) มักยาวกว่าคำตอบ พอเทรนโดยไม่ mask คุณจึงจ่าย compute ส่วนใหญ่ไปกับการสอนสิ่งที่ไม่ได้อยากได้ โน้ตบุ๊กพิมพ์สัดส่วนจริงของชุดข้อมูลที่สุ่มมาให้ดูด้วย — ตัวเลข 60/40 ในภาพคือค่าแทนตัวอย่างทั่วไป ไม่ใช่ค่าคงที่ศักดิ์สิทธิ์
ทำไม rank 16 ถึง "พอ" — สมมติฐาน low-rank
Figure 2.2สเปกตรัม singular value ของ ΔW สังเคราะห์ (1024×1024) ที่ฝังสัญญาณ rank 16 ไว้ใต้ noise เต็ม rank — ภาพประกอบของสมมติฐาน low-rank ไม่ใช่การวัดจากการ fine-tune จริง
สมมติฐานของ LoRA (Hu และคณะ, 2021) คือ: การ fine-tune ขยับน้ำหนักในทิศทางสำคัญเพียงไม่กี่ทิศ เมื่อเทียบกับมิติเต็มของเมทริกซ์ ภาพนี้สร้าง ปลอมที่มีโครงสร้างแบบนั้นเป๊ะ ๆ (สัญญาณ rank 16 + noise) แล้วให้ SVD ค้นมันกลับขึ้นมา — ถ้า update จริงหน้าตาแบบนี้ adapter rank 16 ก็เก็บเนื้อหาไว้ได้เกือบหมด สิ่งที่ภาพนี้ไม่ได้พิสูจน์คือ update จริงหน้าตาแบบนี้เสมอ นั่นเป็นข้อค้นพบเชิงประจักษ์ของงานวิจัย และเป็นเหตุผลที่ LoRA "มักจะ" เกือบเท่า full fine-tuning ไม่ใช่ "เสมอ"
เช็คตัวเลข ไม่ใช่ท่องตัวเลข
Figure 2.3สัดส่วนพารามิเตอร์ที่เทรนได้ต่อ rank คำนวณเป๊ะ ๆ จากมิติจริงของ Qwen3-0.6B — จุด r=16 อยู่ที่ 1.69% เหนือเส้น 'ต่ำกว่า 1%' ที่มักถูกอ้าง
เส้นตรงบนแกน log-log ยืนยันสิ่งที่สมการบอก: สัดส่วนโตเชิงเส้นกับ เป๊ะ ๆ และบนโมเดลตัวนี้ "ต่ำกว่า 1%" จะเป็นจริงก็ต่อเมื่อ เท่านั้น ซึ่งไม่ใช่ค่าที่ใครใช้กันจริงกับงานภาษา — จุดที่เราใช้ () คือ 1.69% หรือราว 40 MB เมื่อเก็บเป็น fp32
5. เตรียมสภาพแวดล้อม (Environment)
เปิด Colab เลือก Runtime → Change runtime type → T4 GPU (แผนฟรีพอ)
Colab T4 คือสถาปัตยกรรม Turing (SM 7.5) ซึ่ง ไม่รองรับ bfloat16 และ ไม่รองรับ FlashAttention-2
แต่ config.json ของ Qwen3-0.6B ระบุ torch_dtype: bfloat16 เอาไว้
ดังนั้น torch_dtype="auto" คือกับดัก โค้ดจะพังหรือช้าผิดปกติโดยไม่บอกสาเหตุ
torch_dtype=torch.float16 # ไม่ใช่ bfloat16
attn_implementation="sdpa" # ไม่ใช่ flash_attention_2
fp16=True # ใน SFTConfig (ไม่ใช่ 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 จริง ๆ
บทที่ 1 เราต้อง model.float() ทั้งโมเดลก่อนเทรน เพราะเทรนทุกพารามิเตอร์
บทนี้ base ถูกแช่แข็ง — ไม่มี gradient — จึงอยู่เป็น fp16 ได้สบาย ๆ และประหยัด VRAM ไปครึ่งหนึ่ง
สิ่งที่ต้องเป็น fp32 คือเฉพาะพารามิเตอร์ที่เทรนได้ นั่นคือ adapter ราว 10 ล้านตัว:
for p in model.parameters():
if p.requires_grad:
p.data = p.data.float() # cast เฉพาะ adapter — ไม่ใช่ทั้งโมเดล
ถ้าลืม คุณจะเจอ ValueError: Attempting to unscale FP16 gradients.
ตัวเดียวกับบทที่ 1 เป๊ะ แต่คราวนี้ค่าแก้ถูกกว่ากันมาก: cast 10 ล้านพารามิเตอร์ ไม่ใช่ 596 ล้าน
งบ VRAM ที่เปลี่ยนโฉมไปเลยเมื่อใช้ LoRA
จำได้ไหมว่าบทที่ 1 optimizer state ของ Adam กินที่มากกว่าตัวโมเดล (ราว 4.4 GB สำหรับ กับ )
พอเทรนแค่ adapter, Adam ก็เก็บ state แค่ 10.1 ล้านพารามิเตอร์ — ประมาณ 0.08 GB
จนไม่ต้องพึ่ง adamw_bnb_8bit อีกต่อไป งบก้อนใหญ่ที่เหลือคือน้ำหนัก base (fp16, ~1.2 GB) กับ activations
ลองสลับระหว่าง full fine-tuning กับ LoRA rank ต่าง ๆ ในเครื่องคิดเลขดูว่างบเปลี่ยนตรงไหน:
- Weights1.13 GiB
- Gradients19.25 MiB
- Optimizer state115.50 MiB
- Activations170.00 MiB
- KV cache—
It fits.This run needs 1.43 GiB and leaves 14.57 GiB of headroom on a free Colab T4.
6. เตรียมข้อมูล (Data)
เราใช้ airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k —
ชุดคำสั่ง-คำตอบภาษาไทยแบบ synthetic 120,000 คู่ จากทีม WangchanX (สุ่มมาใช้ 4,000)
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(4000))
def to_text(ex):
messages = [
{"role": "user", "content": ex["instruction"]}, # เช็คชื่อคอลัมน์จาก dataset card เสมอ
{"role": "assistant", "content": ex["output"]},
]
return {"text": tok.apply_chat_template(messages, tokenize=False,
enable_thinking=False)}
train_ds = ds.map(to_text, remove_columns=ds.column_names)
สองบรรทัดที่ควรอ่านช้า ๆ:
apply_chat_templateประกอบข้อความเป็นรูปแบบที่ Qwen3 ถูกเทรนมา (<|im_start|>user…<|im_end|>…) เราเรียกมันครั้งเดียว ที่นี่ที่เดียว — เหตุผลอยู่ในกับดักข้อ 2 ของหัวข้อ 9enable_thinking=Falseปิดโหมด thinking ของ Qwen3 เพื่อให้ตัวอย่างในบทนี้เรียบง่ายและ mask ตรงไปตรงมา
แล้วทำพิธีที่ควรเป็นนิสัย: decode ตัวอย่างแรกออกมาดูด้วยตาเสมอ
print(train_ds[0]["text"][:400])
# ต้องเห็น <|im_start|>user ... <|im_end|> ... <|im_start|>assistant ... อย่างละครั้งเดียวต่อ turn
โน้ตบุ๊กยังพิมพ์สถิติความยาว token ของชุดที่สุ่มมา: median ฝั่ง prompt เทียบฝั่งคำตอบ และสัดส่วนตัวอย่างที่ยาวเกิน 768 token — ตัวเลขตัวหลังนี้จะกลับมาทวงบุญคุณในกับดักข้อ 3
7. โค้ดหลัก (Main code)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig, DataCollatorForCompletionOnlyLM
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B", # ตัว post-trained — บทนี้เราแก้พฤติกรรม ไม่ได้สอนความรู้
torch_dtype=torch.float16, # T4 ไม่มี bf16
attn_implementation="sdpa", # T4 ไม่มี FlashAttention-2
).cuda()
lora = LoraConfig(
r=16,
lora_alpha=32, # α/r = 2 — ดูสมการ 3.2
lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora)
model.print_trainable_parameters() # เชื่อบรรทัดนี้ ไม่ใช่ตัวเลขในบล็อกไหนทั้งนั้น
for p in model.parameters(): # กล่อง fp16 ในหัวข้อ 5
if p.requires_grad:
p.data = p.data.float()
peft คิดเปอร์เซ็นต์โดยหารด้วยจำนวนพารามิเตอร์รวม adapter แล้ว ส่วน 1.69% ของเราหารด้วยพารามิเตอร์ฐานล้วน ๆ
นิยามต่างกันนิดเดียว ตัวเลขจึงต่างกันนิดเดียว — และนี่แหละคือประเด็นของหัวข้อ 3.3:
ตัวเลขแบบนี้ต้องรู้ที่มาของเศษกับส่วน ไม่ใช่จำตัวเลขลอย ๆ ไปอ้างต่อ
ต่อไปคือชิ้นที่แปลงสมการ 3.1 เป็นโค้ด — collator ที่ทำหน้าที่เป็น :
collator = DataCollatorForCompletionOnlyLM(
response_template="<|im_start|>assistant\n", # ทุกอย่างก่อนหน้านี้ = prompt
tokenizer=tok,
)
collator ตัวนี้ตั้ง label ของ token ทุกตัวก่อน <|im_start|>assistant เป็น -100
ซึ่งเป็นค่าที่ loss function ของ PyTorch ข้ามให้ — ค่าเดียวกับที่บทที่ 4 ใช้ mask ฝั่ง prompt
ในฟังก์ชัน seq_logp นั่นแหละครับ mask ตัวเดียวกัน โผล่มาคนละบท
cfg = SFTConfig(
output_dir="sft-out",
dataset_text_field="text",
max_seq_length=768, # โน้ตบุ๊กพิมพ์จำนวนตัวอย่างที่โดนตัด — กับดักข้อ 3
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # effective batch = 16
num_train_epochs=1,
learning_rate=2e-4, # LoRA รับไหว — full FT ที่ค่านี้คือหายนะ (บทที่ 1)
lr_scheduler_type="cosine",
warmup_ratio=0.03,
packing=False, # completion mask ใช้กับ packing ตรง ๆ ไม่ได้
fp16=True, # T4: ไม่ใช่ bf16
logging_steps=10,
)
trainer = SFTTrainer(
model=model,
args=cfg,
train_dataset=train_ds,
data_collator=collator,
processing_class=tok,
)
trainer.train() # ~15 นาทีบน T4 (วัดจริง 14.8 นาที)
learning rate ตัวเดียวกันนี้ ถ้าใช้เทรนทุกพารามิเตอร์ จะลบความสามารถของโมเดลภายในไม่กี่ร้อย step แต่กับ LoRA มันปลอดภัย เพราะ (1) น้ำหนักเดิม 596 ล้านตัวถูกแช่แข็ง — ความรู้ใน base ไม่มีทางถูกเขียนทับ (2) เริ่มจากศูนย์ — โมเดล ณ step แรกคือ base เป๊ะ ๆ แล้วค่อย ๆ เดินออกจากมัน สิ่งที่แย่ที่สุดที่ LoRA ทำได้คือ adapter ที่แย่ ซึ่งถอดทิ้งได้ทุกเมื่อ
เทรนเสร็จ เซฟเฉพาะส่วนแก้:
model.save_pretrained("qwen3-0.6b-th-sft-lora") # ~40 MB — ไม่ใช่ 1.2 GB
adapter ตัวนี้แหละที่บทที่ 4 โหลดในชื่อ kobkrit/qwen3-0.6b-th-sft-lora
เพื่อเป็นทั้ง policy ตั้งต้นและ (เมื่อปิด adapter) reference model — ของขวัญข้ามบทที่ราคา 40 MB
8. ผลลัพธ์ (Results)
โน้ตบุ๊กวัด 3 อย่างก่อนและหลังเทรน แล้วเขียนลง results.json:
- TH-INSTR — สัดส่วนคำตอบที่ผ่านเกณฑ์ rubric การทำตามคำสั่ง (ตอบตรงคำถาม จบด้วยตัวเอง อยู่ในบทบาท) จากชุดวัด KobEval-TH พร้อม Wilson 95% CI
th_ratio— สัดส่วนอักขระไทยในคำตอบ metric ประจำซีรีส์ Qwen3-0.6B ตั้งต้นขึ้นชื่อเรื่องไหลเป็นอังกฤษบน prompt ไทย การ SFT ด้วยข้อมูลไทยล้วน 4,000 ตัวอย่าง ควรขยับตัวเลขนี้ให้เห็นชัด — และถ้าไม่ขยับ นั่นคือสัญญาณให้ไปไล่ดู mask กับ template ก่อนสิ่งอื่น- สัดส่วนคำตอบที่จบด้วย eos ภายในงบ token — ตัวจับอาการ "ไม่ยอมหยุด" จากกับดักข้อ 1 และ 3
ชุดวัดหลักร้อยข้อให้ CI กว้างราว ±10 จุด ตัวเลขที่ไม่มี CI จึงยังไม่ใช่ผลการทดลอง
ตารางในหัวข้อ 9 ใส่ ? ไว้ตรงที่ต้องรอตัวเลขจริงจากโน้ตบุ๊ก — เราไม่เดาผลล่วงหน้าในบล็อก
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
9. เปรียบเทียบ (Comparison)
โน้ตบุ๊กเทรน 3 แบบบนข้อมูลชุดเดียวกัน แล้ววัดเทียบกับโมเดลตั้งต้น:
| โมเดล | TH-INSTR (95% CI) | th_ratio | PPL | พารามิเตอร์ที่เทรน | เวลาเทรน |
|---|---|---|---|---|---|
| Qwen3-0.6B (ตั้งต้น) | 73.3% (55.6–85.8) | 0.93 | 21.3 | — | — |
| LoRA r=16 + completion mask | 83.3% (66.4–92.7) | 0.97 | 17.7 | 10.1M (1.69%) | 13.2 นาที |
results.json ที่โน้ตบุ๊กเขียนเอง
SFT ขยับดีขึ้นทุกมิติ: TH-INSTR +10 จุด (73.3% → 83.3%), th_ratio เพิ่ม
(ตอบภาษาไทยมากขึ้น เลิกหลุดไปอังกฤษ) และ perplexity ลดจาก 21.3 เหลือ 17.7 —
โดยเทรนแค่ 1.69% ของพารามิเตอร์ และได้ adapter ~40 MB
73.3% กับ 83.3% ดูต่างชัด แต่ Wilson CI คือ 55.6–85.8 กับ 66.4–92.7 ซึ่งยังซ้อนกัน
ที่ n=30 ข้อ ผลนี้ยังไม่ผ่านนัยสำคัญทางสถิติ หลักฐานที่หนักแน่นกว่าคือ th_ratio
กับ PPL ที่วัดจาก token หลายพันตัว ถ้าอยากให้ TH-INSTR สรุปได้ ต้องเพิ่มข้อเป็นหลักร้อย (บทที่ 9)
หมายเหตุเรื่อง full fine-tuning และ no-mask: โน้ตบุ๊กเทรน LoRA แบบไม่ mask ไว้ให้ดู พฤติกรรมที่โมเดลตอบเสร็จแล้วแต่งคำถามผู้ใช้ต่อเอง (ดูตัวอย่างใน §8) แต่ไม่ได้วัด metric เต็มรูปแบบ เพราะการฝึก full fine-tuning 100% ของพารามิเตอร์ให้ครบเพื่อเทียบ ใช้เวลาเกินงบ ของ Colab ฟรี — เราจึงรายงานเฉพาะตัวเลขที่วัดจริง ไม่เดาส่วนที่ไม่ได้รัน
10. สรุป (Summary)
- SFT คือ CPT บนบทสนทนา + completion mask — loss ตัวเดิมจากบทที่ 1 สิ่งที่เปลี่ยนคือข้อมูลถูกจัดฉาก และ mask เลือกว่า token ไหนนับ
- mask คือครึ่งหนึ่งของความสำเร็จ — ทำให้ 60% ของ gradient ไปหัดเขียนคำถาม และได้โมเดลที่ตอบเสร็จแล้วแต่งคำถามต่อเอง
- LoRA เทรน "ส่วนแก้" ไม่ใช่น้ำหนัก: โดย เริ่มจากศูนย์ การเทรนจึงออกตัวจาก base เป๊ะ ๆ และ ทำให้เปลี่ยน rank ได้โดยไม่จูน lr ใหม่
- 1.69% ไม่ใช่ "ต่ำกว่า 1%" — adapter โตเชิงเส้นกับ hidden size แต่ base โตกำลังสอง โมเดลเล็ก adapter จึงเป็นสัดส่วนใหญ่กว่า เช็คจาก
print_trainable_parameters()เสมอ - adapter ~40 MB ต่องาน: base ตัวเดียว + adapter หลายตัว และมันคือเหตุผลที่ reference model ของบทที่ 4 ได้มาฟรี
- lr 2e-4 ปลอดภัยเพราะ base ถูกแช่แข็ง — ค่าเดียวกันนี้ทำลายโมเดลถ้าเทรนเต็มตัว
- pad ≠ eos, template ครั้งเดียว, อย่าปล่อยให้ตัดกลางคำตอบ — สามกับดักที่อาการโผล่ตอน inference แต่ต้นเหตุอยู่ในข้อมูล
SFT สอน "รูปแบบและสไตล์" ได้มากกว่า "ความรู้" มาก — 4,000 ตัวอย่างจะไม่เพิ่มข้อเท็จจริงใหม่เข้าไปในโมเดล ถ้าโมเดลไม่รู้เรื่องไหน หลัง SFT มันจะยังไม่รู้เหมือนเดิม แค่ตอบผิดด้วย format ที่สวยขึ้นและน้ำเสียงที่มั่นใจขึ้น ซึ่งอันตรายกว่าเดิม — การใส่ความรู้คืองานของบทที่ 1 (CPT) ไม่ใช่บทนี้
และเช่นเดียวกับทุกบท: 4,000 ตัวอย่างคือการสาธิตกลไก งาน SFT ระดับใช้จริงใช้ข้อมูลหลักหมื่นถึงหลักล้านคู่ ที่ผ่านการคัดคุณภาพหนักกว่านี้หลายชั้น สิ่งที่คุณได้จากบทนี้คือความเข้าใจว่าปุ่มไหนทำอะไรและพังแบบไหน ซึ่งโอนไปใช้กับสเกลจริงได้ แต่อย่าเอาผลนี้ไปอ้างว่าได้โมเดลไทยที่ดีกว่าเดิม
บทต่อไป: RLHF และ PPO — โมเดลตอบเป็นแล้ว แต่ "ตอบดี" เขียนเป็น loss function ไม่ได้ เราจะให้มนุษย์เปรียบเทียบคำตอบ เทรน reward model จากความชอบนั้น แล้วใช้ reinforcement learning ดันโมเดลเข้าหามัน — โดยมี adapter 40 MB จากบทนี้เป็นจุดตั้งต้น
อ้างอิง (References)
- Hu et al. (2021). LoRA: Low-Rank Adaptation of Large Language Models — สมการ W' = W₀ + (α/r)BA ในหัวข้อ 3 มาจากเปเปอร์นี้
- Aghajanyan et al. (2020). Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning — หลักฐานว่าการปรับโมเดลมี intrinsic dimension ต่ำ -- เหตุผลว่าทำไม LoRA ได้ผล
- Dettmers et al. (2023). QLoRA: Efficient Finetuning of Quantized LLMs — ต่อยอด LoRA ด้วย 4-bit ให้เทรนโมเดลใหญ่บน GPU ตัวเดียว
- Biderman et al. (2024). LoRA Learns Less and Forgets Less — LoRA แลกความจำได้น้อยลงกับการลืมน้อยลง -- อ่านคู่กับหัวข้อ 9
- Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT: ต้นแบบของ pipeline SFT -> RM -> PPO ทั้งหมด
- Wang et al. (2022). Self-Instruct: Aligning Language Models with Self-Generated Instructions — วิธีสร้างชุดข้อมูล instruction จากโมเดลเอง
- Zhou et al. (2023). LIMA: Less Is More for Alignment — ข้อมูลคุณภาพสูงไม่กี่พันตัวอย่างก็พอ -- เหตุผลที่เราใช้แค่ 4,000
บทความ โค้ด และโน้ตบุ๊กในซีรีส์นี้เผยแพร่ภายใต้สัญญาอนุญาต CC BY-NC-SA 4.0 — นำไปใช้และดัดแปลงต่อได้ โดยอ้างอิงที่มา ไม่ใช้เพื่อการค้า และเผยแพร่ต่อด้วยสัญญาเดียวกัน (โมเดลและชุดข้อมูลของบุคคลที่สามที่อ้างถึง ยังคงใช้สัญญาของเจ้าของเดิม)
