bbkdevops's picture
download
raw
10.9 kB
"""
DPO Trainer — Direct Preference Optimization
ทำให้โมเดลตอบดีขึ้นโดยการ align ด้วย preference pairs:
- chosen: คำตอบที่ดี (มี CoT reasoning)
- rejected: คำตอบที่แย่กว่า (ไม่มี reasoning / ผิด / สั้นเกิน)
อ้างอิง: "Direct Preference Optimization" (Rafailov et al. 2023)
"""
from __future__ import annotations
import json
import math
import time
from pathlib import Path
from functools import partial
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
from tokenizers import Tokenizer
from model.config import OmegaConfig, small_config
from model.architecture import OmegaModel
# ─── DPO Dataset ──────────────────────────────────────────────────────────────
class DPODataset(Dataset):
"""โหลด DPO pairs จาก JSONL — แต่ละ record มี question, chosen, rejected"""
CHAT_TEMPLATE = (
"<bos><system>{system}</system>\n"
"<user>{question}</user>\n"
"<assistant>{response}<eos>"
)
DEFAULT_SYSTEM = (
"คุณคือ TinyMind ผู้ช่วย AI ที่ฉลาด คิดอย่างเป็นระบบ และตอบถูกต้องเสมอ"
)
def __init__(self, path: str, tokenizer: Tokenizer, max_seq_len: int = 1024):
self.tokenizer = tokenizer
self.max_seq_len = max_seq_len
self.records: list[dict] = []
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
rec = json.loads(line)
if rec.get("chosen") and rec.get("rejected") and rec.get("question"):
self.records.append(rec)
def _encode(self, question: str, response: str, system: str) -> list[int]:
text = self.CHAT_TEMPLATE.format(
system=system, question=question, response=response
)
enc = self.tokenizer.encode(text)
return enc.ids[: self.max_seq_len]
def _prompt_len(self, question: str, system: str) -> int:
prompt = (
f"<bos><system>{system}</system>\n"
f"<user>{question}</user>\n"
f"<assistant>"
)
return len(self.tokenizer.encode(prompt).ids)
def __len__(self) -> int:
return len(self.records)
def __getitem__(self, idx: int) -> dict:
rec = self.records[idx]
q = rec["question"]
system = rec.get("system", self.DEFAULT_SYSTEM)
chosen_ids = self._encode(q, rec["chosen"], system)
rejected_ids = self._encode(q, rec["rejected"], system)
prompt_len = self._prompt_len(q, system)
return {
"chosen_ids": chosen_ids,
"rejected_ids": rejected_ids,
"prompt_len": prompt_len,
}
def dpo_collate(batch: list[dict], pad_id: int = 0) -> dict:
def pad(seqs: list[list[int]]) -> torch.Tensor:
max_len = max(len(s) for s in seqs)
return torch.tensor(
[s + [pad_id] * (max_len - len(s)) for s in seqs], dtype=torch.long
)
return {
"chosen_ids": pad([b["chosen_ids"] for b in batch]),
"rejected_ids": pad([b["rejected_ids"] for b in batch]),
"prompt_len": torch.tensor([b["prompt_len"] for b in batch], dtype=torch.long),
}
# ─── DPO Loss ─────────────────────────────────────────────────────────────────
def compute_log_probs(
model: OmegaModel,
input_ids: torch.Tensor,
prompt_len: torch.Tensor,
) -> torch.Tensor:
"""คำนวณ log prob ของส่วน response เท่านั้น (mask prompt out)"""
out = model(input_ids)
logits = out["logits"] # [B, T, V]
# shift: logits[t] predicts token[t+1]
log_probs = F.log_softmax(logits[:, :-1, :], dim=-1)
targets = input_ids[:, 1:] # [B, T-1]
# gather log prob ของ target tokens
token_log_probs = log_probs.gather(2, targets.unsqueeze(-1)).squeeze(-1) # [B, T-1]
# mask: เก็บเฉพาะ response tokens (หลัง prompt)
B, T = targets.shape
mask = torch.zeros(B, T, device=input_ids.device, dtype=torch.bool)
for i in range(B):
start = min(int(prompt_len[i].item()), T)
mask[i, start:] = True
# ignore pad (id=0)
mask &= targets != 0
token_log_probs = token_log_probs * mask
seq_log_probs = token_log_probs.sum(dim=-1) / mask.sum(dim=-1).clamp(min=1)
return seq_log_probs # [B]
def dpo_loss(
policy_chosen_lp: torch.Tensor,
policy_rejected_lp: torch.Tensor,
ref_chosen_lp: torch.Tensor,
ref_rejected_lp: torch.Tensor,
beta: float = 0.1,
) -> torch.Tensor:
"""DPO loss: -log σ(β * (chosen_ratio - rejected_ratio))"""
chosen_ratio = policy_chosen_lp - ref_chosen_lp
rejected_ratio = policy_rejected_lp - ref_rejected_lp
logits = beta * (chosen_ratio - rejected_ratio)
loss = -F.logsigmoid(logits).mean()
return loss
# ─── DPO Trainer ──────────────────────────────────────────────────────────────
DPO_CFG = {
"data_path": "data/filtered/dpo_pairs.jsonl",
"tokenizer_path": "data/tokenizer/tokenizer.json",
"ref_checkpoint": "checkpoints/omega_best.pt", # frozen reference model
"out_dir": "checkpoints",
"max_seq_len": 1024,
"batch_size": 2,
"grad_accum": 4,
"lr": 5e-6,
"beta": 0.1,
"max_steps": 5_000,
"save_every": 500,
"dtype": "bfloat16",
"grad_clip": 1.0,
}
class DPOTrainer:
def __init__(self, cfg: dict = DPO_CFG, model_cfg: OmegaConfig | None = None):
self.cfg = cfg
self.model_cfg = model_cfg or small_config()
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.dtype = torch.bfloat16 if cfg["dtype"] == "bfloat16" else torch.float16
self.step = 0
def setup(self):
print(f"DPO Trainer | device={self.device} | dtype={self.dtype}")
tok_path = self.cfg["tokenizer_path"]
if not Path(tok_path).exists():
raise FileNotFoundError(f"Tokenizer not found: {tok_path}")
self.tokenizer = Tokenizer.from_file(tok_path)
ds = DPODataset(self.cfg["data_path"], self.tokenizer, self.cfg["max_seq_len"])
collate = partial(dpo_collate, pad_id=self.model_cfg.pad_token_id)
self.loader = DataLoader(
ds, batch_size=self.cfg["batch_size"],
shuffle=True, collate_fn=collate, num_workers=2, pin_memory=True,
)
print(f"DPO dataset: {len(ds):,} pairs")
# Policy model (trainable)
ref_path = self.cfg.get("ref_checkpoint")
if ref_path and Path(ref_path).exists():
ckpt = torch.load(ref_path, map_location=self.device, weights_only=False)
saved_cfg: OmegaConfig = ckpt["model_cfg"]
self.policy = OmegaModel(saved_cfg).to(self.device)
self.policy.load_state_dict(ckpt["model_state"])
self.model_cfg = saved_cfg
else:
self.policy = OmegaModel(self.model_cfg).to(self.device)
# Reference model (frozen copy)
self.ref = OmegaModel(self.model_cfg).to(self.device)
self.ref.load_state_dict(self.policy.state_dict())
for p in self.ref.parameters():
p.requires_grad_(False)
self.ref.eval()
self.policy.enable_grad_checkpointing()
self.optimizer = torch.optim.AdamW(
self.policy.parameters(), lr=self.cfg["lr"],
betas=(0.9, 0.95), eps=1e-8, weight_decay=0.0
)
def train_step(self, batch: dict) -> float:
self.policy.train()
chosen_ids = batch["chosen_ids"].to(self.device)
rejected_ids = batch["rejected_ids"].to(self.device)
prompt_len = batch["prompt_len"].to(self.device)
with torch.amp.autocast(device_type=self.device.type, dtype=self.dtype,
enabled=self.device.type == "cuda"):
policy_chosen_lp = compute_log_probs(self.policy, chosen_ids, prompt_len)
policy_rejected_lp = compute_log_probs(self.policy, rejected_ids, prompt_len)
with torch.no_grad():
ref_chosen_lp = compute_log_probs(self.ref, chosen_ids, prompt_len)
ref_rejected_lp = compute_log_probs(self.ref, rejected_ids, prompt_len)
loss = dpo_loss(
policy_chosen_lp, policy_rejected_lp,
ref_chosen_lp, ref_rejected_lp,
beta=self.cfg["beta"],
) / self.cfg["grad_accum"]
loss.backward()
return loss.item() * self.cfg["grad_accum"]
def save(self, tag: str = "dpo_latest"):
path = Path(self.cfg["out_dir"]) / f"omega_{tag}.pt"
torch.save({
"step": self.step,
"model_state": self.policy.state_dict(),
"model_cfg": self.model_cfg,
}, path)
print(f" Saved → {path}")
def train(self):
self.setup()
data_iter = iter(self.loader)
self.optimizer.zero_grad()
t0 = time.time()
running_loss = 0.0
print(f"DPO training for {self.cfg['max_steps']:,} steps\n")
while self.step < self.cfg["max_steps"]:
for _ in range(self.cfg["grad_accum"]):
try:
batch = next(data_iter)
except StopIteration:
data_iter = iter(self.loader)
batch = next(data_iter)
running_loss += self.train_step(batch)
torch.nn.utils.clip_grad_norm_(self.policy.parameters(), self.cfg["grad_clip"])
self.optimizer.step()
self.optimizer.zero_grad()
self.step += 1
if self.step % 10 == 0:
dt = time.time() - t0
print(f"step {self.step:5d} | dpo_loss {running_loss/10:.4f} | {dt:.1f}s")
running_loss = 0.0
t0 = time.time()
if self.step % self.cfg["save_every"] == 0:
self.save(f"dpo_step{self.step}")
self.save("dpo_final")
print("DPO training complete!")
if __name__ == "__main__":
trainer = DPOTrainer()
trainer.train()

Xet Storage Details

Size:
10.9 kB
·
Xet hash:
7dabf1fa1606d5e128c61446b90fbfa33216a5d55e382034503d950009b5b604

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.