PEFT
Safetensors
echoforest's picture
Upload naver-psy classifier (adapter, heads, calibration)
210f9af verified
Raw History Blame Contribute Delete
21.4 kB
"""Train the heads classifier on an export bundle. Run on the pod as ``python -m training.train``.
Classifier-plan §3.2-3.5, §4.3-4.4. Steps of ``--posts-per-step`` posts (32) in length buckets, split into
token-budget micro-batches (16k padded tokens) with per-step loss normalisation; masked targets; LoRA lr 2e-4,
heads lr 1e-3, AdamW wd 0.01, clip 1.0, cosine with 3% warmup; val every ``--eval-every`` steps (200, or four times
per epoch if that is fewer); ``best/`` = lowest mean val log-loss over all fields. The arm of the §4.4 screen is
``--checkpoint base|it`` (the base checkpoint dir is ``--model``) times ``--pool last|mean``. Per-option class
weights come from the bundle's taxonomy (nsb-v2: pnl breakeven 3.0) unless ``--class-weights`` overrides them; the
resolved weights are recorded in config.json.
Writes OUT/config.json, OUT/taxonomy.json, OUT/train_log.jsonl, OUT/last/ (resume point every eval: adapter,
heads.safetensors, optimizer, scheduler, RNG, data cursor, cumulative hours), OUT/best/, OUT/predictions/val.jsonl
and OUT/metrics_val.json for the best checkpoint. ``--max-hours`` is cumulative across resumes (state.json).
Exit code 3: stopped by --max-hours, --stop-after or SIGINT (``timeout``) at a saved resume point; rerun with
--resume. Exit code 4: non-finite loss or gradient (nothing saved; last/ keeps the previous good point).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import platform
import random
import time
from pathlib import Path
from .calibrate import nll, outputs
from .data import inference_batches, load_split, micro_batches, step_counts, steps, subset, write_jsonl
from .formats import FORMAT, encode
from .taxonomy import Taxonomy
# Settings a resume must not change.
CORE = ("data", "model", "checkpoint", "pool", "subset", "epochs", "max_steps", "posts_per_step", "token_budget",
"max_len", "seed", "lr_lora", "lr_heads", "warmup", "weight_decay", "lora_r", "lora_alpha", "lora_dropout",
"head_dropout", "dtype", "eval_every", "val_limit", "class_weights")
def parse(argv=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--data", required=True, help="Bundle directory (train/val.jsonl, taxonomy.json, manifest.json)")
p.add_argument("--model", required=True, help="Local base checkpoint directory")
p.add_argument("--out", required=True)
p.add_argument("--arm", default="", help="Arm name for the record (B-last, I-last, B-mean, I-mean)")
p.add_argument("--checkpoint", default="base", choices=["base", "it"], help="Which Gemma 4 checkpoint --model is")
p.add_argument("--pool", default="last", choices=["last", "mean"])
p.add_argument("--subset", type=int, default=0, help="Train on this many rows in seeded order (0 = all)")
p.add_argument("--epochs", type=float, default=2.0)
p.add_argument("--max-steps", type=int, default=0, help="Cap the schedule at this many optimiser steps")
p.add_argument("--posts-per-step", type=int, default=32)
p.add_argument("--token-budget", type=int, default=16384, help="Padded tokens per training micro-batch")
p.add_argument("--eval-token-budget", type=int, default=65536)
p.add_argument("--max-len", type=int, default=2048)
p.add_argument("--lr-lora", type=float, default=2e-4)
p.add_argument("--lr-heads", type=float, default=1e-3)
p.add_argument("--warmup", type=float, default=0.03)
p.add_argument("--weight-decay", type=float, default=0.01)
p.add_argument("--lora-r", type=int, default=32)
p.add_argument("--lora-alpha", type=int, default=32)
p.add_argument("--lora-dropout", type=float, default=0.05)
p.add_argument("--head-dropout", type=float, default=0.1)
p.add_argument("--class-weights", default=None,
help='Per-option loss weights as JSON, e.g. \'{"pnl": {"breakeven": 3.0}}\'; "{}" = none '
"(default: the bundle taxonomy's class_weights)")
p.add_argument("--eval-every", type=int, default=200, help="Optimiser steps between val evaluations and saves")
p.add_argument("--val-limit", type=int, default=0, help="Evaluate on the first N val rows in seeded order")
p.add_argument("--log-every", type=int, default=10)
p.add_argument("--max-hours", type=float, default=0, help="Cumulative training hours across resumes")
p.add_argument("--stop-after", type=int, default=0, help="Stop this process after N more steps (resume test)")
p.add_argument("--force-longest", type=int, default=0, help="First run one forward/backward on the N longest rows")
p.add_argument("--seed", type=int, default=20261017)
p.add_argument("--resume", action="store_true", help="Continue from last/ if it exists (settings must match)")
p.add_argument("--device", default="cuda")
p.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float32"])
p.add_argument("--no-gradient-checkpointing", action="store_true")
return p.parse_args(argv)
def versions() -> dict:
out = dict(python=platform.python_version())
for pkg in ("torch", "transformers", "peft", "accelerate", "safetensors", "tokenizers", "huggingface_hub"):
try:
out[pkg] = getattr(__import__(pkg), "__version__", "unknown")
except Exception:
out[pkg] = None
try:
import torch
out["cuda"] = torch.version.cuda
if torch.cuda.is_available():
out["gpu"] = torch.cuda.get_device_name(0)
except Exception:
pass
commit = Path(__file__).with_name(".source_commit")
out["source_commit"] = commit.read_text().strip() if commit.exists() else None
return out
def file_sha256(path: Path) -> str | None:
if not path.exists():
return None
return hashlib.sha256(path.read_bytes()).hexdigest()
class Log:
def __init__(self, path: Path):
self.fh = open(path, "a", encoding="utf-8")
def __call__(self, **row):
row = dict(time=round(time.time(), 1), **row)
line = json.dumps(row, ensure_ascii=False)
self.fh.write(line + "\n")
self.fh.flush()
print(line, flush=True)
def prediction_rows(rows, logits_by_index, infos, split=None) -> list[dict]:
"""Prediction rows hold ids and numbers only (never text)."""
return [{"id": r["id"], "split": split or r.get("split"), "n_tokens": infos[i]["n_tokens"],
"truncated": infos[i]["cut"], "logits": logits_by_index[i]} for i, r in enumerate(rows)]
def val_report(preds: list[dict], rows: list[dict], tax: Taxonomy) -> dict:
"""Mean val log-loss over fields (selection), per-field log-loss and gated accuracy (uncalibrated)."""
pairs = list(zip(preds, rows))
per_field = {f: nll(pairs, tax, f, 1.0) for f in tax.names}
scored = [v for v in per_field.values() if v is not None]
acc = {}
labels = [outputs(p["logits"], tax)["labels"] for p in preds]
for f in tax.names:
gold = [(lab[f], r["targets"].get(f)) for lab, r in zip(labels, rows)
if f not in r.get("mask", ()) and r["targets"].get(f) is not None]
acc[f] = sum(1 for a, g in gold if a == g) / len(gold) if gold else None
eval_pairs = [(p, r) for p, r in pairs if r.get("eval")]
screen = [nll(eval_pairs, tax, f, 1.0) for f in ("relevance", "direction", "commercial")]
screen = [v for v in screen if v is not None]
return {"selection_loss": sum(scored) / len(scored) if scored else float("inf"), "log_loss": per_field,
"accuracy": acc, "rows": len(rows), "eval_rows": len(eval_pairs),
"screen_primary": sum(screen) / len(screen) if screen else None}
def resolve_class_weights(tax: Taxonomy, override: str | None) -> Taxonomy:
"""The taxonomy with ``--class-weights`` applied (JSON object; None keeps the bundle's weights)."""
if override is None:
return tax
value = json.loads(override)
if not isinstance(value, dict):
raise SystemExit("--class-weights must be a JSON object {field: {option: weight}}")
return tax.replace(class_weights=value)
def schedule_eval_every(eval_every: int, steps_per_epoch: int) -> int:
"""200 steps, or four times per epoch if that is fewer steps (classifier-plan §4.3)."""
return max(1, min(eval_every, math.ceil(steps_per_epoch / 4)))
def main(argv=None) -> int:
args = parse(argv)
import torch
from .model import HUB_IDS, bos_id, build, load_tokenizer, pad, predict, rng_state, save_checkpoint, \
set_rng_state, step_loss, tokenizer_fn
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
cfg_path = out / "config.json"
tax = resolve_class_weights(Taxonomy.load(args.data), args.class_weights)
cfg = {k: v for k, v in vars(args).items() if k not in ("resume", "device", "stop_after", "force_longest")}
cfg["gradient_checkpointing"] = not args.no_gradient_checkpointing
cfg["class_weights"] = tax.class_weights or None # resolved; None (not {}) keeps older unweighted runs resumable
if cfg_path.exists():
if not args.resume:
raise SystemExit(f"refusing to start a new run over {out}; use --resume or a new --out")
old = json.loads(cfg_path.read_text())
changed = sorted(k for k in CORE if old.get(k) != cfg.get(k))
if changed:
raise SystemExit(f"--resume with different settings: {changed}")
if old.get("max_hours") != cfg.get("max_hours"):
raise SystemExit("--resume with a different --max-hours; the cap is cumulative")
manifest_path = Path(args.data) / "manifest.json"
manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
cfg.update(versions=versions(), format=FORMAT, base_model=HUB_IDS[args.checkpoint],
data_version=manifest.get("version"), data_manifest_sha256=file_sha256(manifest_path),
taxonomy_version=manifest.get("taxonomy_version"), render_version=manifest.get("render_version"))
cfg_path.write_text(json.dumps(cfg, indent=1))
log = Log(out / "train_log.jsonl")
random.seed(args.seed)
torch.manual_seed(args.seed)
device = torch.device(args.device)
(out / "taxonomy.json").write_text(json.dumps(tax.to_json(), ensure_ascii=False, indent=1))
tok = load_tokenizer(args.model)
tokenize, bos = tokenizer_fn(tok), bos_id(tok)
train_rows = subset(load_split(args.data, "train"), args.subset, args.seed)
val_rows = subset(load_split(args.data, "val"), args.val_limit, args.seed)
t0 = time.time()
enc_train = [encode(r["segments"], tokenize, args.max_len, bos) for r in train_rows]
enc_val = [encode(r["segments"], tokenize, args.max_len, bos) for r in val_rows]
ids = [e[0] for e in enc_train]
lengths = [len(x) for x in ids]
labels = [tax.encode(r["targets"], r.get("mask", ())) for r in train_rows]
val_ids = [e[0] for e in enc_val]
val_infos = [e[1] for e in enc_val]
val_batches = inference_batches([len(x) for x in val_ids], args.eval_token_budget)
log(event="data", train=len(train_rows), val=len(val_rows), tokens=sum(lengths), max_len=max(lengths),
mean_len=round(sum(lengths) / len(lengths), 1), truncated_rows=sum(1 for e in enc_train if e[1]["cut"]),
val_tokens=sum(len(x) for x in val_ids), seconds=round(time.time() - t0, 1))
last_dir, best_dir = out / "last", out / "best"
resume = args.resume and (last_dir / "state.json").exists()
model = build(cfg, tax.sizes(), device, train=True)
if resume:
from peft import set_peft_model_state_dict
from safetensors.torch import load_file
from .model import load_heads
set_peft_model_state_dict(model.backbone, load_file(str(last_dir / "adapter" / "adapter_model.safetensors")))
load_heads(model, last_dir / "heads.safetensors")
lora = [p for n, p in model.named_parameters() if p.requires_grad and "lora_" in n]
heads = [p for n, p in model.named_parameters() if p.requires_grad and "lora_" not in n]
opt = torch.optim.AdamW([dict(params=lora, lr=args.lr_lora), dict(params=heads, lr=args.lr_heads)],
weight_decay=args.weight_decay)
steps_per_epoch = math.ceil(len(train_rows) / args.posts_per_step)
total = max(1, int(steps_per_epoch * args.epochs))
if args.max_steps:
total = min(total, args.max_steps)
eval_every = schedule_eval_every(args.eval_every, steps_per_epoch)
from transformers import get_cosine_schedule_with_warmup
sched = get_cosine_schedule_with_warmup(opt, int(args.warmup * total), total)
state = dict(step=0, epoch=0, step_in_epoch=0, best=None, best_step=None, hours_used=0.0)
if resume:
state = json.loads((last_dir / "state.json").read_text())
trainer = torch.load(last_dir / "trainer.pt", map_location="cpu", weights_only=False)
opt.load_state_dict(trainer["opt"])
sched.load_state_dict(trainer["sched"])
set_rng_state(trainer["rng"])
log(event="resume", **state)
log(event="start", trainable=sum(p.numel() for p in lora + heads), steps=total, steps_per_epoch=steps_per_epoch,
eval_every=eval_every, arm=args.arm, checkpoint=args.checkpoint, pool=args.pool)
amp = dict(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda")
process_start = time.time()
hours_before = state["hours_used"]
weights = tax.weights()
class_weights = [None if v is None else torch.tensor(v, dtype=torch.float32, device=device)
for v in tax.class_weight_vectors()]
def hours_now():
return hours_before + (time.time() - process_start) / 3600
def save_last():
state["hours_used"] = round(hours_now(), 4)
save_checkpoint(model, last_dir, tax.names, dict(state),
dict(opt=opt.state_dict(), sched=sched.state_dict(), rng=rng_state()))
def evaluate():
t = time.time()
rng = rng_state() # evaluation must not move the training RNG (dropout) stream
logits = predict(model, val_ids, tok.pad_token_id, val_batches, device, tax.names)
set_rng_state(rng)
preds = prediction_rows(val_rows, logits, val_infos, "val")
rep = val_report(preds, val_rows, tax)
seconds = time.time() - t
log(event="eval", step=state["step"], selection_loss=round(rep["selection_loss"], 5),
screen_primary=rep["screen_primary"],
log_loss={f: (round(v, 4) if v is not None else None) for f, v in rep["log_loss"].items()},
accuracy={f: (round(v, 4) if v is not None else None) for f, v in rep["accuracy"].items()},
seconds=round(seconds, 1), posts_per_second=round(len(val_rows) / max(seconds, 1e-9), 1))
if state["best"] is None or rep["selection_loss"] < state["best"]:
state["best"], state["best_step"] = rep["selection_loss"], state["step"]
save_checkpoint(model, best_dir, tax.names, dict(state))
write_jsonl(out / "predictions" / "val.jsonl", preds)
(out / "metrics_val.json").write_text(json.dumps(dict(step=state["step"], **rep), indent=1))
if args.force_longest and not resume:
# Worst-case micro-batches for peak memory: the N longest rows of the whole train split (not the subset).
pool = sorted(load_split(args.data, "train"), key=lambda r: -sum(len(x["text"]) for x in r["segments"]))
cand = [(encode(r["segments"], tokenize, args.max_len, bos)[0], tax.encode(r["targets"], r.get("mask", ())))
for r in pool[:4 * args.force_longest]]
cand = sorted(cand, key=lambda c: -len(c[0]))[:args.force_longest]
f_ids, f_labels = [c[0] for c in cand], [c[1] for c in cand]
f_len = [len(x) for x in f_ids]
parts = micro_batches(list(range(len(cand))), f_len, args.token_budget)
for mb in parts:
x, m, last = pad([f_ids[i] for i in mb], tok.pad_token_id, device)
y = torch.tensor([f_labels[i] for i in mb], device=device)
with torch.autocast(**amp):
loss, _ = step_loss(model(x, m, last), y, step_counts(f_labels), weights, class_weights)
loss.backward()
opt.zero_grad(set_to_none=True)
mem = round(torch.cuda.max_memory_allocated() / 2 ** 30, 2) if device.type == "cuda" else None
log(event="forced_longest", rows=len(cand), lengths=f_len, micro_batches=len(parts),
padded_tokens=[max(f_len[i] for i in mb) * len(mb) for mb in parts], max_memory_gb=mem)
if device.type == "cuda":
torch.cuda.reset_peak_memory_stats()
model.train()
try:
stop = run_steps(args, state, total, eval_every, model, opt, sched, lora, heads, ids, lengths, labels, tax,
weights, device, amp, tok, log, hours_now, evaluate, save_last, class_weights)
except KeyboardInterrupt: # timeout sends SIGINT: keep a resume point at the last completed step
log(event="interrupted", step=state["step"])
save_last()
return 3
if stop == 4:
return 4
save_last()
if state["best"] is None:
evaluate()
log(event="done" if stop is None else "stopped", **state)
return stop or 0
def run_steps(args, state, total, eval_every, model, opt, sched, lora, heads, ids, lengths, labels, tax, weights,
device, amp, tok, log, hours_now, evaluate, save_last, class_weights=None):
"""The optimiser loop. Returns None (finished), 3 (cap or planned stop; the caller saves the resume point)
or 4 (non-finite loss or gradient: nothing is saved, so last/ keeps the previous good resume point)."""
import torch
from .model import pad, step_loss
def fresh_window():
return dict(loss=0.0, tokens=0, padded=0, t=time.time(), field_sums=[0.0] * len(tax.names),
field_counts=[0] * len(tax.names), n=0)
window = fresh_window()
stop, done_here = None, 0
while state["step"] < total and stop is None:
epoch_steps = steps(lengths, args.posts_per_step, args.seed * 1000 + state["epoch"])
while state["step_in_epoch"] < len(epoch_steps) and state["step"] < total:
if args.max_hours and hours_now() >= args.max_hours:
stop = 3
break
step = epoch_steps[state["step_in_epoch"]]
step_labels = [labels[i] for i in step]
counts = step_counts(step_labels)
step_total = 0.0
for mb in micro_batches(step, lengths, args.token_budget):
x, m, last = pad([ids[i] for i in mb], tok.pad_token_id, device)
y = torch.tensor([labels[i] for i in mb], device=device)
with torch.autocast(**amp):
loss, per_field = step_loss(model(x, m, last), y, counts, weights, class_weights)
loss.backward()
step_total += float(loss.detach())
window["field_sums"] = [a + b for a, b in zip(window["field_sums"], per_field)]
window["tokens"] += int(m.sum())
window["padded"] += int(m.numel())
window["field_counts"] = [a + sum(1 for r in step_labels if r[f] >= 0)
for f, a in enumerate(window["field_counts"])]
norm = float(torch.nn.utils.clip_grad_norm_(lora + heads, 1.0))
if not (math.isfinite(step_total) and math.isfinite(norm)):
log(event="non_finite", step=state["step"] + 1, loss=step_total, grad_norm=norm)
opt.zero_grad(set_to_none=True)
return 4
opt.step()
sched.step()
opt.zero_grad(set_to_none=True)
state["step"] += 1
state["step_in_epoch"] += 1
done_here += 1
window["loss"] += step_total
window["n"] += 1
if state["step"] % args.log_every == 0 or state["step"] == total:
dt = max(time.time() - window["t"], 1e-9)
row = dict(event="train", step=state["step"], epoch=state["epoch"],
loss=round(window["loss"] / window["n"], 4),
fields={f: round(s / c, 4) if c else None for f, s, c in
zip(tax.names, window["field_sums"], window["field_counts"])},
lr=sched.get_last_lr()[0], tokens_per_second=round(window["tokens"] / dt),
padded_tokens_per_second=round(window["padded"] / dt), hours=round(hours_now(), 3))
if device.type == "cuda":
row["max_memory_gb"] = round(torch.cuda.max_memory_allocated() / 2 ** 30, 1)
log(**row)
window = fresh_window()
if state["step_in_epoch"] >= len(epoch_steps):
state["epoch"] += 1
state["step_in_epoch"] = 0
if state["step"] % eval_every == 0 or state["step"] == total:
evaluate()
save_last()
if args.stop_after and done_here >= args.stop_after:
stop = 3
break
if state["step_in_epoch"] == 0:
break # new epoch: new step order
return stop
if __name__ == "__main__":
raise SystemExit(main())