initial upload

This commit is contained in:
sam-paech
2025-10-17 03:44:24 +11:00
parent 49cff43c50
commit b480cb72f7
33 changed files with 4007542 additions and 0 deletions

332
core/ftpo_trainer.py Normal file
View File

@@ -0,0 +1,332 @@
from core.finetuning import DPOTrainer, torch, pad_sequence, F
# ---------------------------------------------------------------
# Add Adaptive Gradient Clipping to *every* parameter in-place
# ---------------------------------------------------------------
def attach_agc(model, clip: float = 0.01, eps: float = 1e-3):
"""
Registers a per-parameter hook that applies Brock et al.s
Adaptive Gradient Clipping:
||g||₂ > clip * (||θ||₂ + eps) → g ← g * (threshold / ||g||₂)
Works with params in fp32, bf16, or bitsandbytes int4.
"""
def _agc_hook(grad, param):
#print('agc hook')
if grad is None:
return grad
param_norm = param.detach().norm() # ||θ||
grad_norm = grad.norm() # ||g||
max_norm = clip * (param_norm + eps)
if grad_norm > max_norm:
grad = grad * (max_norm / (grad_norm + 1e-6))
return grad
for p in model.parameters():
if p.requires_grad:
p.register_hook(lambda g, p=p: _agc_hook(g, p))
# ---------------------------------------------------------------------
# Early-stopping on any logged scalar (loss, chosen_win, etc.)
# ---------------------------------------------------------------------
from transformers.trainer_callback import TrainerCallback
class ThresholdStop(TrainerCallback):
"""
Stop training immediately when `monitor` crosses `threshold`.
If `higher_is_better` is True → stop when metric >= threshold.
If False → stop when metric <= threshold.
"""
def __init__(self, monitor: str, threshold: float, higher_is_better: bool):
self.monitor = monitor
self.threshold = threshold
self.higher_is_better = higher_is_better
def on_log(self, args, state, control, logs=None, **kwargs):
if logs is None or self.monitor not in logs:
return
value = logs[self.monitor]
stop = (value >= self.threshold) if self.higher_is_better else (value <= self.threshold)
if stop:
control.should_training_stop = True
print(f"[ThresholdStop] {self.monitor}={value:.4f} "
f"crossed {'' if self.higher_is_better else ''} "
f"{self.threshold} stopping.")
class EarlyStoppingByMetric(TrainerCallback):
"""
Stop training when a monitored metric has stopped improving.
Args
----
monitor: str
Key that appears in the `logs` dict (e.g. "loss", "chosen_win").
higher_is_better: bool
True → metric should increase (e.g. chosen_win)
False → metric should decrease (e.g. loss / pref_loss)
patience: int
How many *log events* with no improvement to wait before stopping.
min_delta: float
Minimum change that counts as an improvement.
"""
def __init__(self,
monitor: str,
higher_is_better: bool,
patience: int = 10,
min_delta: float = 0.0):
self.monitor = monitor
self.higher_is_better = higher_is_better
self.patience = patience
self.min_delta = min_delta
self.best = None
self.counter = 0 # events since last improv.
# ── invoked every time trainer logs metrics ─────────────────────
def on_log(self, args, state, control, logs=None, **kwargs):
if logs is None or self.monitor not in logs:
return
current = logs[self.monitor]
# first observation
if self.best is None:
self.best = current
return
# compute signed improvement
if self.higher_is_better:
improvement = current - self.best
else:
improvement = self.best - current
# has the metric improved “enough”?
if improvement > self.min_delta:
self.best = current
self.counter = 0
else:
self.counter += 1
if self.counter >= self.patience:
# signal the Trainer to halt
control.should_training_stop = True
print(f"[EarlyStopping] '{self.monitor}' plateaued "
f"(best={self.best:.5f}) stopping training.")
class FTPOTrainer(DPOTrainer):
"""
Trainer for final token preference optimisation (ftpo).
Replaces TRLs standard loss with a log-ratio on the **last**
autoregressive position.
"""
# ──────────────────────────────────────────────────────────────
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.remove_unused_columns = False
self.data_collator = self.ftpo_collator # override
# ──────────────────────────────────────────────────────────────
@staticmethod
def _get_proj(model):
"""
Return the output-projection module in a model-agnostic way.
Falls back to `lm_head` if `get_output_embeddings()` is None.
"""
proj = model.get_output_embeddings()
if proj is None:
proj = getattr(model, "lm_head", None)
if proj is None:
raise AttributeError(
"Model lacks both get_output_embeddings() and lm_head."
)
return proj
# ──────────────────────────────────────────────────────────────
def ftpo_collator(self, features):
"""
Left-pads every prompt to `self.args.max_length`, so the last real
token is always at position -1. That lets the loss read logits
with a single slice ([:, -1, :]).
"""
pad_id = self.padding_value
max_len = self.args.max_length
batch_sz = len(features)
# ── build [B, L] prompt tensor ───────────────────────────────
prompt_ids = torch.full((batch_sz, max_len), pad_id, dtype=torch.long)
attention_ms = torch.zeros_like(prompt_ids, dtype=torch.bool)
for i, feat in enumerate(features):
seq = torch.tensor(feat["prompt_ids"], dtype=torch.long)
if seq.size(0) > max_len:
seq = seq[-max_len:] # truncate left if over-long
prompt_ids[i, -seq.size(0):] = seq # left-pad
attention_ms[i, -seq.size(0):] = True
# ── universal fields ─────────────────────────────────────────
batch = dict(
prompt_ids = prompt_ids,
attention_mask = attention_ms,
rejected_token_id = torch.tensor([f["rejected_token_id"] for f in features]),
)
# ── ftpo vs single-token branch ────────────────────────
max_c = max(len(f["chosen_ids"]) for f in features)
chosen_pad = torch.full((batch_sz, max_c), pad_id, dtype=torch.long)
chosen_mask = torch.zeros_like(chosen_pad, dtype=torch.bool)
for i, f in enumerate(features):
ids = torch.tensor(f["chosen_ids"], dtype=torch.long)
chosen_pad [i, :ids.size(0)] = ids
chosen_mask[i, :ids.size(0)] = True
batch.update(chosen_ids = chosen_pad,
chosen_mask = chosen_mask)
return batch
def compute_loss(self, model, inputs, return_outputs=False, **_):
# We use 2 separate MSE loss terms (aggregate removed):
# 1. A lightly applied tokenwise MSE loss applied to only the target tokens
lambda_mse_target = getattr(self, "lambda_mse_target", 0.05) # strength
tau_mse_target = getattr(self, "tau_mse_target", 1.0) # grace region (zero cost movement)
# 2. A strongly applied tokenwise MSE loss applied to the remaining (non-target) vocab
lambda_mse = getattr(self, "lambda_mse", 0.4) # how strongly the remaining vocab (other than chosen/rejected) is tethered to reference via mse loss
# loss contribution is clipped if (chosen - rejected) logits delta is above this
clip_epsilon_logits = getattr(self, "clip_epsilon_logits", 2)
USE_MSE_LOSS=True # tether all the logits other than the ones we are interested in moving to the reference
# ── unpack ---------------------------------------------------------
device = next(model.parameters()).device # works for DP / DDP
ids = inputs["prompt_ids"].to(device) # [B,L]
attn = inputs["attention_mask"].to(device) # [B,L]
B, L = ids.shape
seq_len = attn.sum(1)
pad_off = (L - seq_len).unsqueeze(1)
arange_L = torch.arange(L, device=ids.device).unsqueeze(0)
pos_full = (arange_L - pad_off).clamp(min=0)
pos_full = pos_full.masked_fill(attn == 0, 0)
outputs = model(
ids,
attention_mask=attn,
position_ids=pos_full,
use_cache=False,
return_dict=True,
)
logits_last = outputs.logits[:, -1, :] # [B, V]
logp_all = F.log_softmax(logits_last, dim=-1) # [B, V]
ch_ids = inputs["chosen_ids"].to(device)
ch_mask = inputs["chosen_mask"].to(device)
rejected = inputs["rejected_token_id"].to(device)
logp_bad = logp_all.gather(-1, rejected.unsqueeze(-1)).squeeze(-1)
batch_rows = torch.arange(B, device=logp_all.device).unsqueeze(1)
gathered = logits_last[batch_rows, ch_ids]
logit_bad = logits_last.gather(-1, rejected.unsqueeze(-1))
margin = gathered - logit_bad
weights = torch.clamp((clip_epsilon_logits - margin) / clip_epsilon_logits, 0.0, 1.0) * ch_mask
zero_row = weights.sum(dim=-1, keepdim=True) < 1e-12
weights = torch.where(zero_row, ch_mask.float(), weights)
weights_sum = weights.sum(dim=-1, keepdim=True)
batch_rows = torch.arange(B, device=ids.device).unsqueeze(1)
l_chosen = logits_last[batch_rows, ch_ids]
l_bad = logits_last.gather(-1, rejected.unsqueeze(-1))
delta_tok = l_chosen - l_bad
margin = clip_epsilon_logits
tau = 1.0
gap = margin - delta_tok
per_tok_loss = F.softplus(gap / tau)
pref_loss = (per_tok_loss * weights).sum() / weights_sum.sum()
extra_metrics = {}
if USE_MSE_LOSS:
with torch.no_grad():
if self.ref_model is None:
with self.null_ref_context():
ref_logits_last = model(
ids, attention_mask=attn, position_ids=pos_full,
use_cache=False, return_dict=True,
).logits[:, -1, :]
else:
ref_logits_last = self.ref_model(
ids, attention_mask=attn, position_ids=pos_full,
use_cache=False, return_dict=True,
).logits[:, -1, :]
freeze_mask = torch.ones_like(logits_last, dtype=torch.bool)
rows = torch.arange(B, device=ch_ids.device).unsqueeze(1).expand_as(ch_ids)
freeze_mask[rows[ch_mask], ch_ids[ch_mask]] = False
freeze_mask.scatter_(1, rejected.unsqueeze(-1), False)
diff = logits_last - ref_logits_last
mse_elem_raw = (freeze_mask * diff.pow(2)).sum() / freeze_mask.sum()
tgt_mask = torch.zeros_like(logits_last, dtype=torch.bool)
rows = torch.arange(B, device=ch_ids.device).unsqueeze(1).expand_as(ch_ids)
tgt_mask[rows[ch_mask], ch_ids[ch_mask]] = True
tgt_mask.scatter_(1, rejected.unsqueeze(-1), True)
if lambda_mse_target:
diff_tok = logits_last - ref_logits_last
diff_tok = diff_tok * tgt_mask
excess_tok = torch.clamp(diff_tok.abs() - tau_mse_target, min=0.0)
mse_target_raw = (excess_tok.pow(2)).sum() / tgt_mask.sum()
else:
mse_target_raw = logits_last.new_tensor(0.0)
mse_loss = (
lambda_mse * mse_elem_raw
+ lambda_mse_target * mse_target_raw
)
loss = pref_loss + mse_loss
extra_metrics.update({
"mse_elem" : mse_elem_raw.detach(),
"mse_tgt_tokenwise" : mse_target_raw.detach(),
})
else:
loss = pref_loss
lp_chosen = logp_all.gather(-1, ch_ids)
lp_bad = logp_bad.unsqueeze(-1)
wins_tok = (lp_chosen > lp_bad) & ch_mask
frac_win = wins_tok.float().sum(-1) / ch_mask.sum(-1).clamp(min=1e-8)
chosen_win = frac_win.mean().detach()
metrics = {
"pref_loss": pref_loss.detach(),
"chosen_win": chosen_win,
**extra_metrics,
}
self.store_metrics(metrics, train_eval="train")
if return_outputs:
return loss, metrics
return loss
# ----------------------------------------------------------
def _prepare_dataset(self, dataset, *args, **_):
return dataset