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

[LLM 4/10] DPO: เมื่อโมเดลภาษากลายเป็น reward model ของตัวเอง

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

บทที่แล้วเราทำ RLHF ด้วย PPO และคุณคงเห็นแล้วว่ามันมีชิ้นส่วนเยอะแค่ไหน — ต้องเทรน reward model แยกหนึ่งตัว ต้องโหลดโมเดลพร้อมกัน 4 ตัวใน VRAM ต้องจูน PPO อีกสิบกว่าพารามิเตอร์ และถ้า reward model เพี้ยน โมเดลจะไปเจอทางลัดที่โกงคะแนนได้ บทนี้เราจะทำสิ่งเดียวกันด้วยลูป training ธรรมดาแบบ supervised — ไม่มี reward model ไม่มี RL และที่สำคัญคือ มันไม่ใช่การประมาณ เราจะพิสูจน์ด้วยพีชคณิตว่าทั้งสองชิ้นนั้นตัดกันหายไปจริง ๆ

Open in Colab04_dpo.ipynb

1. ปัญหา (Problem statement)

สมมติคุณอยากให้ผู้ช่วย AI ของคุณ "ตอบเป็นภาษาไทยเสมอ" — ฟังดูง่าย แต่ลองเขียนเป็น loss function ดูสิครับ คุณจะเขียนไม่ออก

นี่คือปัญหาแกนกลางของ alignment: คุณภาพของคำตอบ เขียนเป็นสมการไม่ได้ "สุภาพกว่า" "เป็นธรรมชาติกว่า" "ไม่หลุดไปเป็นภาษาอังกฤษ" — ไม่มีเฉลยเดียวที่ถูกต้อง มีแต่การเปรียบเทียบ ให้คนดูคำตอบสองอันแล้วบอกว่าชอบอันไหนมากกว่า ข้อมูลที่ได้จึงมีหน้าตาเป็นสามสิ่ง: prompt xx, คำตอบที่ชอบ ywy_w (chosen), คำตอบที่ไม่ชอบ yly_l (rejected)

RLHF แบบ PPO แก้ปัญหานี้ด้วยการเดินอ้อมสองขั้น:

  1. เทรน reward model rϕ(x,y)r_\phi(x,y) ให้เลียนแบบความชอบของมนุษย์
  2. ใช้ RL ดัน policy ไปหาคะแนนสูง ๆ ของ reward model นั้น

การเดินอ้อมนี้มีราคา:

ปัญหาของ RLHF/PPOผลที่เกิดขึ้นจริง
ต้องเทรนโมเดลเพิ่ม 1 ตัวเพิ่มขั้นตอน เพิ่มโอกาสพัง เพิ่มเวลา
ต้องโหลด 4 โมเดลพร้อมกันpolicy + ref + reward + value — VRAM บาน
reward hackingโมเดลหาช่องโกงคะแนน โดยที่มนุษย์ไม่ได้ชอบขึ้นเลย
PPO ไวต่อ hyperparameterรันสองรอบด้วย seed ต่างกัน อาจได้ผลคนละเรื่อง

คำถามของบทนี้จึงเป็นคำถามเดียวสั้น ๆ: เราข้ามขั้นที่ 1 กับ 2 ไปเลยได้ไหม

2. เราจะทำอะไร (Solution)

คำตอบคือได้ และเหตุผลสวยมาก

จุดเริ่มคือข้อสังเกตว่า objective ของ RLHF ที่มี KL constraint นั้น มีคำตอบในรูปปิด (closed form) เรารู้อยู่แล้วว่า policy ที่ดีที่สุดหน้าตาเป็นอย่างไร โดยไม่ต้องรัน RL เลยแม้แต่ step เดียว เมื่อรู้แบบนั้น เราก็พลิกสมการกลับด้าน — แทนที่จะถามว่า "reward นี้ให้ policy อะไร" เราถามว่า "policy นี้แปลว่า reward เท่าไหร่"

แนวคิดหลักของบทนี้

เมื่อพลิกสมการ ตัวโมเดลภาษาเองก็คือ reward model อยู่แล้วโดยปริยาย reward model กับ RL loop ไม่ได้ถูก "ประมาณทิ้ง" แต่มันตัดกันหายไปทางพีชคณิต สิ่งที่เหลือคือ loss function แบบ supervised ธรรมดาที่เทรนด้วย Trainer ตัวเดียวจบ

นี่คือ DPO (Direct Preference Optimization) ซึ่งเสนอโดย Rafailov และคณะ (2023) ชื่อ "Direct" มาจากการที่เรา optimize บนข้อมูล preference โดยตรง ไม่ผ่านตัวกลาง

3. สมการ (Equation)

3.1 ตั้งโจทย์: RLHF objective

maxπ ExD,yπ(x)[r(x,y)]βDKL[π(yx)πref(yx)]\max_{\pi}\ \mathbb{E}_{x\sim\mathcal{D},\,y\sim\pi(\cdot|x)}\big[r(x,y)\big] - \beta\,\mathbb{D}_{\text{KL}}\big[\pi(y|x)\,\|\,\pi_{\text{ref}}(y|x)\big]

อ่านเป็นภาษาคน: "ทำคะแนน reward ให้สูงที่สุด แต่ห้ามเดินห่างจากโมเดลตั้งต้นมากเกินไป"

  • π\pi = policy คือโมเดลที่เรากำลังเทรน
  • πref\pi_{\text{ref}} = reference policy คือโมเดลตั้งต้น (ในบทนี้คือโมเดลหลัง SFT จากบทที่ 2)
  • r(x,y)r(x,y) = reward ของคำตอบ yy ต่อ prompt xx
  • β\beta = ความเข้มของสายจูง ยิ่งมากยิ่งดึงกลับหา πref\pi_{\text{ref}} แรง

พจน์ KL ไม่ใช่ของประดับ ถ้าไม่มีมันโมเดลจะวิ่งหนีไปหาจุดที่ reward สูงแต่ภาษาพัง

3.2 ขั้นที่ 1 — คำตอบในรูปปิด

โจทย์ข้างบนนี้แก้ได้ด้วยมือ (เป็นการหา distribution ที่ minimize KL ต่อ distribution เป้าหมาย) ได้ผลว่า

π(yx)=1Z(x)πref(yx)exp(1βr(x,y))\pi^*(y|x) = \frac{1}{Z(x)}\pi_{\text{ref}}(y|x)\exp\left(\frac{1}{\beta}r(x,y)\right)
  • Z(x)=yπref(yx)exp ⁣(r(x,y)/β)Z(x) = \sum_{y}\pi_{\text{ref}}(y|x)\exp\!\big(r(x,y)/\beta\big) คือ partition function ตัวหารที่ทำให้ผลรวมเป็น 1
  • สังเกตว่า Z(x)Z(x) ขึ้นกับ xx เท่านั้น ไม่ขึ้นกับ yy — จำประโยคนี้ไว้ให้ดี เดี๋ยวมันจะกลายเป็นพระเอก

ความหมายเชิงสัญชาตญาณ: policy ที่ดีที่สุดคือ โมเดลเดิม ถ่วงน้ำหนักใหม่ด้วย exp(r/β)\exp(r/\beta) คำตอบที่ reward สูงถูกขยายความน่าจะเป็น คำตอบที่ reward ต่ำถูกกด แต่ตั้งต้นจากรูปร่างเดิมของ πref\pi_{\text{ref}} เสมอ

ในทางปฏิบัติเราคำนวณ Z(x)Z(x) ไม่ได้ เพราะต้องรวมทุกคำตอบที่เป็นไปได้ทั้งจักรวาล นี่คือเหตุผลที่คนใช้ RL — และเป็นเหตุผลที่ DPO ไม่ต้องใช้

3.3 ขั้นที่ 2 — พลิกสมการหา reward

ใส่ log ทั้งสองข้างแล้วย้ายข้าง จะได้

r(x,y)=βlogπ(yx)πref(yx)+βlogZ(x)r(x,y) = \beta\log\frac{\pi^*(y|x)}{\pi_{\text{ref}}(y|x)} + \beta\log Z(x)

บรรทัดนี้คือหัวใจ reward ทุกฟังก์ชันเขียนใหม่ได้ในรูปของ policy ที่ optimal กับ policy ตั้งต้น แปลว่าถ้าเรามีโมเดลสองตัว เราคำนวณ reward โดยปริยายของมันได้ทันที โดยไม่ต้องเทรน reward model ใด ๆ

3.4 ขั้นที่ 3 — แทนใน Bradley-Terry แล้ว Z(x)Z(x) ตัดหาย

โมเดลมาตรฐานของความชอบคือ Bradley-Terry: โอกาสที่คนจะเลือก ywy_w เหนือ yly_l คือ

p(ywylx)=σ(r(x,yw)r(x,yl))p(y_w \succ y_l \mid x) = \sigma\big(r(x,y_w) - r(x,y_l)\big)

โดย σ\sigma คือ sigmoid สังเกตว่าในสมการนี้ reward ปรากฏในรูป ผลต่าง เท่านั้น แทนสมการ 3.3 ลงไป — βlogZ(x)\beta\log Z(x) อยู่ทั้งสองฝั่งเท่ากันเป๊ะเพราะ xx ตัวเดียวกัน — มันจึงตัดกันหายไป

LDPO(θ)=E(x,yw,yl)[logσ(βlogπθ(ywx)πref(ywx)βlogπθ(ylx)πref(ylx))]\mathcal{L}_{\text{DPO}}(\theta) = -\mathbb{E}_{(x,y_w,y_l)}\left[\log\sigma\left(\beta\log\frac{\pi_\theta(y_w|x)}{\pi_{\text{ref}}(y_w|x)} - \beta\log\frac{\pi_\theta(y_l|x)}{\pi_{\text{ref}}(y_l|x)}\right)\right]
นี่คือประโยคที่ทั้งบทความสร้างขึ้นมาเพื่อพูด

สิ่งที่คำนวณไม่ได้ (Z(x)Z(x)) ตัดหายไป เพราะ Bradley-Terry สนใจแค่ผลต่างของ reward ที่เหลือคือ log-probability ของโมเดลสองตัวบนข้อความที่เรามีอยู่แล้ว ซึ่งคำนวณด้วย forward pass ธรรมดา ไม่มีการสุ่มคำตอบ ไม่มี rollout ไม่มี value function — DPO เป็น supervised learning เต็มตัว

3.5 Gradient — ที่ซึ่งสัญชาตญาณอยู่

θLDPO=βE[σ(r^lr^w)(θlogπθ(ywx)θlogπθ(ylx))]\nabla_\theta\mathcal{L}_{\text{DPO}} = -\beta\,\mathbb{E}\left[\sigma(\hat r_l - \hat r_w)\left(\nabla_\theta\log\pi_\theta(y_w|x) - \nabla_\theta\log\pi_\theta(y_l|x)\right)\right]

โดย r^=βlog(πθ/πref)\hat r = \beta\log\big(\pi_\theta/\pi_{\text{ref}}\big) เรียกว่า implicit reward (reward โดยปริยาย)

อ่านทีละชิ้น:

  • วงเล็บขวา = ทิศทาง ดัน log-prob ของ ywy_w ขึ้น และกด log-prob ของ yly_l ลง พร้อมกัน
  • σ(r^lr^w)\sigma(\hat r_l - \hat r_w) = น้ำหนัก คือ "โมเดลจัดอันดับคู่นี้ผิดแค่ไหน"

น้ำหนักตัวนี้คือจุดสอนที่สำคัญที่สุด ถ้าโมเดลจัดอันดับคู่นี้ถูกอยู่แล้ว (r^w\hat r_w มากกว่า r^l\hat r_l ชัดเจน) ค่า σ(r^lr^w)\sigma(\hat r_l - \hat r_w) จะเข้าใกล้ศูนย์ และคู่นั้นแทบไม่ส่ง gradient เลย DPO จึงโฟกัสไปที่ความผิดพลาดของตัวเองโดยอัตโนมัติ ไม่ต้องมีใครมาคัดข้อมูลให้

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

Bradley-Terry: จากผลต่าง reward เป็นความน่าจะเป็น

กราฟ sigmoid ของผลต่าง reward ระหว่าง chosen กับ rejected แบ่งพื้นที่ฝั่งที่โมเดลเห็นตรงกับมนุษย์และฝั่งที่เห็นต่างกราฟ sigmoid ของผลต่าง reward ระหว่าง chosen กับ rejected แบ่งพื้นที่ฝั่งที่โมเดลเห็นตรงกับมนุษย์และฝั่งที่เห็นต่าง

Figure 4.1Bradley-Terry แปลงผลต่างของ reward เป็นความน่าจะเป็นที่มนุษย์จะเลือก chosen — โมเดลไม่เคยต้องรู้ค่า reward สัมบูรณ์ รู้แค่ผลต่างก็พอ

จุดที่ผลต่างเป็นศูนย์ให้ความน่าจะเป็น 0.5 พอดี คือ "โมเดลไม่มีความเห็น" และเพราะกราฟสนใจแค่ผลต่าง การบวกค่าคงที่เข้าไปใน reward ทั้งสองฝั่งจึงไม่เปลี่ยนอะไรเลย นั่นแหละคือเหตุผลเชิงเรขาคณิตที่ Z(x)Z(x) ตัดหายได้

Loss และน้ำหนักของ gradient

ให้ Δ=logπθ(ywx)πref(ywx)logπθ(ylx)πref(ylx)\Delta = \log\frac{\pi_\theta(y_w|x)}{\pi_{\text{ref}}(y_w|x)} - \log\frac{\pi_\theta(y_l|x)}{\pi_{\text{ref}}(y_l|x)} แล้ว loss คือ logσ(βΔ)-\log\sigma(\beta\Delta) และน้ำหนัก gradient คือ σ(βΔ)\sigma(-\beta\Delta)

กราฟสองแผงแสดง DPO loss ที่ลดลงเมื่อ margin เพิ่มขึ้น และน้ำหนัก gradient ที่ลู่เข้าศูนย์เมื่อโมเดลจัดอันดับถูกแล้ว เปรียบเทียบสามค่าเบตากราฟสองแผงแสดง DPO loss ที่ลดลงเมื่อ margin เพิ่มขึ้น และน้ำหนัก gradient ที่ลู่เข้าศูนย์เมื่อโมเดลจัดอันดับถูกแล้ว เปรียบเทียบสามค่าเบตา

Figure 4.2ซ้าย: DPO loss เทียบกับ margin — ขวา: น้ำหนักที่คู่นั้นได้รับใน gradient สำหรับ β = 0.1, 0.3, 1.0

แผงขวาคือสิ่งที่ต้องดูให้ดี เมื่อ margin เป็นบวกมาก ๆ น้ำหนักจะลู่เข้าศูนย์ — คู่นั้น "เรียนจบแล้ว" และยิ่ง β\beta สูง เส้นยิ่งชัน คือทั้งเรียนเร็วและ "เลิกเรียน" เร็ว ที่ β=1.0\beta = 1.0 คู่ที่ margin เกิน 4 แทบไม่มี gradient เหลือเลย ส่วนที่ β=0.1\beta = 0.1 เส้นแบนกว่ามาก โมเดลยังเก็บ gradient จากทุกคู่อยู่เรื่อย ๆ — ช้ากว่า แต่นิ่งกว่า

β\beta ควบคุมว่าโมเดลจะเดินห่างจากตั้งต้นได้แค่ไหน

แผนภูมิแท่งของความน่าจะเป็นคำตอบ 5 ตัวเลือกที่ค่าเบตาต่างกัน เทียบกับเส้นประของ policy ตั้งต้นแผนภูมิแท่งของความน่าจะเป็นคำตอบ 5 ตัวเลือกที่ค่าเบตาต่างกัน เทียบกับเส้นประของ policy ตั้งต้น

Figure 4.3สมการ π* ∝ π_ref · exp(r/β) บนตัวอย่างของเล่นที่มี 5 คำตอบ — β เล็กบีบทุกอย่างไปที่คำตอบ reward สูงสุด β ใหญ่คืนรูปกลับเป็น π_ref

อ่านภาพนี้จากขวาไปซ้าย: ที่ β=0.1\beta = 0.1 ความน่าจะเป็นเกือบทั้งหมดยุบไปกองที่ y5y_5 ซึ่ง reward สูงสุด นั่นคือ mode collapse — ได้คะแนนดีแต่ความหลากหลายหายเกลี้ยง ที่ β=10\beta = 10 แท่งเกือบทับเส้นประ คือแทบไม่ได้เรียนอะไรเลย β\beta ไม่ใช่ hyperparameter ที่ "ปรับให้ loss ต่ำสุด" แต่มันคือ การเลือกจุดแลกเปลี่ยน ระหว่างการทำตามความชอบกับการรักษาตัวตนเดิม

ลองเลื่อนค่าเองแล้วดูว่า loss กับ gradient เปลี่ยนรูปอย่างไร:

Loss family
Positive means the model already prefers the chosen response.
-6-4-20246reward margin Δlossgradient weight

Drag anywhere on the plot, or use the Δ slider with the arrow keys.

Δ2.00βΔ = 0.200
Loss0.5981
Gradient weight0.045069.7% of maximum
σ(−βΔ)0.450245.02%

This pair carries real signal.σ(−βΔ) is 45.02%: the model is still wrong or unsure about this pair, so it dominates the batch gradient. Drag Δ to the right and watch that weight collapse.

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 # ใน DPOConfig (ไม่ใช่ 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 จริง ๆ

reference model ที่ไม่กิน VRAM เพิ่มเลย

DPO ต้องใช้ทั้ง πθ\pi_\theta และ πref\pi_{\text{ref}} ฟังดูเหมือนต้องโหลดสองโมเดล แต่เราต่อยอดจาก LoRA adapter ของบทที่ 2 ซึ่งทำให้ทั้งสองตัวใช้น้ำหนักฐานร่วมกัน

from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B",
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda()

policy = PeftModel.from_pretrained(base, "kobkrit/qwen3-0.6b-th-sft-lora", is_trainable=True)

policy คือ base + adapter ส่วน πref\pi_{\text{ref}} คือ base ตัวเดิม ที่ปิด adapter ไว้ เรียกผ่าน context manager policy.disable_adapter() ได้เลย ไม่ต้องโหลดอะไรเพิ่ม

นี่คือผลตอบแทนจากบทที่ 2

ถ้าเราทำ full fine-tuning ในบทที่ 2 เราจะต้องโหลดโมเดลสองชุดเต็ม ๆ ในบทนี้ การเลือก LoRA ตั้งแต่ต้นทำให้ reference model มีต้นทุน VRAM เพิ่ม ศูนย์ไบต์ มันคือเหตุผลเชิงสถาปัตยกรรม ไม่ใช่แค่การประหยัดหน่วยความจำตอนเทรน

6. เตรียมข้อมูล (Data)

ข้อมูล DPO ต้องมี 3 คอลัมน์: prompt, chosen, rejected เท่านั้น

ชุดที่ 1 — iapp/dpo_thai_tutorial (100 คู่, Apache-2.0) เป็นชุดข้อมูลที่ผมทำขึ้นเองสำหรับซีรีส์นี้ ปล่อยเป็น Apache-2.0 ให้เอาไปใช้ต่อได้ เป็นคู่ preference ภาษาไทยที่คัดด้วยมือ เน้นความสุภาพและความเป็นธรรมชาติของภาษา

ชุดที่ 2 — สร้างเองราว 400 คู่ จาก airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k วิธีสร้างตรงไปตรงมามาก:

  • chosen = คำตอบอ้างอิงภาษาไทยที่มากับชุดข้อมูล
  • rejected = คำตอบที่โมเดลฐานสร้างเองด้วย greedy decoding
def build_rejected(prompt):
with torch.no_grad(), policy.disable_adapter():
out = policy.generate(**tok(prompt, return_tensors="pt").to("cuda"),
max_new_tokens=192, do_sample=False)
return tok.decode(out[0], skip_special_tokens=True)[len(prompt):]
ทำไม rejected ที่โมเดลสร้างเองถึงดีกว่า rejected ที่ไปหามา

คำตอบ greedy ของ Qwen3-0.6B บน prompt ภาษาไทย มักไหลไปเป็นภาษาอังกฤษกลางประโยค นั่นคือข้อบกพร่องจริงของโมเดลตัวนี้ ไม่ใช่ข้อบกพร่องที่เราสมมติขึ้น

การใช้มันเป็น rejected ทำให้ gradient ชี้ตรงไปที่พฤติกรรมที่เราอยากแก้พอดี และตรงกับ metric th_ratio ที่เราจะวัดในหัวข้อ 8 แบบตรงเป้า ถ้าคุณไปเอา rejected จากโมเดลอื่นมา คุณจะกำลังสอนให้โมเดล "ไม่เป็นโมเดลอื่น" ซึ่งไม่ใช่สิ่งที่คุณต้องการ

รวมได้ราว 500 คู่ แบ่ง held-out ไว้ 15% สำหรับวัดผล ไม่แตะระหว่างเทรน

7. โค้ดหลัก (Main code)

หัวใจของหัวข้อนี้ไม่ใช่การเรียกใช้ไลบรารี แต่คือการเขียน DPO loss เองด้วยมือ แล้วพิสูจน์ว่ามันตรงกับของจริง ถ้าอ่านสมการในหัวข้อ 3 มาแล้วยังไม่เชื่อ โค้ดนี้จะทำให้เชื่อ

7.1 เขียนเอง 25 บรรทัด

import torch, torch.nn.functional as F

def seq_logp(model, input_ids, attention_mask, labels):
"""ผลรวม log-prob ของ 'เฉพาะส่วนคำตอบ' (token ของ prompt ถูก mask เป็น -100)"""
logits = model(input_ids=input_ids, attention_mask=attention_mask).logits
logits = logits[:, :-1, :] # ตำแหน่ง t ทำนาย token ที่ t+1
target = labels[:, 1:] # จึงต้องเลื่อนเป้าหมายไป 1
mask = target.ne(-100) # นับเฉพาะ token คำตอบ
target = target.masked_fill(~mask, 0) # กัน gather พังที่ตำแหน่ง -100
logp = torch.log_softmax(logits.float(), dim=-1)
tokp = logp.gather(-1, target.unsqueeze(-1)).squeeze(-1)
return (tokp * mask).sum(-1) # [B] — ผลรวม ไม่ใช่ค่าเฉลี่ย

def dpo_loss(policy, batch, beta=0.1):
pi_w = seq_logp(policy, batch["chosen_ids"], batch["chosen_mask"], batch["chosen_labels"])
pi_l = seq_logp(policy, batch["rejected_ids"], batch["rejected_mask"], batch["rejected_labels"])

with torch.no_grad(), policy.disable_adapter(): # reference: ปิด adapter + ไม่เอา gradient
ref_w = seq_logp(policy, batch["chosen_ids"], batch["chosen_mask"], batch["chosen_labels"])
ref_l = seq_logp(policy, batch["rejected_ids"], batch["rejected_mask"], batch["rejected_labels"])

delta = (pi_w - ref_w) - (pi_l - ref_l) # Δ ในหัวข้อ 4
loss = -F.logsigmoid(beta * delta).mean() # สมการ 3.4 ตรงตัว

r_w = beta * (pi_w - ref_w).detach() # implicit reward ของ chosen
r_l = beta * (pi_l - ref_l).detach() # implicit reward ของ rejected
return loss, r_w, r_l

ทั้ง DPO อยู่ในนี้หมดแล้วครับ ไม่มีอะไรซ่อนอยู่อีก บรรทัด delta คือสมการ 3.4 แปลงเป็นโค้ดแบบหนึ่งต่อหนึ่ง และ -F.logsigmoid(beta * delta) คือ loss ทั้งก้อน

7.2 พิสูจน์ว่ามันตรงกับ TRL

from trl import DPOTrainer, DPOConfig

cfg = DPOConfig(
output_dir="dpo-out",
beta=0.1,
loss_type="sigmoid", # ต้องตรงกับสูตรที่เราเขียนเอง
label_smoothing=0.0, # ถ้าไม่ศูนย์ สูตรจะไม่ใช่สมการ 3.4 อีกต่อไป
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # effective batch = 16
num_train_epochs=2,
learning_rate=5e-6, # ต่ำกว่า SFT มาก — ดูคำเตือนด้านล่าง
lr_scheduler_type="cosine",
warmup_ratio=0.1,
max_length=768,
max_prompt_length=256,
fp16=True, # T4 ไม่มี bf16
logging_steps=5,
)

trainer = DPOTrainer(model=policy, args=cfg, train_dataset=train_ds, processing_class=tok)

# สำคัญ: LoRA ที่เพิ่งสร้างมี lora_B = 0 ทำให้ policy เท่ากับ reference พอดี
# ผลต่างจึงเป็นศูนย์ และทั้งสองสูตรคืนค่า ln 2 เท่ากันเสมอ "แม้สูตรจะผิด"
# ต้องรบกวนน้ำหนักก่อน assert ถึงจะเป็นการทดสอบจริง
with torch.no_grad():
for name, p in policy.named_parameters():
if "lora_B" in name:
p.add_(torch.randn_like(p) * 0.01)

loss_manual, _, _ = dpo_loss(policy, batch, beta=0.1)
loss_trl = trainer.compute_loss(policy, trl_batch)

assert torch.allclose(loss_manual, loss_trl, atol=1e-4)
print("ตรงกัน:", loss_manual.item(), loss_trl.item())
นี่คือช่วงเวลาที่บทความนี้พิสูจน์ตัวเอง แทนที่จะขอให้คุณเชื่อ

บทความ tutorial ส่วนใหญ่จบที่ "เรียก DPOTrainer แล้วมันก็ทำงาน" บรรทัด assert ข้างบนบอกว่าสมการที่เราอนุมานมาทั้งหัวข้อที่ 3 ให้ค่าเท่ากับไลบรารีที่คนทั้งโลกใช้ ถ้า assert ผ่าน แปลว่าคุณเข้าใจ DPO ในระดับที่ implement เองได้แล้ว ไม่ใช่แค่เรียกใช้เป็น

ถ้า assert ไม่ผ่าน อย่าเพิ่งโทษโค้ดตัวเอง

สาเหตุที่พบบ่อยเรียงตามลำดับ: label_smoothing ไม่เป็นศูนย์, loss_type ไม่ใช่ "sigmoid", batch ที่ป้อนสองฝั่งไม่ใช่ตัวอย่างเดียวกัน, หรือ padding/masking ไม่ตรงกัน ทั้งสี่ข้อคือความไม่ตรงกันของนิยาม ไม่ใช่ bug — และการไล่หามันคือบทเรียนที่ดีที่สุดของหัวข้อนี้

DPO ต้องการ learning rate ต่ำกว่า SFT มาก

SFT ด้วย LoRA ใช้ 2e-4 ได้สบาย แต่ DPO ที่ 2e-4 จะทำให้ policy วิ่งหนี reference ภายในไม่กี่สิบ step แล้วภาษาจะพังจนอ่านไม่ออก

ใช้ 5e-6 เป็นจุดตั้งต้น เพราะ DPO ไม่ได้กำลังสอนเนื้อหาใหม่ มันแค่ เอียงการกระจายความน่าจะเป็นที่มีอยู่แล้ว ซึ่งใช้แรงน้อยกว่ามาก

รวมเวลาเทรนบน T4 ประมาณ 9 นาที สำหรับ 500 คู่ 2 epoch

8. ผลลัพธ์ (Results)

โน้ตบุ๊กวัด 3 อย่างแล้วเขียนลง results.json:

  1. Held-out preference accuracy — สัดส่วนคู่ที่ implicit reward ของ chosen มากกว่า rejected พร้อม Wilson 95% CI
  2. การกระจายของ implicit reward margin (r^wr^l\hat r_w - \hat r_l) ทั้ง distribution ไม่ใช่แค่ค่าเฉลี่ย
  3. th_ratio — สัดส่วนอักขระไทยในคำตอบที่โมเดลสร้าง คือ metric ประจำซีรีส์นี้ที่ใช้จับอาการไหลไปเป็นภาษาอังกฤษแบบเงียบ ๆ
ทำไมต้องดูทั้ง distribution ไม่ใช่แค่ค่าเฉลี่ย

margin เฉลี่ยที่สวยงามอาจมาจากคู่ไม่กี่คู่ที่ margin สูงลิ่ว ขณะที่คู่ส่วนใหญ่ยังอยู่แถวศูนย์ histogram บอกความจริงข้อนี้ ส่วนตัวเลขเดียวปิดบังมันไว้ และ accuracy ที่ไม่มี CI ก็ยังไม่ใช่ผลการทดลอง เหมือนที่ย้ำไว้ตั้งแต่บทที่ 1

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.

สิ่งที่จะทำให้คุณตกใจตอนดู log ครั้งแรก

ระหว่างเทรน คุณจะเห็น rewards/chosen และ rewards/rejected ใน log ของ TRL และสิ่งที่เกิดขึ้นเกือบทุกครั้งคือ ทั้งสองค่าไหลลงเป็นลบพร้อมกัน ขณะที่ rewards/margins กว้างขึ้นเรื่อย ๆ

นี่คือเรื่องปกติ ไม่ใช่อาการพัง

จำนิยามไว้: r^=βlog(πθ/πref)\hat r = \beta\log(\pi_\theta/\pi_{\text{ref}}) ดังนั้น r^\hat r ติดลบแปลว่า policy ให้ความน่าจะเป็นกับข้อความนั้นน้อยกว่า reference

DPO ไม่ได้ถูกสั่งให้ "ทำให้ chosen น่าจะเป็นมากขึ้น" มันถูกสั่งให้ "ทำให้ช่องว่างกว้างขึ้น" เท่านั้น การกด rejected ลงแรง ๆ แล้วกด chosen ลงเบา ๆ ก็ตอบโจทย์ได้เหมือนกัน และมักเป็นทางที่ง่ายกว่า

สิ่งที่ต้องดูจึงเป็น margin กับ held-out accuracy ไม่ใช่ระดับสัมบูรณ์ของ reward แต่ถ้า rewards/chosen ดิ่งลงลึกมาก (เช่น ต่ำกว่า −10) นั่นเริ่มเป็นสัญญาณว่าโมเดลกำลังทิ้ง reference — ลด LR หรือเพิ่ม β\beta

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

โมเดลPref. acc (95% CI)Mean marginth_ratioความยาวเฉลี่ยเวลาเทรน
SFT จากบทที่ 2 (ตั้งต้น)baseline0baselinebaseline
DPO, β = 0.1??ควรสูงขึ้น?~9 นาที
DPO, β = 0.5?เล็กกว่า??~9 นาที

รูปแบบที่คุณควรจะเห็น: preference accuracy สูงขึ้นชัดเจน, margin เป็นบวกและกว้างขึ้น, และ th_ratio ขยับขึ้นเพราะ rejected ทั้งชุดถูกสร้างจากคำตอบที่หลุดเป็นอังกฤษ

ถ้าเห็นอย่างอื่น ให้ตีความแบบนี้:

  • accuracy ขยับน้อยมาก และ margin เกือบศูนย์β\beta สูงเกินหรือ LR ต่ำเกิน โมเดลแทบไม่ขยับจาก reference
  • accuracy บน train สูงลิ่วแต่ held-out ไม่ขยับ → overfit 500 คู่ ซึ่งน้อยมากจริง ๆ
  • th_ratio ขึ้นแต่คำตอบอ่านแล้วแปลก ๆ → policy หนี reference มากไป ลด LR หรือเพิ่ม β\beta

กับดักที่ต้องระวัง

1. ลืม torch.no_grad() ตอน forward reference จะไม่มี error ใด ๆ แต่ VRAM จะพุ่งจนอาจ OOM และถ้า reference ไม่ได้ถูกแช่แข็งจริง สมการ 3.4 จะไม่ถูกต้องอีกต่อไป — นี่คือ bug ที่เงียบที่สุดในบทนี้

2. รวม log-prob ของ token ฝั่ง prompt เข้าไปด้วย prompt เหมือนกันทั้ง chosen และ rejected ในทางทฤษฎีจึงน่าจะตัดกัน แต่ในทางปฏิบัติ padding และความยาวที่ต่างกันทำให้มันไม่ตัดกันพอดี และ margin จะเพี้ยน ต้อง mask prompt เป็น -100 เสมอ (บรรทัด mask = target.ne(-100) ในหัวข้อ 7.1 คือจุดนั้น)

3. Length bias — DPO ชอบคำตอบยาวอย่างเป็นระบบ เพราะเรารวม log-prob แบบ ผลรวม ไม่ใช่ค่าเฉลี่ย คำตอบที่ยาวกว่าจึงมีที่ให้สะสมผลต่างมากกว่า ถ้าในข้อมูลของคุณ chosen ยาวกว่า rejected โดยเฉลี่ยอยู่แล้ว โมเดลอาจเรียนแค่ว่า "ตอบยาวไว้ก่อน" แทนที่จะเรียนว่า "ตอบดี"

วัดความยาวก่อน-หลังเสมอ แล้วรายงานตามจริง

โน้ตบุ๊กพิมพ์ความยาว token เฉลี่ยของคำตอบก่อนและหลัง DPO ออกมาเสมอ ถ้าความยาวเพิ่มขึ้นมากอย่างมีนัย ให้สงสัยไว้ก่อนว่าส่วนหนึ่งของ "คุณภาพที่ดีขึ้น" คือ length bias วิธีเช็กง่าย ๆ คือดูความยาวเฉลี่ยของ chosen เทียบ rejected ในชุดข้อมูลตั้งแต่ก่อนเทรน

4. เลือก β\beta ผิดทาง β\beta ต่ำเกิน → policy วิ่งหนี reference จนภาษาพัง (ดูรูปที่ 4.3 ที่ β=0.1\beta = 0.1 บนตัวอย่างของเล่น) β\beta สูงเกิน → แทบไม่มีอะไรขยับ เสียเวลาเทรนฟรี 0.1 คือค่าตั้งต้นที่คนใช้กันมากที่สุด และควรเป็นจุดเริ่มของคุณ ไม่ใช่จุดจบ

10. สรุป (Summary)

  • DPO ไม่ได้ประมาณ RLHF แต่แก้สมการเดียวกันในรูปปิด — reward model กับ RL loop ตัดกันหายทางพีชคณิต
  • Z(x)Z(x) หายไปได้เพราะ Bradley-Terry สนใจแค่ผลต่างของ reward นี่คือกุญแจของทั้งบท
  • โมเดลภาษาเป็น reward model ของตัวเอง ผ่าน implicit reward r^=βlog(πθ/πref)\hat r = \beta\log(\pi_\theta/\pi_{\text{ref}})
  • gradient ถ่วงน้ำหนักด้วยความผิดของตัวเอง คู่ที่จัดอันดับถูกแล้วแทบไม่มี gradient
  • β\beta คือปุ่มแลกเปลี่ยน ระหว่างตามใจ preference กับรักษาความสามารถเดิม
  • LR ต่ำมาก (5e-6) เพราะเรากำลังเอียง distribution ไม่ได้สอนความรู้ใหม่
  • reward ทั้งสองฝั่งไหลลงพร้อมกันเป็นเรื่องปกติ ดู margin อย่าดูระดับสัมบูรณ์
  • วัดความยาวคำตอบเสมอ เพราะ length bias ปลอมตัวเป็นคุณภาพได้เนียนมาก
ข้อจำกัดของการทดลองนี้

DPO เป็น offline อย่างเคร่งครัด มันเรียนจากคู่คำตอบที่มีอยู่แล้วในไฟล์เท่านั้น สิ่งที่มันทำได้คือ จัดลำดับพฤติกรรมที่โมเดลสุ่มออกมาได้อยู่แล้วใหม่ มันไม่มีทางค้นพบวิธีตอบที่โมเดลฐานไม่เคยผลิตออกมาเลย เพราะไม่มีใครเคยเอาวิธีนั้นมาใส่ในคอลัมน์ chosen

ช่องว่างตรงนี้แหละคือเหตุผลที่บทที่ 5 (GRPO) ต้องมีอยู่ — เมื่อโมเดลต้องสุ่มคำตอบของตัวเองมาเรียน ไม่ใช่แค่จัดอันดับสิ่งที่มีคนเตรียมไว้ให้

และอีกข้อ: 500 คู่คือการสาธิตกลไก ไม่ใช่การ align จริง งาน alignment ระดับใช้งานจริงใช้คู่ preference ระดับหมื่นถึงแสนคู่ ต่างกันหลาย order of magnitude สิ่งที่คุณได้จากบทนี้คือความเข้าใจว่าสมการทำงานอย่างไรและปุ่มไหนทำอะไร ซึ่งโอนไปใช้กับสเกลจริงได้ แต่อย่าเอาผลนี้ไปอ้างว่าได้โมเดลไทยที่ดีกว่าเดิม

บทต่อไป: GRPO — เมื่อการจัดอันดับของที่มีอยู่ไม่พออีกต่อไป เราจะให้โมเดลสุ่มคำตอบหลาย ๆ อันของตัวเองมาเปรียบเทียบกันเอง โดยไม่ต้องมี value function แบบ PPO

อ้างอิง (References)

  1. Rafailov et al. (2023). Direct Preference Optimization: Your Language Model is Secretly a Reward Model — DPO ต้นฉบับ -- ที่มาของการอนุมานทั้งหัวข้อ 3
  2. Bradley & Terry (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons — โมเดล Bradley-Terry ที่ reward model ทั้งหมดตั้งอยู่บนนั้น
  3. Azar et al. (2023). A General Theoretical Paradigm to Understand Learning from Human Preferences — IPO: ชี้จุดอ่อนของ DPO เรื่อง overfitting กับ preference
  4. Ethayarajh et al. (2024). KTO: Model Alignment as Prospect Theoretic Optimization — KTO: ทางเลือกที่ไม่ต้องมีคู่ chosen/rejected
  5. Park et al. (2024). Disentangling Length from Quality in Direct Preference Optimization — length bias ของ DPO ที่หัวข้อ 9 เตือนไว้
  6. Tang et al. (2024). Understanding the performance gap between online and offline alignment algorithms — ทำไม offline (DPO) ถึงตามหลัง online (PPO/GRPO)
  7. Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT: ต้นแบบของ pipeline SFT -> RM -> PPO ทั้งหมด

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