[LLM 3/10] RLHF และ PPO: เทรนโมเดลด้วยรางวัลที่หาอนุพันธ์ไม่ได้
บทที่ 2 เราสอนโมเดลด้วยการ "เลียนแบบเฉลย" ทีละ token แต่คุณสมบัติที่ทำให้ผู้ช่วย AI ใช้งานได้จริง — ตอบถูก สุภาพ ไม่มั่ว ไม่หลุดเป็นภาษาอังกฤษ — ไม่มีเฉลยให้เลียนแบบ และเขียนเป็น loss function ตรง ๆ ไม่ได้ บทนี้คือคำตอบแบบดั้งเดิมที่สุดของปัญหานั้น: RLHF (Reinforcement Learning from Human Feedback) ด้วย PPO เราจะเทรน reward model จริงจากคู่ preference ภาษาไทย แล้วเขียนลูป PPO เองจากศูนย์ราว 120 บรรทัด และปิดท้ายด้วยการทดลองที่ผมชอบที่สุดในซีรีส์: ถอดสายจูง KL ออก แล้วดูโมเดลโกงรางวัลกันสด ๆ นี่คือบทที่หนักที่สุดของซีรีส์โดยตั้งใจ เพราะบทที่ 4 (DPO) และบทที่ 5 (GRPO) ต่างก็เริ่มจากสมการของบทนี้ แล้วเลือก "ลบ" ชิ้นส่วนออกคนละชิ้น
Open in Colab03_rlhf_ppo.ipynb
1. ปัญหา (Problem statement)
SFT ในบทที่ 2 มีสมมติฐานซ่อนอยู่หนึ่งข้อ: ต้องมีเฉลยให้เลียนแบบ แต่ลองนึกถึงสิ่งที่เราอยากได้จริง ๆ เช่น "ตอบโจทย์เลขให้ถูก และอธิบายเป็นภาษาไทยที่อ่านรู้เรื่อง" ประโยคนี้ไม่มีเฉลยเดียว คำตอบที่ดีมีได้ร้อยแบบ และคำว่า "อ่านรู้เรื่อง" ก็เขียนเป็นสมการไม่ออก
พอพยายามจะ optimize สิ่งเหล่านี้ตรง ๆ เราจะชนกำแพงสองชั้นเสมอ:
กำแพงที่หนึ่ง — คุณภาพเขียนเป็น loss ไม่ได้ "ดีกว่า" นิยามเป็นฟังก์ชันไม่ได้ แต่มนุษย์เปรียบเทียบได้เก่งมาก ให้ดูคำตอบสองอันแล้วชี้ว่าชอบอันไหน ทำได้ทันทีและตรงกันพอสมควร ข้อมูลที่เก็บได้จริงจึงเป็นสามสิ่ง: prompt , คำตอบที่ถูกเลือก , คำตอบที่ถูกปัด
กำแพงที่สอง — ต่อให้มีคะแนน ก็ backprop ไม่ได้ สมมติมีฟังก์ชันวิเศษ ให้คะแนนทุกคำตอบ คุณก็ยังเทรนแบบ supervised ไม่ได้อยู่ดี เพราะคำตอบ เกิดจากการสุ่ม token ทีละตัว คะแนนมาถึงหลังการสุ่มจบแล้ว และอนุพันธ์เดินทางย้อนผ่านการสุ่มไม่ได้ — เส้นทางจาก กลับไปหา weights ขาดตอนตรงนั้นพอดี
| ทางที่อยากเดิน | ติดกำแพงอะไร |
|---|---|
| เขียน loss ของ "คำตอบที่ดี" ตรง ๆ | นิยาม "ดี" เป็นสมการไม่ได้ มีแต่การเปรียบเทียบ |
| ให้คนให้คะแนน แล้ว backprop | คะแนนอยู่หลังการสุ่ม token — gradient เดินผ่านการสุ่มไม่ได้ |
| ให้คนนั่งให้คะแนนสดระหว่างเทรน | มนุษย์ให้คะแนนไม่ทันแม้แต่เสี้ยวเดียวของ rollout |
นี่คือที่มาของชื่อบท: เรากำลังจะ optimize รางวัลที่หาอนุพันธ์ไม่ได้ เครื่องมือที่ทำแบบนั้นได้ชื่อว่า reinforcement learning
2. เราจะทำอะไร (Solution)
RLHF แก้กำแพงทั้งสองชั้นด้วยการเดินสองขั้น:
- Stage A — Reward Model: เทรนโมเดล ให้เลียนแบบการเปรียบเทียบของมนุษย์จากคู่ preference (แก้กำแพงที่หนึ่ง และแทนมนุษย์ที่ให้คะแนนไม่ทัน)
- Stage B — PPO: ใช้ policy-gradient RL ดัน policy ไปหาคะแนนของ โดยไม่ต้องหาอนุพันธ์ผ่านการสุ่ม (แก้กำแพงที่สอง) พร้อมสายจูง KL รั้งไม่ให้วิ่งหนีโมเดลตั้งต้น
ราคาที่จ่ายคือความซับซ้อน: ระหว่างเทรนจะมีโมเดลสี่ตัวอยู่ใน VRAM พร้อมกัน — policy (ตัวที่เทรน), reference (ตัวตั้งต้นที่แช่แข็ง), reward model และ value network ที่ยังไม่ได้แนะนำตัว (รอหัวข้อ 3.4)
RLHF คือการ optimize รางวัลที่คุณหาอนุพันธ์ไม่ได้ ผ่านตัวแทนที่ไม่สมบูรณ์ (reward model) และการ optimize ตัวแทนแรง ๆ โดยไม่มีอะไรรั้ง จะพังตาม Goodhart's law เสมอ: เมื่อตัวชี้วัดกลายเป็นเป้าหมาย มันจะเลิกเป็นตัวชี้วัดที่ดี
พจน์ KL ในสมการ 3.2 จึงไม่ใช่ regularizer ที่ใส่ไว้กันเหนียว — มันคือสิ่งเดียวที่ขวางอยู่ระหว่างคุณกับ reward hacking หัวข้อ 8 จะพิสูจน์ประโยคนี้ด้วยการถอดมันออกให้ดูกับตา
และอีกประโยคที่อยากให้ถือไว้ทั้งซีรีส์: สมการ objective ของบทนี้คือสมการแม่ของครึ่งหลังของซีรีส์ บทที่ 4 (DPO) แก้สมการนี้ในรูปปิดจน reward model และ RL loop ตัดกันหายไป บทที่ 5 (GRPO) เปลี่ยนวิธีประมาณ advantage จน value network หายไป เข้าใจบทนี้บทเดียว อีกสองบทจะกลายเป็น "การลบชิ้นส่วน" ที่อ่านออกทันที
3. สมการ (Equation)
3.1 Reward model: Bradley–Terry
Stage A เทรน ด้วย loss เดียวสั้น ๆ:
- = คะแนนสเกลาร์หนึ่งตัวต่อหนึ่งข้อความ — ในทางปฏิบัติคือโมเดลภาษาที่เปลี่ยนหัวเป็น linear ชั้นเดียว (
num_labels=1) - = sigmoid แปลงผลต่างคะแนนเป็นความน่าจะเป็นที่มนุษย์จะเลือก (โมเดล Bradley–Terry)
- ยิ่งผลต่าง กว้าง loss ยิ่งต่ำ
จุดที่คนมองข้ามแล้วไปเจ็บตัวทีหลัง: loss นี้เห็นแค่ผลต่างของคะแนน ลองแทน ด้วย ด้วยค่าคงที่ ใดก็ได้ — loss ไม่เปลี่ยนเลย แปลว่าสเกลสัมบูรณ์ของ reward model ไม่มีความหมายและไม่ถูกกำหนดโดยการเทรน รันสองครั้งอาจได้คะแนนเฉลี่ย 3.7 กับ −12.4 ที่จัดอันดับเหมือนกันเป๊ะ นี่คือเหตุผลที่ต้อง standardize reward ก่อนป้อนเข้า PPO เสมอ (ลบ mean หารด้วย std) — จำจุดนี้ไว้ มันจะกลับมาในหัวข้อ 7 และ 9
3.2 สมการแม่: RLHF objective
ถ้าทั้งซีรีส์นี้จะท่องจำได้สมการเดียว จงจำสมการนี้:
อ่านเป็นภาษาคน: "เก็บคะแนน reward ให้มากที่สุด แต่ทุกก้าวที่เดินห่างจากโมเดลตั้งต้น ต้องจ่ายค่าปรับ"
- = policy คือโมเดลที่กำลังเทรน — สังเกตว่า ถูกสุ่มจาก เอง นี่คือความต่างเชิงโครงสร้างจาก SFT ที่เรียนจากข้อมูลนิ่ง ๆ ในไฟล์
- = reference คือโมเดลตั้งต้น (โมเดลหลัง SFT จากบทที่ 2) แช่แข็งตลอดการเทรน
- = ราคาต่อหนึ่ง nat ของการเดินห่าง — ความตึงของสายจูง
- = ระยะห่างเชิงการกระจายระหว่าง policy กับ reference
บทที่ 4 (DPO) จะพิสูจน์ว่าสมการนี้มีคำตอบในรูปปิด แล้วพลิกกลับด้านจน และ RL loop หายไปทั้งคู่ บทที่ 5 (GRPO) จะเก็บโครง RL ไว้ แต่เปลี่ยนวิธีคำนวณ advantage จน หายไป ทั้งสองบทไม่ได้เสนอ objective ใหม่ — พวกมันแก้สมการเดียวกันนี้ด้วยเครื่องมือที่ต่างกัน
3.3 PPO clipped surrogate: เครื่องยนต์ของ Stage B
policy gradient แบบดิบ (REINFORCE) ใช้ rollout หนึ่งชุดอัปเดตได้ครั้งเดียวแล้วต้องทิ้ง ซึ่งแพงมากเพราะการ generate คือคอขวด PPO อยากรีดค่า rollout ชุดเดิมหลาย epoch จึงต้องมีตัวคูณแก้ทาง (importance sampling ratio):
- = สถานะ ณ ตำแหน่ง คือ prompt บวก token ที่สุ่มมาแล้วทั้งหมด
- = "การกระทำ" คือ token ตัวถัดไปที่ถูกสุ่มไปแล้วตอน rollout
- = snapshot ของ policy ณ ตอน rollout — ค่านี้ถูกคำนวณครั้งเดียวแล้วแช่แข็ง
แล้วหนีบ ไว้ด้วย clip:
- = advantage คือ "token นี้ดีกว่าที่คาดไว้เท่าไหร่" (นิยามในข้อถัดไป)
- = ความกว้างของ trust region (ค่ามาตรฐาน 0.2)
หัวใจอยู่ที่ min + clip ทำงานร่วมกันแบบมองโลกแง่ร้ายอย่างจงใจ: ถ้า เป็นบวก (token ดี) ผลตอบแทนจากการดัน ถูกตัดเพดานที่ — ดันเกินนั้นไม่ได้อะไรเพิ่ม gradient เป็นศูนย์ แต่ถ้า เป็นลบ (token แย่) ตัว min จะเลือกฝั่งที่แย่กว่าเสมอ — ค่าปรับไม่มีเพดาน สรุปหนึ่งประโยค: ได้จำกัด เสียไม่จำกัด นโยบายจึงขยับทีละก้าวเล็ก ๆ ใกล้ ๆ ตัวเดิม
ในสมการ 3.2 แช่แข็งตลอดการเทรน ทำหน้าที่สายจูง KL ในสมการ 3.3 คือ snapshot ณ ตอน rollout ล่าสุด เปลี่ยนทุกรอบ ทำหน้าที่ trust region บั๊กยอดฮิตอันดับหนึ่งของคนเขียน PPO เองคือจับสองตัวนี้ใส่ตัวแปรเดียวกัน
3.4 GAE: คำนวณ advantage อย่างไรไม่ให้จมน้ำเสียง noise
advantage สร้างจาก TD error ของ value network :
- = value network ทำนายว่า "จากจุดนี้ไปจนจบ จะเก็บ reward ได้อีกเท่าไหร่" — นี่คือโมเดลตัวที่สี่
- = reward ต่อ token (ในงานของเรา: ค่าปรับ KL ทุกตำแหน่ง บวกคะแนนงานที่ token สุดท้าย)
- = discount factor (งาน LLM มักใช้ 1.0)
- = ปุ่มหมุน bias–variance: เชื่อ สุดใจ (bias สูงถ้า ทำนายพลาด), ไม่เชื่อเลยและรอดูผลจริงจนจบ (variance สูงเพราะแบก noise ทั้งสาย), ค่าที่นิยมใช้คือ 0.95
เป็นโมเดลขนาดเท่า ๆ policy ที่ต้องเทรนไปพร้อมกันด้วย loss ของมันเอง ถ้า ทำนายมั่ว advantage ก็มั่ว แล้ว policy ก็เรียนจากสัญญาณมั่ว — จุดพังคลาสสิกของ PPO บทที่ 5 จะตอบคำถามว่า "ถ้าแทน ด้วยค่าเฉลี่ยของกลุ่มคำตอบที่สุ่มจาก prompt เดียวกันล่ะ?" นั่นแหละคือ GRPO ทั้งอัลกอริทึม — ลบโมเดลตัวที่สี่ทิ้งด้วยค่าเฉลี่ยตัวเดียว
3.5 Loss เต็มของ PPO: สามพจน์ สองโมเดล
รวมทุกชิ้นเป็น loss เดียวที่ optimizer เห็นจริง (เขียนในรูป minimize):
- พจน์แรก = clipped surrogate จากข้อ 3.3 (ติดลบเพราะเราต้องการ maximize)
- พจน์ที่สอง = value loss สอน ให้ทายเข้าใกล้ return จริง , มักเป็น 0.5
- พจน์ที่สาม = entropy bonus กันการกระจายยุบเร็วเกินไป, มักเป็น 0.01
- ส่วนสายจูง KL ของสมการ 3.2 ในทางปฏิบัตินิยมยัดเข้าไปใน reward ต่อ token: ซึ่งเป็นวิธีที่เราใช้ในหัวข้อ 7
นับของเล่นทั้งหมดที่ต้องจูน: โมเดล 4 ตัว บวก และ learning rate อีกสองชุด นี่คือเหตุผลที่ PPO ขึ้นชื่อว่า "รันสองรอบด้วย seed ต่างกัน ได้ผลคนละเรื่อง" และเป็นเหตุผลการมีอยู่ของบทที่ 4 ทั้งบท
4. เห็นภาพสมการ (Visualize)
Bradley–Terry: gradient ไปกองอยู่ที่คู่ที่ยังเรียงผิด
เลือกโหมด Bradley-Terry ในเครื่องมือด้านล่าง แล้วลากค่า margin ดู: คู่ที่ reward model จัดอันดับถูกแล้วอย่างมั่นใจ (margin บวกมาก) แทบไม่เหลือ gradient เลย — การเทรน Stage A ใช้งบไปกับคู่ที่มันยังเรียงผิดโดยอัตโนมัติ
Drag anywhere on the plot, or use the Δ slider with the arrow keys.
This pair teaches almost nothing.The model already ranks this pair correctly, so σ(−βΔ) is only 11.92% and the gradient is 11.9% of what a hard pair would give. This is why preference datasets full of obvious wins barely move the model: the easy pairs are silently ignored, and the few genuinely confusing pairs do all the work.
ถ้ากราฟนี้ทำให้นึกถึง gradient ของ DPO ในบทที่ 4 — ไม่ใช่เรื่องบังเอิญ DPO ยกโมเดล Bradley–Terry ตัวนี้ไปใช้ทั้งก้อน แค่เปลี่ยนว่าอะไรทำหน้าที่เป็น reward
Trust region ที่มองเห็นได้: min + clip
Figure 3.1สมการ 3.3 วาดออกมาตรง ๆ ที่ ε = 0.2 — ฝั่ง Â บวก ผลตอบแทนถูกตัดเพดานที่ 1+ε (บริเวณแบน = gradient ศูนย์) แต่ฝั่ง Â ลบ ค่าปรับไม่มีเพดาน เพราะ min เลือกกิ่งที่แย่กว่าเสมอ
สังเกตความไม่สมมาตรให้ดี มันคือบุคลิกทั้งหมดของ PPO: ขยับเข้าหาสิ่งที่ดี ได้ทีละไม่เกิน 20% แต่ถ้าเผลอให้ความน่าจะเป็นกับ token แย่ ๆ มากไป โดนลากกลับเต็มแรงเสมอ "บริเวณแบน" ในภาพคือ trust region ที่ทำให้ PPO ใช้ rollout เก่าซ้ำหลาย epoch ได้โดยไม่ระเบิด
ปุ่มหมุน bias–variance ของ GAE
Figure 3.2GAE บน rollout สังเคราะห์ 20 ขั้น (γ = 1): reward ต่อขั้นเป็น noise เล็ก ๆ คะแนนจริงมาตอนจบ และ V_ψ ตั้งใจให้ทายต่ำกว่าจริงราว 0.4 — λ = 0 สัญญาณไปไม่ถึง token ต้น ๆ, λ = 1 ทุก token ได้เครดิตเต็มพร้อม noise เต็ม (ภาพประกอบกลไก ไม่ใช่ข้อมูลเทรนจริง)
อ่านจากเส้นล่างขึ้นบน: ที่ (เขียว) advantage เกาะศูนย์เกือบตลอด — คะแนนตอนจบไปไม่ถึง token ต้น ๆ เพราะทุกอย่างถูกกรองผ่าน ที่ทายพลาด ที่ (แดง) ทุก token ได้เครดิตเต็มจากตอนจบ แต่แบก noise สะสมของทั้งสายมาด้วย (น้ำเงิน) คือจุดกลางที่คนทั้งวงการเลือกใช้ — สัญญาณเดินทางไกล แต่ noise ถูกหน่วง
ก่อนไปต่อ ลองสร้างสัญชาตญาณของคำว่า advantage — "ดีกว่าที่คาดเท่าไหร่" — ด้วยมือ: เครื่องมือนี้ใช้ ค่าเฉลี่ยของกลุ่ม เป็น baseline แทน (สปอยล์บทที่ 5 แบบเต็ม ๆ) กดชุด All correct แล้วดูว่าเกิดอะไรขึ้นเมื่อทุกคำตอบได้ reward เท่ากัน:
Rewards r_i (G = 8)
Watch the std term.Dividing by std(r) = 0.500 rescales this whole group. A group that happened to be near-unanimous gets a large multiplier and dominates the update, even though it carries less information than a group that genuinely disagreed. Untick the box to see the same rewards without the rescaling.
advantage เป็นศูนย์ทั้งกลุ่ม = ไม่มีสัญญาณให้เรียน — จำความรู้สึกนี้ไว้ตอนอ่านหัวข้อ 9
สายจูง KL: สมการ 3.2 วาดเป็นภาพ
Figure 3.3เส้นทางการเทรนสองแบบในระนาบ (KL, reward) — β = 0.05 ไต่ขึ้นแล้วหยุดตรงจุดที่กำไรส่วนเพิ่มเท่ากับค่าปรับ ส่วน β = 0 ไม่มีจุดหยุด วิ่งขวาเข้าเขต reward hacking (ภาพประกอบกลไกของ failure mode — เส้นจริงที่วัดได้อยู่ในหัวข้อ 8)
จุดหยุดของเส้นเขียวไม่ใช่การเดา — มันคือคณิตศาสตร์ของสมการ 3.2: optimization หยุดตรงที่กำไร reward ต่อ nat เท่ากับ β พอดี เดินต่อจากนั้นขาดทุน เมื่อ เงื่อนไขหยุดนี้ไม่มีอยู่ — ทุก nat ของการเดินห่างที่แลก reward ได้แม้นิดเดียวคือ "กำไร" โมเดลจึงวิ่งออกจากภาษาธรรมชาติไปเรื่อย ๆ ตราบใดที่ตัวเลข reward ยังกระดิกขึ้น
5. เตรียมสภาพแวดล้อม (Environment)
RLHF ระดับใช้งานจริงถือโมเดล 4 ตัวที่ตัวใหญ่สุดมัก 7B ขึ้นไป ใช้คู่ preference จากมนุษย์จริง หลักหมื่นถึงหลักล้านคู่ และแยกเครื่อง generate (rollout fleet) ออกจากเครื่องเทรน โน้ตบุ๊กนี้ใช้ Qwen3-0.6B ทุกตำแหน่ง คู่ preference 100 คู่ และโจทย์เลข 64 ข้อ สิ่งที่มันสาธิตคืออัลกอริทึมครบทุกชิ้นส่วน — ไม่ใช่การทำ RLHF จริง ผลที่ได้จะพิสูจน์กลไก ไม่ได้พิสูจน์ว่าโมเดลเก่งขึ้นสำหรับใช้งาน
เปิด 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 มีระเบิดเพิ่มอีกหนึ่งลูกชื่อ ratio overflow — รอดูในหัวข้อ 7
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 จริง ๆ
ทำไม RLHF ถึงแพง: ภาพเดียวจบ
Figure 3.4โมเดลสี่ตัวที่ต้องอยู่ใน VRAM พร้อมกันระหว่างหนึ่ง PPO step คำนวณจากพารามิเตอร์จริงของ Qwen3-0.6B (fp16 เฉพาะน้ำหนัก) — LoRA ทำให้ policy กับ reference ใช้ base ก้อนเดียวกัน ประหยัดไปหนึ่งโมเดลเต็ม ๆ
ตัวเลขในภาพคือแค่น้ำหนัก — ยังไม่รวม activation, KV cache ตอน generate, gradient และ optimizer state และที่ 0.6B ทุกอย่างยังดูจิ๋ว แต่ตัวคูณ ×4 ไม่หายไปไหนเมื่อสเกลขึ้น: ที่ 7B คือ 56 GB ก่อนเริ่มทำอะไรเลย
policy ของเราคือ base + LoRA adapter จากบทที่ 2 ส่วน คือ base ตัวเดิมที่ปิด adapter
เรียกผ่าน policy.disable_adapter() ได้เลย — reference model มีต้นทุน VRAM เพิ่มศูนย์ไบต์
บทที่ 4 จะใช้ท่าเดียวกันนี้กับ DPO อีกครั้ง มันคือเหตุผลเชิงสถาปัตยกรรมของการเลือก LoRA ตั้งแต่ต้นซีรีส์
6. เตรียมข้อมูล (Data)
Stage A — คู่ preference สำหรับ reward model
ใช้ iapp/dpo_thai_tutorial (100 คู่, Apache-2.0) —
ชุดข้อมูล preference ภาษาไทยที่ผมทำขึ้นเองสำหรับซีรีส์นี้และปล่อยให้ใช้ต่อได้อิสระ
แต่ละแถวมี prompt, chosen, rejected ที่คัดด้วยมือ เน้นความสุภาพและความเป็นธรรมชาติของภาษา
แบ่ง 80/20: เทรน 80 คู่ กัน 20 คู่ไว้เป็น held-out ห้ามแตะระหว่างเทรน เกณฑ์ผ่านของ Stage A คือ pairwise ranking accuracy บน 20 คู่ที่กันไว้ พร้อม Wilson 95% CI ที่ไม่คร่อม 0.5 — แค่ "ดีกว่าโยนเหรียญอย่างมีนัยสำคัญ" ก็ถือว่าพิสูจน์กลไกได้แล้ว เพราะ 20 คู่ทำ CI แคบกว่านั้นไม่ได้จริง ๆ
Stage B — โจทย์ที่ตรวจได้ด้วยกติกา
สำหรับลูป PPO เราใช้โจทย์เลข 64 ข้อจาก VISAI-AI/gsm8k-thai (GSM8K ฉบับแปลไทย)
และให้คะแนนด้วยกติกาที่ตรวจสอบได้ แทนที่จะใช้ reward model จาก Stage A:
import re
def rule_reward(response: str, gold: int) -> float:
"""+1.0 ถ้าเลขจำนวนเต็มตัวสุดท้ายในคำตอบถูก, +0.2 ถ้าตอบเป็นภาษาไทยจริง"""
nums = re.findall(r"-?\d+", response.replace(",", ""))
correct = 1.0 if nums and int(nums[-1]) == gold else 0.0
thai = sum("ก" <= ch <= "๛" for ch in response)
thai_bonus = 0.2 if thai / max(len(response), 1) >= 0.5 else 0.0
return correct + thai_bonus
ในระบบจริง Stage B กินผลผลิตของ Stage A ตรง ๆ — นั่นคือนิยามของ RLHF แต่ RM ที่เทรนจาก 100 คู่อ่อนเกินกว่าจะรับแรงกดดันของ PPO มันจะโดน hack ภายในไม่กี่ update แล้วเราจะแยกไม่ออกว่าลูป PPO ของเราผิด หรือ RM แค่อ่อน — การทดลองจะพิสูจน์อะไรไม่ได้เลย
rule reward จึงทำหน้าที่เป็น stand-in ของ RM ที่ตรวจสอบได้: เมื่อ reward ขึ้น เรารู้แน่ว่าลูปทำงานจริง แต่มันยังคง "ไม่สมบูรณ์" เหมือน RM ทุกตัว — มันวัดแค่เลขท้ายกับสัดส่วนอักขระไทย ไม่วัดความอ่านรู้เรื่องของทุกอย่างรอบ ๆ ซึ่งเป็นช่องโหว่ที่การทดลอง ในหัวข้อ 8 จะทิ่มให้ดู (แนวคิด reward ที่ตรวจได้ด้วยกติกาแบบนี้จะกลับมาเป็นพระเอกเต็มตัวในบทที่ 5)
7. โค้ดหลัก (Main code)
7.1 Stage A — เทรน reward model จริง (~6 นาที)
เปลี่ยนโมเดลภาษาให้เป็นเครื่องให้คะแนน: หัว LM ถูกแทนด้วย linear ชั้นเดียวที่คืนสเกลาร์หนึ่งตัว
import torch, torch.nn.functional as F
from transformers import AutoModelForSequenceClassification, AutoTokenizer
from peft import LoraConfig, get_peft_model
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
rm = AutoModelForSequenceClassification.from_pretrained(
"Qwen/Qwen3-0.6B",
num_labels=1, # หัวสเกลาร์: หนึ่งคะแนนต่อหนึ่งข้อความ
torch_dtype=torch.float16, # T4 ไม่มี bf16
attn_implementation="sdpa",
).cuda()
rm.config.pad_token_id = tok.pad_token_id
rm = get_peft_model(rm, LoraConfig(
task_type="SEQ_CLS", r=8, lora_alpha=16,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
modules_to_save=["score"], # หัวคะแนนตั้งต้นแบบสุ่ม ต้องเทรนเต็ม
))
for p in rm.parameters():
if p.requires_grad:
p.data = p.data.float() # บทเรียน fp16 จากบทที่ 1: เทรนใน fp32
def rm_loss(chosen, rejected):
s_w = rm(**chosen).logits.squeeze(-1) # r_φ(x, y_w)
s_l = rm(**rejected).logits.squeeze(-1) # r_φ(x, y_l)
return -F.logsigmoid(s_w - s_l).mean() # สมการ 3.1 ตรงตัว
เทรน 3 epoch บน 80 คู่ แล้ววัด pairwise accuracy บน 20 คู่ที่กันไว้ พร้อม Wilson CI ถ้า CI ไม่คร่อม 0.5 — คุณเพิ่งเทรน reward model ตัวแรกในชีวิตสำเร็จ ด้วยข้อมูล 80 แถว
7.2 Stage B — PPO เขียนเองจากศูนย์ ~120 บรรทัด (~10 นาที)
เราจะไม่ใช้ PPOTrainer ของ TRL และนี่คือการตัดสินใจที่ตั้งใจ ไม่ใช่ความดื้อ:
TRL ย้าย PPOTrainer ไปอยู่ trl.experimental แล้ว และประกาศแผนถอดออกใน 0.29.0 —
โค้ดที่สอนด้วยไลบรารีตัวนี้จะรันไม่ได้ในอีกไม่กี่เดือน
ส่วนลูป PPO ที่เขียนเองราว 120 บรรทัดจะรันได้ตราบเท่าที่ PyTorch ยังอยู่
และสำคัญกว่านั้น: เขียนเองแล้วคุณจะรู้ว่าทุกบรรทัดทำอะไร แบบเดียวกับ DPO loss 25 บรรทัดในบทที่ 4
ชิ้นส่วนบนกระดาน: policy = base + LoRA adapter (จากบทที่ 2, is_trainable=True),
reference = base ตัวเดิมปิด adapter, และ value head เป็น MLP สองชั้นเสียบบน hidden state สุดท้าย:
value_head = torch.nn.Sequential(
torch.nn.Linear(1024, 1024), torch.nn.Tanh(),
torch.nn.Linear(1024, 1),
).float().cuda() # ~1M พารามิเตอร์ — จิ๋วเดียวเทียบกับอีกสามตัว
rollout: สุ่มคำตอบครั้งละ 8 prompt (max_new_tokens=200, do_sample=True)
ให้คะแนนด้วย rule_reward, standardize คะแนนภายใน batch, และเก็บ log-prob ณ ตอน rollout แบบ detach ไว้ทันที
แล้วเข้าลูป update นี้ — 40 บรรทัดที่เป็นหัวใจของทั้งบท (ลูปเต็มอยู่ในโน้ตบุ๊ก):
def compute_gae(rewards, values, gamma=1.0, lam=0.95):
"""rewards: [T] ของหนึ่ง response, values: [T+1] (ช่องสุดท้าย = 0 หลังจบ)"""
adv, acc = torch.zeros_like(rewards), 0.0
for t in reversed(range(rewards.shape[0])):
delta = rewards[t] + gamma * values[t + 1] - values[t] # สมการ 3.4
acc = delta + gamma * lam * acc # Â_t = δ_t + γλ Â_{t+1}
adv[t] = acc
return adv
def ppo_update(rollout, eps=0.2, beta=0.05):
out = policy(rollout.ids, output_hidden_states=True) # forward เดียวได้สองอย่าง
logp = gather_logprobs(out.logits, rollout.ids) # [B, T] มี gradient
with torch.no_grad(), policy.disable_adapter():
logp_ref = gather_logprobs(policy(rollout.ids).logits, rollout.ids)
# reward ต่อ token = ค่าปรับ KL ทุกตำแหน่ง + คะแนนงานที่ token สุดท้าย (สมการ 3.2)
rew = -beta * (logp.detach() - logp_ref)
rew[:, -1] += rollout.scores_std # คะแนน rule ที่ standardize แล้ว
hidden = out.hidden_states[-1].detach().float() # detach = ตัด gradient เข้าลำต้น
values = value_head(hidden).squeeze(-1)
adv = torch.stack([compute_gae(r, F.pad(v, (0, 1))) for r, v in zip(rew, values)])
adv = (adv - adv.mean()) / (adv.std() + 1e-8) # standardize advantage อีกชั้น
log_ratio = (logp - rollout.logp_old).clamp(-10, 10) # กัน fp16 overflow!
ratio = log_ratio.exp() # ρ_t (สมการ 3.3)
surr = torch.min(ratio * adv.detach(),
ratio.clamp(1 - eps, 1 + eps) * adv.detach())
returns = (adv + values).detach() # เป้าหมายของ value head
m = rollout.resp_mask # นับเฉพาะ token ของ response
loss = (-(surr * m).sum() + 0.5 * ((values - returns).pow(2) * m).sum()) / m.sum()
return loss # (พจน์ entropy อยู่ในโน้ตบุ๊ก)
ตั้งค่า: , , , ,
4 epoch ต่อ rollout เดียวกัน บน 64 prompt, LR ของ adapter 1e-5 ของ value head 1e-4
รวมประมาณ 10 นาทีต่อหนึ่งรันบน T4 (โน้ตบุ๊กรันสองครั้ง: และ )
1. .clamp(-10, 10) ก่อน .exp() — ใน fp16 ค่า exp(12) = 162,754 เกินเพดาน fp16 (65,504)
ผลคือ inf แล้วกลายเป็น NaN ระบาดทั้ง batch ภายใน step เดียว
2. logp_old ต้องคำนวณครั้งเดียวตอน rollout แล้ว detach เก็บไว้ — ห้ามคำนวณใหม่ในลูป epoch
ถ้าคำนวณใหม่ เสมอ clip ไม่เคยทำงาน และ PPO ของคุณเสื่อมเป็น REINFORCE เงียบ ๆ โดยไม่มี error ใด ๆ
3. standardize คะแนนก่อนใช้ — สมการ 3.1 บอกแล้วว่าสเกลของ reward ไม่ถูกนิยาม rule reward ของเราอยู่ในช่วง 0 ถึง 1.2 ก็จริง แต่นิสัยนี้ต้องติดตัวไปตอนใช้ RM จริงที่สเกลมั่วได้ตามใจ
8. ผลลัพธ์ (Results)
โน้ตบุ๊กวัดสามเส้นโค้งพร้อมกันทุก update แล้วเขียนลง results.json:
- Reward เฉลี่ยต่อ rollout — ควรไต่ขึ้น (นี่คือสิ่งที่เราซื้อ)
- KL ต่อ token เทียบ — ควรโตแล้วอิ่มตัวใต้เพดานที่ กำหนด (นี่คือราคาที่จ่าย)
- ความยาวคำตอบเฉลี่ย — ตัวจับโรค: ความยาวที่พุ่งหรือดิ่งผิดปกติคือสัญญาณแรกของ policy ที่กำลังเพี้ยน
reward ที่ไต่ขึ้นแปลว่าอะไรไม่ได้เลยถ้าไม่รู้ว่าโมเดลจ่ายอะไรไปแลกมา reward ขึ้น + KL อิ่มตัว = กำลังเรียนรู้ภายใต้สายจูง reward ขึ้น + KL พุ่งไม่หยุด = กำลังหนีออกจากภาษา ไปหาช่องโหว่ของกรรมการ เส้นเดียวกันคนละบริบท ความหมายตรงข้ามกันเป๊ะ — นี่คือเวอร์ชันวัดจริงของรูปที่ 3.3
จับ reward hacking คาหนังคาเขา
รันที่สองของโน้ตบุ๊กตั้ง โดยแตะอย่างอื่นเลยแม้แต่ตัวเดียว — ไม่มีสายจูง
รูปแบบที่คาดว่าจะเห็น: reward ไต่เท่าเดิมหรือเร็วกว่า แต่ KL ทะยานไม่มีเพดาน
และคำตอบเริ่มเสื่อมสภาพ — วนซ้ำ สั้นผิดปกติ หรือกลายเป็นสูตรสำเร็จที่ยัดตัวเลขไว้ท้ายประโยค
เพราะ rule_reward มองเห็นแค่เลขท้ายกับสัดส่วนอักขระไทย ทุกอย่างที่มันมองไม่เห็นคือของฟรีที่โมเดลทิ้งได้
ตัวอย่างคำตอบเสื่อมสภาพจากรัน จะถูกพิมพ์โดยเซลล์สุดท้ายของโน้ตบุ๊ก:
[ช่องนี้เติมจากผลรันจริงของโน้ตบุ๊กเท่านั้น — ผมจะไม่แต่งตัวอย่าง degenerate ขึ้นเอง
เพราะทั้งซีรีส์นี้ยืนอยู่บนกติกาว่าไม่มีตัวเลขหรือ output ที่ invent ขึ้นมา
รันโน้ตบุ๊กแล้วเซลล์ "hacking exhibits" จะโชว์คำตอบจริง 2-3 อันพร้อม KL ของมัน]
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
9. เปรียบเทียบ (Comparison)
โน้ตบุ๊กวัดชุดเดียวกันสามระบบบนโจทย์เลขไทย held-out (TH-MATH):
| โมเดล | TH-MATH acc (95% CI) | KL เฉลี่ยตอนจบ | ความยาวตอบเฉลี่ย | เวลาเทรน |
|---|---|---|---|---|
| SFT จากบทที่ 2 (ตั้งต้น) | baseline | 0 | baseline | — |
| PPO, β = 0.05 | ? | ? (ควรอิ่มตัว) | ? | ~10 นาที |
| PPO, β = 0 (ablation) | ? | ? (ควรทะยาน) | ? | ~10 นาที |
รูปแบบที่ควรจะเห็น: แถว ขยับ accuracy ขึ้นเล็กน้อยหรือเสมอตัวโดย KL นิ่ง ส่วนแถว ได้ rule reward ตอนเทรนสูงที่สุด แต่ accuracy บน held-out ไม่ควรดีกว่า และภาษาพัง — คะแนนที่โกงมาไม่โอนย้ายไปยังโจทย์ใหม่
ถ้าเห็นอย่างอื่น ให้ตีความแบบนี้:
- ทั้งสามแถวแทบไม่ต่างกัน → advantage เป็นศูนย์เกือบตลอด เช็คว่า reward ในแต่ละ batch มีความหลากหลายไหม (ถ้าโมเดลตอบผิดหมดทุกข้อ advantage หลัง standardize คือ noise ล้วน — ความรู้สึกเดียวกับชุด All wrong ในเครื่องมือหัวข้อ 4)
- β = 0 แล้ว KL ไม่ทะยาน → 4 epoch คูณ 64 prompt สั้นเกินกว่าที่การ hack จะสุกงอม — เพิ่มรอบแล้วดูใหม่ อย่าเพิ่งสรุปว่า "ไม่มี hacking"
- β = 0.05 แต่ KL ก็ยังทะยาน → เกือบแน่นอนว่าลืม standardize คะแนน ทำให้สเกล reward ท่วม β หรือไม่ก็ LR สูงเกิน
กับดักที่ต้องระวัง
1. ไม่ standardize reward สเกลของ reward ไม่ถูกนิยามตามสมการ 3.1 — RM สองตัวที่จัดอันดับเหมือนกันเป๊ะอาจให้สเกลต่างกันสิบเท่า สเกลนั้นคูณตรงเข้าไปใน advantage และ gradient: รันเดิมที่เคยนิ่งจะระเบิดทันทีเมื่อเปลี่ยน RM
2. คำนวณ logp_old ใหม่ในลูป update
บั๊กที่เงียบที่สุดของบทนี้: ไม่มี error, loss ดูปกติ แต่ ตลอดกาล
clip ไม่เคยทำงาน trust region ไม่มีจริง — คุณกำลังรัน REINFORCE โดยเข้าใจว่าตัวเองรัน PPO
3. ใช้ลำต้นร่วมระหว่าง policy กับ value โดยไม่ stop-gradient
value loss มีสเกลใหญ่และไหลย้อนเข้า backbone ไปเขียนทับความสามารถทางภาษา
โน้ตบุ๊กนี้กันด้วย .detach() ตรง hidden state (เห็นในโค้ดหัวข้อ 7.2) — ราคาหนึ่งบรรทัด
4. fp16 ratio overflow
exp(log_ratio) ระเบิดเกินเพดาน fp16 ได้ตั้งแต่ log-ratio ประมาณ 11.1
ต้อง .clamp(-10, 10) ก่อน .exp() เสมอ — และถ้าเห็น log-ratio โตถึงระดับนั้นบ่อย ๆ
นั่นเป็นสัญญาณว่า policy กำลังวิ่งหนี rollout เก่าเร็วเกินไป (ลด LR หรือลดจำนวน epoch ต่อ rollout)
10. สรุป (Summary)
- RLHF = เดินอ้อมสองขั้น เพื่อ optimize สิ่งที่หาอนุพันธ์ไม่ได้: เทรนกรรมการ () แล้วใช้ RL วิ่งเข้าหาคะแนนของกรรมการ
- Bradley–Terry เห็นแค่ผลต่าง — สเกลสัมบูรณ์ของ reward ไม่ถูกนิยาม จึงต้อง standardize ก่อนใช้เสมอ
- สมการแม่ คือสมการเดียวที่ต้องท่องจำ — บทที่ 4 และ 5 คือการแก้สมการนี้ด้วยวิธีอื่น
- KL ไม่ใช่ regularizer — มันคือเงื่อนไขหยุดเพียงอย่างเดียวของระบบ ถอดออกเมื่อไหร่ Goodhart ทำงานเมื่อนั้น
- min + clip = มองโลกแง่ร้ายโดยดีไซน์: ได้จำกัด เสียไม่จำกัด — trust region ที่ทำให้ rollout เก่าใช้ซ้ำได้
- GAE คือปุ่ม bias–variance และ ที่มันพึ่งพาคือโมเดลตัวที่สี่ ที่ GRPO จะลบทิ้งในบทที่ 5
- PPO แพงเพราะโครงสร้าง ไม่ใช่เพราะเขียนโค้ดห่วย: โมเดล 4 ตัว + hyperparameter ร่วมสิบตัวคือราคาหน้าตั๋ว
64 prompt กับ rule-based reward คือการสาธิตอัลกอริทึม ไม่ใช่การทำ RLHF rule reward ของเราเป็นแค่ stand-in ของ reward model จาก Stage A — RLHF จริงใช้ RM ระดับ 7B ขึ้นไปที่เทรนจาก preference ของมนุษย์หลักหมื่นถึงหลักล้านคู่ และต้องมี rollout fleet แยกต่างหาก เพราะการ generate กิน compute มากกว่าการ update หลายเท่า
สิ่งที่การทดลองนี้พิสูจน์ได้จริงมีสองอย่าง: ลูป PPO ที่เขียนเองทำงานถูกต้อง (reward ขึ้นใต้สายจูง KL) และ กลไกของ reward hacking มีจริง (ถอด β แล้ววัดได้ ไม่ใช่แค่เล่าให้ฟัง) อย่าเอาผลนี้ไปอ้างว่าได้โมเดลไทยที่ align แล้ว — สิ่งที่ได้คือความเข้าใจว่าเครื่องจักรทั้งเครื่องหมุนอย่างไร ซึ่งจำเป็นเป๊ะ ๆ ต่อการอ่านสองบทถัดไป
บทต่อไป: DPO — DPO ลบทั้ง reward model และ RL loop ทิ้งด้วยพีชคณิตล้วน ๆ จากสมการแม่ 3.2 ที่คุณเพิ่งท่องจำไป
อ้างอิง (References)
- Schulman et al. (2017). Proximal Policy Optimization Algorithms — PPO ต้นฉบับ: สมการ clipped surrogate ในหัวข้อ 3
- Schulman et al. (2015). High-Dimensional Continuous Control Using Generalized Advantage Estimation — GAE: สมการ advantage ที่ใช้ใน PPO
- Christiano et al. (2017). Deep reinforcement learning from human preferences — งานที่เริ่มต้นแนวคิด RL จาก preference ของมนุษย์
- Stiennon et al. (2020). Learning to summarize from human feedback — RLHF ที่ใช้งานได้จริงเป็นครั้งแรกในงาน summarization
- Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT: ต้นแบบของ pipeline SFT -> RM -> PPO ทั้งหมด
- Bai et al. (2022). Training a Helpful and Harmless Assistant with Reinforcement Learning from Human Feedback — ชุดข้อมูล helpful/harmless และบทเรียนเรื่อง KL
- Zheng et al. (2023). Secrets of RLHF in Large Language Models Part I: PPO — รายละเอียดภาคปฏิบัติของ PPO ที่เปเปอร์อื่นไม่เขียน
- Bradley & Terry (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons — โมเดล Bradley-Terry ที่ reward model ทั้งหมดตั้งอยู่บนนั้น
บทความ โค้ด และโน้ตบุ๊กในซีรีส์นี้เผยแพร่ภายใต้สัญญาอนุญาต CC BY-NC-SA 4.0 — นำไปใช้และดัดแปลงต่อได้ โดยอ้างอิงที่มา ไม่ใช้เพื่อการค้า และเผยแพร่ต่อด้วยสัญญาเดียวกัน (โมเดลและชุดข้อมูลของบุคคลที่สามที่อ้างถึง ยังคงใช้สัญญาของเจ้าของเดิม)
