[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 ไม่ใช่บล็อก (รวมถึงบล็อกนี้)
บทความนี้คือประมาณ 30% แรกของบทเรียน — ส่วนที่เหลือ (การเตรียมสภาพแวดล้อม, การเตรียมข้อมูล, โค้ดหลัก, ผลลัพธ์จริง และบทสรุป) อยู่ในคอร์ส LLM Finetuning ซึ่งเรียนฟรี เพียงเข้าสู่ระบบด้วย Google
อ่านเนื้อหาเต็มในคอร์ส →