[LLM 4/10] DPO: เมื่อโมเดลภาษากลายเป็น reward model ของตัวเอง
บทที่แล้วเราทำ 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 , คำตอบที่ชอบ (chosen), คำตอบที่ไม่ชอบ (rejected)
RLHF แบบ PPO แก้ปัญหานี้ด้วยการเดินอ้อมสองขั้น:
- เทรน reward model ให้เลียนแบบความชอบของมนุษย์
- ใช้ 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
อ่านเป็นภาษาคน: "ทำคะแนน reward ให้สูงที่สุด แต่ห้ามเดินห่างจากโมเดลตั้งต้นมากเกินไป"
- = policy คือโมเดลที่เรากำลังเทรน
- = reference policy คือโมเดลตั้งต้น (ในบทนี้คือโมเดลหลัง SFT จากบทที่ 2)
- = reward ของคำตอบ ต่อ prompt
- = ความเข้มของสายจูง ยิ่งมากยิ่งดึงกลับหา แรง
พจน์ KL ไม่ใช่ของประดับ ถ้าไม่มีมันโมเดลจะวิ่งหนีไปหาจุดที่ reward สูงแต่ภาษาพัง
3.2 ขั้นที่ 1 — คำตอบในรูปปิด
โจทย์ข้างบนนี้แก้ได้ด้วยมือ (เป็นการหา distribution ที่ minimize KL ต่อ distribution เป้าหมาย) ได้ผลว่า
- คือ partition function ตัวหารที่ทำให้ผลรวมเป็น 1
- สังเกตว่า ขึ้นกับ เท่านั้น ไม่ขึ้นกับ — จำประโยคนี้ไว้ให้ดี เดี๋ยวมันจะกลายเป็นพระเอก
ความหมายเชิงสัญชาตญาณ: policy ที่ดีที่สุดคือ โมเดลเดิม ถ่วงน้ำหนักใหม่ด้วย คำตอบที่ reward สูงถูกขยายความน่าจะเป็น คำตอบที่ reward ต่ำถูกกด แต่ตั้งต้นจากรูปร่างเดิมของ เสมอ
ในทางปฏิบัติเราคำนวณ ไม่ได้ เพราะต้องรวมทุกคำตอบที่เป็นไปได้ทั้งจักรวาล นี่คือเหตุผลที่คนใช้ RL — และเป็นเหตุผลที่ DPO ไม่ต้องใช้
3.3 ขั้นที่ 2 — พลิกสมการหา reward
ใส่ log ทั้งสองข้างแล้วย้ายข้าง จะได้
บรรทัดนี้คือหัวใจ reward ทุกฟังก์ชันเขียนใหม่ได้ในรูปของ policy ที่ optimal กับ policy ตั้งต้น แปลว่าถ้าเรามีโมเดลสองตัว เราคำนวณ reward โดยปริยายของมันได้ทันที โดยไม่ต้องเทรน reward model ใด ๆ
3.4 ขั้นที่ 3 — แทนใน Bradley-Terry แล้ว ตัดหาย
โมเดลมาตรฐานของความชอบคือ Bradley-Terry: โอกาสที่คนจะเลือก เหนือ คือ
โดย คือ sigmoid สังเกตว่าในสมการนี้ reward ปรากฏในรูป ผลต่าง เท่านั้น แทนสมการ 3.3 ลงไป — อยู่ทั้งสองฝั่งเท่ากันเป๊ะเพราะ ตัวเดียวกัน — มันจึงตัดกันหายไป
สิ่งที่คำนวณไม่ได้ () ตัดหายไป เพราะ Bradley-Terry สนใจแค่ผลต่างของ reward ที่เหลือคือ log-probability ของโมเดลสองตัวบนข้อความที่เรามีอยู่แล้ว ซึ่งคำนวณด้วย forward pass ธรรมดา ไม่มีการสุ่มคำตอบ ไม่มี rollout ไม่มี value function — DPO เป็น supervised learning เต็มตัว
3.5 Gradient — ที่ซึ่งสัญชาตญาณอยู่
โดย เรียกว่า implicit reward (reward โดยปริยาย)
อ่านทีละชิ้น:
- วงเล็บขวา = ทิศทาง ดัน log-prob ของ ขึ้น และกด log-prob ของ ลง พร้อมกัน
- = น้ำหนัก คือ "โมเดลจัดอันดับคู่นี้ผิดแค่ไหน"
น้ำหนักตัวนี้คือจุดสอนที่สำคัญที่สุด ถ้าโมเดลจัดอันดับคู่นี้ถูกอยู่แล้ว ( มากกว่า ชัดเจน) ค่า จะเข้าใกล้ศูนย์ และคู่นั้นแทบไม่ส่ง gradient เลย DPO จึงโฟกัสไปที่ความผิดพลาดของตัวเองโดยอัตโนมัติ ไม่ต้องมีใครมาคัดข้อมูลให้
4. เห็นภาพสมการ (Visualize)
Bradley-Terry: จากผลต่าง reward เป็นความน่าจะเป็น
Figure 4.1Bradley-Terry แปลงผลต่างของ reward เป็นความน่าจะเป็นที่มนุษย์จะเลือก chosen — โมเดลไม่เคยต้องรู้ค่า reward สัมบูรณ์ รู้แค่ผลต่างก็พอ
จุดที่ผลต่างเป็นศูนย์ให้ความน่าจะเป็น 0.5 พอดี คือ "โมเดลไม่มีความเห็น" และเพราะกราฟสนใจแค่ผลต่าง การบวกค่าคงที่เข้าไปใน reward ทั้งสองฝั่งจึงไม่เปลี่ยนอะไรเลย นั่นแหละคือเหตุผลเชิงเรขาคณิตที่ ตัดหายได้
Loss และน้ำหนักของ gradient
ให้ แล้ว loss คือ และน้ำหนัก gradient คือ
Figure 4.2ซ้าย: DPO loss เทียบกับ margin — ขวา: น้ำหนักที่คู่นั้นได้รับใน gradient สำหรับ β = 0.1, 0.3, 1.0
แผงขวาคือสิ่งที่ต้องดูให้ดี เมื่อ margin เป็นบวกมาก ๆ น้ำหนักจะลู่เข้าศูนย์ — คู่นั้น "เรียนจบแล้ว" และยิ่ง สูง เส้นยิ่งชัน คือทั้งเรียนเร็วและ "เลิกเรียน" เร็ว ที่ คู่ที่ margin เกิน 4 แทบไม่มี gradient เหลือเลย ส่วนที่ เส้นแบนกว่ามาก โมเดลยังเก็บ gradient จากทุกคู่อยู่เรื่อย ๆ — ช้ากว่า แต่นิ่งกว่า
ควบคุมว่าโมเดลจะเดินห่างจากตั้งต้นได้แค่ไหน
Figure 4.3สมการ π* ∝ π_ref · exp(r/β) บนตัวอย่างของเล่นที่มี 5 คำตอบ — β เล็กบีบทุกอย่างไปที่คำตอบ reward สูงสุด β ใหญ่คืนรูปกลับเป็น π_ref
อ่านภาพนี้จากขวาไปซ้าย: ที่ ความน่าจะเป็นเกือบทั้งหมดยุบไปกองที่ ซึ่ง reward สูงสุด นั่นคือ mode collapse — ได้คะแนนดีแต่ความหลากหลายหายเกลี้ยง ที่ แท่งเกือบทับเส้นประ คือแทบไม่ได้เรียนอะไรเลย ไม่ใช่ hyperparameter ที่ "ปรับให้ loss ต่ำสุด" แต่มันคือ การเลือกจุดแลกเปลี่ยน ระหว่างการทำตามความชอบกับการรักษาตัวตนเดิม
ลองเลื่อนค่าเองแล้วดูว่า loss กับ gradient เปลี่ยนรูปอย่างไร:
Drag anywhere on the plot, or use the Δ slider with the arrow keys.
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() โกหกคุณบน T4torch รุ่นใหม่ตอบ True บน T4 เพราะนับ การจำลอง (emulation) ว่ารองรับด้วย ซึ่งช้ากว่า fp16 มาก
ให้เช็ค compute capability ≥ 8.0 (Ampere ขึ้นไป) แทน — นี่คือบั๊กจริงที่เจอตอนรันโน้ตบุ๊กบน Colab จริง ๆ
reference model ที่ไม่กิน VRAM เพิ่มเลย
DPO ต้องใช้ทั้ง และ ฟังดูเหมือนต้องโหลดสองโมเดล แต่เราต่อยอดจาก 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 ส่วน คือ base ตัวเดิม ที่ปิด adapter ไว้
เรียกผ่าน context manager policy.disable_adapter() ได้เลย ไม่ต้องโหลดอะไรเพิ่ม
ถ้าเราทำ 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):]
คำตอบ 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 เองได้แล้ว ไม่ใช่แค่เรียกใช้เป็น
สาเหตุที่พบบ่อยเรียงตามลำดับ: label_smoothing ไม่เป็นศูนย์, loss_type ไม่ใช่ "sigmoid",
batch ที่ป้อนสองฝั่งไม่ใช่ตัวอย่างเดียวกัน, หรือ padding/masking ไม่ตรงกัน
ทั้งสี่ข้อคือความไม่ตรงกันของนิยาม ไม่ใช่ bug — และการไล่หามันคือบทเรียนที่ดีที่สุดของหัวข้อนี้
SFT ด้วย LoRA ใช้ 2e-4 ได้สบาย แต่ DPO ที่ 2e-4 จะทำให้ policy วิ่งหนี reference
ภายในไม่กี่สิบ step แล้วภาษาจะพังจนอ่านไม่ออก
ใช้ 5e-6 เป็นจุดตั้งต้น เพราะ DPO ไม่ได้กำลังสอนเนื้อหาใหม่
มันแค่ เอียงการกระจายความน่าจะเป็นที่มีอยู่แล้ว ซึ่งใช้แรงน้อยกว่ามาก
รวมเวลาเทรนบน T4 ประมาณ 9 นาที สำหรับ 500 คู่ 2 epoch
8. ผลลัพธ์ (Results)
โน้ตบุ๊กวัด 3 อย่างแล้วเขียนลง results.json:
- Held-out preference accuracy — สัดส่วนคู่ที่ implicit reward ของ chosen มากกว่า rejected พร้อม Wilson 95% CI
- การกระจายของ implicit reward margin () ทั้ง distribution ไม่ใช่แค่ค่าเฉลี่ย
th_ratio— สัดส่วนอักขระไทยในคำตอบที่โมเดลสร้าง คือ metric ประจำซีรีส์นี้ที่ใช้จับอาการไหลไปเป็นภาษาอังกฤษแบบเงียบ ๆ
margin เฉลี่ยที่สวยงามอาจมาจากคู่ไม่กี่คู่ที่ margin สูงลิ่ว ขณะที่คู่ส่วนใหญ่ยังอยู่แถวศูนย์ histogram บอกความจริงข้อนี้ ส่วนตัวเลขเดียวปิดบังมันไว้ และ accuracy ที่ไม่มี CI ก็ยังไม่ใช่ผลการทดลอง เหมือนที่ย้ำไว้ตั้งแต่บทที่ 1
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
สิ่งที่จะทำให้คุณตกใจตอนดู log ครั้งแรก
ระหว่างเทรน คุณจะเห็น rewards/chosen และ rewards/rejected ใน log ของ TRL
และสิ่งที่เกิดขึ้นเกือบทุกครั้งคือ ทั้งสองค่าไหลลงเป็นลบพร้อมกัน ขณะที่ rewards/margins กว้างขึ้นเรื่อย ๆ
จำนิยามไว้: ดังนั้น ติดลบแปลว่า policy ให้ความน่าจะเป็นกับข้อความนั้นน้อยกว่า reference
DPO ไม่ได้ถูกสั่งให้ "ทำให้ chosen น่าจะเป็นมากขึ้น" มันถูกสั่งให้ "ทำให้ช่องว่างกว้างขึ้น" เท่านั้น การกด rejected ลงแรง ๆ แล้วกด chosen ลงเบา ๆ ก็ตอบโจทย์ได้เหมือนกัน และมักเป็นทางที่ง่ายกว่า
สิ่งที่ต้องดูจึงเป็น margin กับ held-out accuracy ไม่ใช่ระดับสัมบูรณ์ของ reward
แต่ถ้า rewards/chosen ดิ่งลงลึกมาก (เช่น ต่ำกว่า −10) นั่นเริ่มเป็นสัญญาณว่าโมเดลกำลังทิ้ง reference — ลด LR หรือเพิ่ม
9. เปรียบเทียบ (Comparison)
| โมเดล | Pref. acc (95% CI) | Mean margin | th_ratio | ความยาวเฉลี่ย | เวลาเทรน |
|---|---|---|---|---|---|
| SFT จากบทที่ 2 (ตั้งต้น) | baseline | 0 | baseline | baseline | — |
| DPO, β = 0.1 | ? | ? | ควรสูงขึ้น | ? | ~9 นาที |
| DPO, β = 0.5 | ? | เล็กกว่า | ? | ? | ~9 นาที |
รูปแบบที่คุณควรจะเห็น: preference accuracy สูงขึ้นชัดเจน, margin เป็นบวกและกว้างขึ้น,
และ th_ratio ขยับขึ้นเพราะ rejected ทั้งชุดถูกสร้างจากคำตอบที่หลุดเป็นอังกฤษ
ถ้าเห็นอย่างอื่น ให้ตีความแบบนี้:
- accuracy ขยับน้อยมาก และ margin เกือบศูนย์ → สูงเกินหรือ LR ต่ำเกิน โมเดลแทบไม่ขยับจาก reference
- accuracy บน train สูงลิ่วแต่ held-out ไม่ขยับ → overfit 500 คู่ ซึ่งน้อยมากจริง ๆ
th_ratioขึ้นแต่คำตอบอ่านแล้วแปลก ๆ → policy หนี reference มากไป ลด LR หรือเพิ่ม
กับดักที่ต้องระวัง
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. เลือก ผิดทาง
ต่ำเกิน → policy วิ่งหนี reference จนภาษาพัง (ดูรูปที่ 4.3 ที่ บนตัวอย่างของเล่น)
สูงเกิน → แทบไม่มีอะไรขยับ เสียเวลาเทรนฟรี
0.1 คือค่าตั้งต้นที่คนใช้กันมากที่สุด และควรเป็นจุดเริ่มของคุณ ไม่ใช่จุดจบ
10. สรุป (Summary)
- DPO ไม่ได้ประมาณ RLHF แต่แก้สมการเดียวกันในรูปปิด — reward model กับ RL loop ตัดกันหายทางพีชคณิต
- หายไปได้เพราะ Bradley-Terry สนใจแค่ผลต่างของ reward นี่คือกุญแจของทั้งบท
- โมเดลภาษาเป็น reward model ของตัวเอง ผ่าน implicit reward
- gradient ถ่วงน้ำหนักด้วยความผิดของตัวเอง คู่ที่จัดอันดับถูกแล้วแทบไม่มี gradient
- คือปุ่มแลกเปลี่ยน ระหว่างตามใจ 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)
- Rafailov et al. (2023). Direct Preference Optimization: Your Language Model is Secretly a Reward Model — DPO ต้นฉบับ -- ที่มาของการอนุมานทั้งหัวข้อ 3
- Bradley & Terry (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons — โมเดล Bradley-Terry ที่ reward model ทั้งหมดตั้งอยู่บนนั้น
- Azar et al. (2023). A General Theoretical Paradigm to Understand Learning from Human Preferences — IPO: ชี้จุดอ่อนของ DPO เรื่อง overfitting กับ preference
- Ethayarajh et al. (2024). KTO: Model Alignment as Prospect Theoretic Optimization — KTO: ทางเลือกที่ไม่ต้องมีคู่ chosen/rejected
- Park et al. (2024). Disentangling Length from Quality in Direct Preference Optimization — length bias ของ DPO ที่หัวข้อ 9 เตือนไว้
- Tang et al. (2024). Understanding the performance gap between online and offline alignment algorithms — ทำไม offline (DPO) ถึงตามหลัง online (PPO/GRPO)
- Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT: ต้นแบบของ pipeline SFT -> RM -> PPO ทั้งหมด
บทความ โค้ด และโน้ตบุ๊กในซีรีส์นี้เผยแพร่ภายใต้สัญญาอนุญาต CC BY-NC-SA 4.0 — นำไปใช้และดัดแปลงต่อได้ โดยอ้างอิงที่มา ไม่ใช้เพื่อการค้า และเผยแพร่ต่อด้วยสัญญาเดียวกัน (โมเดลและชุดข้อมูลของบุคคลที่สามที่อ้างถึง ยังคงใช้สัญญาของเจ้าของเดิม)
