minijev-snake / code /minijev /training /supervised.py
markuz89's picture
MiniJEV v2 model export
026f63e verified
Raw History Blame Contribute Delete
23.3 kB
"""Supervised / distillation training loop.
Resumable (last.pt holds model, optimizer, scheduler, scaler, data position and
RNG), stops cleanly on SIGINT/SIGTERM or a time budget, and ends with
temperature calibration on the validation split and a test-set report.
"""
from __future__ import annotations
import math
import os
import random
import signal
import sys
import time
from pathlib import Path
import numpy as np
import torch
from minijev.calibration.metrics import (IsotonicConfidence, calibration_report, fit_temperature,
reliability_svg, softmax)
from minijev.datasets.batching import BackboneTable, BatchEncoder, EpochBatches, model_inputs, to_torch
from minijev.datasets.decision_data import DecisionData
from minijev.device import RuntimeProfile, autocast, resolve_runtime
from minijev.model import build_model, human_params
from minijev.runs import RunLogger, environment_info, new_run_dir
from minijev.tokenizers import make_tokenizer
from minijev.tasks import get_task
from .losses import decision_loss
def seed_everything(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
def task_specs(cfg: dict) -> list[dict]:
"""Single task (`task:` + `data:`) or multi-task (`tasks: [{key, task, data, weight}]`)."""
if cfg.get("tasks"):
sampling = cfg.get("sampling") or {}
out = []
for t in cfg["tasks"]:
t = dict(t)
t.setdefault("key", t["task"]["name"])
t["weight"] = float(sampling.get(t["key"], t.get("weight", 1.0)))
out.append(t)
return out
return [{"key": cfg["task"]["name"], "task": cfg["task"], "data": cfg["data"], "weight": 1.0}]
def make_task_for(cfg: dict, task_cfg: dict):
"""Task plugin with the model's text tokenizer attached (byte or pretrained)."""
task = get_task(task_cfg["name"], task_cfg)
task.tokenizer = make_tokenizer(cfg["model"].get("text_encoder"))
return task
def load_splits(cfg: dict) -> dict[str, DecisionData]:
dcfg = cfg["data"]
d = Path(dcfg["dir"])
if not (d / "train.npz").exists():
gen = dcfg.get("generate")
if not gen:
raise FileNotFoundError(f"{d}/train.npz missing and no data.generate section")
import importlib
mod = importlib.import_module(dcfg.get("generator") or f"environments.{cfg['task']['name']}.dataset")
gen = dict(gen)
n = gen.pop("samples")
print(f"[data] generating {n:,} samples into {d} ...")
mod.generate_splits(d, n, **gen)
splits = {k: DecisionData.load(d / f"{k}.npz") for k in ("train", "val", "test")}
lim = dcfg.get("train_limit")
if lim and lim < len(splits["train"]):
splits["train"] = splits["train"].subset(np.arange(lim))
return splits
def make_model(cfg: dict, task=None):
"""task: one task, a list of tasks (multi-task: one state encoder per modality), or None (from cfg)."""
tasks = task if isinstance(task, (list, tuple)) else [task] if task is not None else \
[make_task_for(cfg, t["task"]) for t in task_specs(cfg)]
encs, classes = {}, {}
for t in tasks:
encs.setdefault(t.state_encoder, t.encoder_config())
if t.encoder_class():
classes[t.state_encoder] = t.encoder_class()
return build_model(cfg["model"], encs, classes or None)
def param_groups(model, wd: float, lr: float | None = None, backbone_lr: float | None = None):
"""AdamW groups: no decay on norms/biases/embeddings; a pretrained backbone can use its own (lower) lr."""
groups = {}
for n, p in model.named_parameters():
if not p.requires_grad:
continue
nd = p.ndim < 2 or "embed" in n or n.endswith((".pos", ".row", ".col", "cls", "type_emb"))
bb = backbone_lr is not None and n.startswith("backbone.")
groups.setdefault((nd, bb), []).append(p)
out = []
for (nd, bb), ps in groups.items():
g = {"params": ps, "weight_decay": 0.0 if nd else wd}
if bb:
g["lr"] = backbone_lr
elif lr is not None:
g["lr"] = lr
out.append(g)
return out
def lr_lambda(warmup: int, total: int, min_ratio: float):
def f(step):
if step < warmup:
return (step + 1) / max(1, warmup)
prog = min(1.0, (step - warmup) / max(1, total - warmup))
return min_ratio + (1 - min_ratio) * 0.5 * (1 + math.cos(math.pi * prog))
return f
@torch.no_grad()
def predict_logits(model, encoder: BatchEncoder, device, batch_size: int = 1024, max_rows: int | None = None,
profile: RuntimeProfile | None = None) -> tuple[np.ndarray, np.ndarray]:
model.eval()
n = len(encoder.data) if max_rows is None else min(max_rows, len(encoder.data))
outs, tg = [], []
for i in range(0, n, batch_size):
b = to_torch(encoder(np.arange(i, min(n, i + batch_size))), device)
with autocast(profile) if profile else torch.autocast("cpu", enabled=False):
out = model(**model_inputs(b))
outs.append(out["logits"].float().cpu().numpy())
tg.append(b["target"].cpu().numpy())
return np.concatenate(outs), np.concatenate(tg)
def _acceptable(encoder: BatchEncoder, n: int):
"""Rows' acceptable choices (soft label > 0), if the dataset has soft labels."""
soft = encoder.data.soft
return None if soft is None else soft[:n] > 0
class _Stop:
def __init__(self):
self.flag = False
self._prev = {}
def __enter__(self):
for s in (signal.SIGINT, signal.SIGTERM):
try:
self._prev[s] = signal.signal(s, self._handle)
except ValueError: # not in main thread
pass
return self
def _handle(self, *_):
if self.flag:
raise KeyboardInterrupt
print("\n[train] stop requested - saving checkpoint (Ctrl-C again to abort)")
self.flag = True
def __exit__(self, *a):
for s, h in self._prev.items():
signal.signal(s, h)
class MultiTaskBatches:
"""Batches for multi-task training: each batch comes from ONE task (its own state
encoder / choice set), picked with the configured sampling weights. Deterministic
in (seed, epoch, i), so resume skips exactly the consumed batches."""
def __init__(self, encoders: dict, weights: dict, batch_size: int, n_batches: int, seed: int, epoch: int,
start_batch: int = 0):
self.keys = list(encoders)
w = np.array([weights[k] for k in self.keys], float)
self.p = w / w.sum()
self.encoders, self.bs, self.seed, self.epoch = encoders, batch_size, seed, epoch
self.range = range(start_batch, n_batches)
def __len__(self):
return len(self.range)
def __getitem__(self, i):
rng = np.random.default_rng((self.seed, self.epoch, self.range[i], 11))
enc = self.encoders[self.keys[rng.choice(len(self.keys), p=self.p)]]
n = len(enc.data)
return enc(rng.choice(n, self.bs, replace=n < self.bs), rng)
def make_encoders(cfg: dict, model, task, split: DecisionData, choice_dropout: float = 0.0, seed: int = 0):
"""BatchEncoder for one split; with a frozen pretrained backbone, questions and
choice labels are encoded once per vocabulary entry (BackboneTable)."""
mcfg = cfg["model"]
kw = dict(max_question_len=mcfg.get("max_question_len", 32), max_choice_len=mcfg.get("max_choice_len", 16))
tables = {}
bb = getattr(model, "backbone", None)
if bb is not None and bb.frozen and cfg["training"].get("cache_text", True):
tok = task.tokenizer
tables = {"question_table": BackboneTable(split.questions, model, tok, kw["max_question_len"]),
"choice_table": BackboneTable([task.model_label(c) for c in split.choices], model, tok,
kw["max_choice_len"])}
return BatchEncoder(split, task, choice_dropout=choice_dropout, seed=seed, tokenizer=task.tokenizer,
**kw, **tables)
def train(cfg: dict, run_dir: str | Path | None = None, resume: bool = False, max_steps: int | None = None,
max_minutes: float | None = None, quiet: bool = False, final_eval: bool = True) -> dict:
tcfg = cfg["training"]
seed = int(cfg.get("seed", 0))
seed_everything(seed)
specs = task_specs(cfg)
tasks = {s["key"]: make_task_for(cfg, s["task"]) for s in specs}
splits = {s["key"]: load_splits({**cfg, "task": s["task"], "data": s["data"]}) for s in specs}
weights = {s["key"]: s["weight"] for s in specs}
multi = len(specs) > 1
model = make_model(cfg, list(tasks.values()))
if tcfg.get("init_checkpoint") and not resume:
ck = torch.load(tcfg["init_checkpoint"], map_location="cpu", weights_only=False)
model.load_state_dict(ck["model"])
model.log_temperature.zero_() # re-calibrated at the end of this run
print(f"[train] initialised from {tcfg['init_checkpoint']}")
n_params = model.num_parameters()
prof = resolve_runtime(tcfg, n_params)
torch.set_num_threads(prof.threads)
device = torch.device(prof.device)
model.to(device)
run_dir = Path(run_dir) if run_dir else new_run_dir(cfg.get("name", "run"))
ckpt_dir = run_dir / "checkpoints"
log = RunLogger(run_dir, cfg, resume=resume)
encs = {}
for k, sp in splits.items():
encs[k] = {"train": make_encoders(cfg, model, tasks[k], sp["train"], tcfg.get("choice_dropout", 0.0), seed),
"val": make_encoders(cfg, model, tasks[k], sp["val"]),
"test": make_encoders(cfg, model, tasks[k], sp["test"])}
train_enc = {k: e["train"] for k, e in encs.items()}
eff_bs = int(tcfg.get("batch_size", 256))
epochs = float(tcfg.get("epochs", 1))
n_train = sum(len(sp["train"]) for sp in splits.values())
batches_per_epoch = max(1, n_train // prof.micro_batch)
steps_per_epoch = max(1, batches_per_epoch // prof.grad_accum)
total_steps = max(1, int(steps_per_epoch * epochs))
if max_steps:
total_steps = min(total_steps, max_steps)
lr = tcfg.get("lr", 1e-3)
opt = torch.optim.AdamW(param_groups(model, tcfg.get("weight_decay", 0.05), lr, tcfg.get("backbone_lr")), lr=lr,
betas=(0.9, 0.98), fused=prof.device == "cuda")
warmup = int(tcfg.get("warmup_steps", min(500, total_steps // 20)))
sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda(warmup, total_steps, tcfg.get("min_lr_ratio", 0.05)))
scaler = torch.amp.GradScaler(enabled=prof.amp_dtype == "fp16")
fwd = torch.compile(model) if prof.compile else model
step, epoch, batch_pos, best, elapsed_prev = 0, 0, 0, -1.0, 0.0
samples_seen = 0
session_samples = 0
last = ckpt_dir / "last.pt"
if resume and last.exists():
ck = torch.load(last, map_location=device, weights_only=False)
model.load_state_dict(ck["model"])
opt.load_state_dict(ck["optimizer"])
sched.load_state_dict(ck["scheduler"])
scaler.load_state_dict(ck["scaler"])
step, epoch, batch_pos, best = ck["step"], ck["epoch"], ck["batch_pos"], ck["best"]
elapsed_prev, samples_seen = ck.get("elapsed", 0.0), ck.get("samples_seen", 0)
torch.set_rng_state(ck["torch_rng"])
print(f"[train] resumed from {last} at step {step} (epoch {epoch}, batch {batch_pos})")
breakdown = model.param_breakdown()
info = {"task": "+".join(tasks) if multi else next(iter(tasks.values())).name, "tasks": list(tasks),
"params": n_params, "params_human": human_params(n_params), "params_breakdown": breakdown,
"train_rows": n_train, "val_rows": sum(len(sp["val"]) for sp in splits.values()),
"test_rows": sum(len(sp["test"]) for sp in splits.values()),
"effective_batch": eff_bs, "total_steps": total_steps, "runtime": prof.to_dict(),
"env": environment_info()}
log.write_json("env.json", info)
if not quiet:
print(f"[train] {cfg.get('name')} | {human_params(n_params)} params (core+heads "
f"{human_params(breakdown['core_total'])}, text {human_params(breakdown['text'])}, trainable "
f"{human_params(breakdown['trainable'])}) | {prof.device} ({prof.device_name}) "
f"amp={prof.amp_dtype} micro={prof.micro_batch}x{prof.grad_accum} | "
f"{n_train:,} train rows ({', '.join(tasks)}) | {total_steps:,} steps")
def save(path, extra=None):
tmp = Path(str(path) + ".tmp")
torch.save({"model": model.state_dict(), "optimizer": opt.state_dict(), "scheduler": sched.state_dict(),
"scaler": scaler.state_dict(), "step": step, "epoch": epoch, "batch_pos": batch_pos,
"best": best, "elapsed": elapsed_prev + (time.time() - t_start), "samples_seen": samples_seen,
"torch_rng": torch.get_rng_state(), "config": cfg, **(extra or {})}, tmp)
os.replace(tmp, path)
def validate():
reps = {}
for k, e in encs.items():
lg, tg = predict_logits(model, e["val"], device, max_rows=eval_rows, profile=prof)
reps[k] = calibration_report(lg, tg, acceptable=_acceptable(e["val"], len(tg)))
w = np.array([weights[k] for k in reps])
acc = float(np.dot(w / w.sum(), [r["accuracy"] for r in reps.values()]))
return acc, reps
loss_cfg = cfg.get("loss", {})
eval_every = int(tcfg.get("eval_every", 1000))
ckpt_every = int(tcfg.get("checkpoint_every", 1000))
log_every = int(tcfg.get("log_every", 50))
eval_rows = tcfg.get("eval_max_rows", 20000)
grad_clip = tcfg.get("grad_clip", 1.0)
t_start = time.time()
t_train = 0.0
run_loss, run_n, window_samples, window_t = 0.0, 0, 0, time.time()
stopped_early = False
with _Stop() as stop:
while step < total_steps and not stop.flag:
if multi:
ds = MultiTaskBatches(train_enc, weights, prof.micro_batch, batches_per_epoch, seed, epoch, batch_pos)
else:
ds = EpochBatches(next(iter(train_enc.values())), prof.micro_batch, shuffle=True, seed=seed,
epoch=epoch, start_batch=batch_pos, drop_last=True)
loader = torch.utils.data.DataLoader(ds, batch_size=None, shuffle=False, num_workers=prof.num_workers,
pin_memory=prof.device == "cuda",
prefetch_factor=4 if prof.num_workers else None,
# fork shares the in-memory dataset with workers instead of pickling it
multiprocessing_context="fork" if prof.num_workers and sys.platform == "linux" else None)
model.train()
micro = 0
for batch in loader:
t0 = time.time()
b = to_torch(batch, device, non_blocking=True)
with autocast(prof):
out = fwd(**model_inputs(b))
loss, terms = decision_loss(out["logits"], out["choice_mask"], b, loss_cfg, out.get("score"))
scaler.scale(loss / prof.grad_accum).backward()
micro += 1
batch_pos += 1
n_b = len(batch["target"])
samples_seen += n_b
session_samples += n_b
window_samples += n_b
run_loss += float(loss.detach())
run_n += 1
if micro % prof.grad_accum:
t_train += time.time() - t0
continue
if grad_clip:
scaler.unscale_(opt)
torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
scaler.step(opt)
scaler.update()
opt.zero_grad(set_to_none=True)
sched.step()
step += 1
t_train += time.time() - t0
if step % log_every == 0:
now = time.time()
sps = window_samples / max(1e-9, now - window_t)
log.log(step, train_loss=run_loss / run_n, lr=sched.get_last_lr()[0],
samples_per_sec=round(sps, 1), epoch=epoch, **{f"loss_{k}": v for k, v in terms.items()})
if not quiet:
print(f" step {step:6d}/{total_steps} loss {run_loss / run_n:.4f} "
f"lr {sched.get_last_lr()[0]:.2e} {sps:,.0f} samples/s")
run_loss, run_n, window_samples, window_t = 0.0, 0, 0, now
if step % eval_every == 0 or step == total_steps:
acc, reps = validate()
first = next(iter(reps.values()))
extra = {"val_acceptable_acc": first["acceptable_accuracy"]} \
if not multi and "acceptable_accuracy" in first else {}
per_task = {f"val_acc_{k}": r["accuracy"] for k, r in reps.items()} if multi else {}
log.log(step, val_acc=acc, val_nll=first["nll"], val_ece=first["ece"],
val_brier=first["brier"], **extra, **per_task)
if not quiet:
detail = " ".join(f"{k} {r['accuracy']:.4f}" for k, r in reps.items()) if multi else \
f"nll {first['nll']:.4f} ece {first['ece']:.4f}"
print(f" [val] step {step} acc {acc:.4f} {detail}"
+ (f" acceptable {first['acceptable_accuracy']:.4f}" if extra else ""))
if acc > best:
best = acc
torch.save({"model": model.state_dict(), "config": cfg, "step": step,
"val_accuracy": best}, ckpt_dir / "best.pt")
model.train()
if step % ckpt_every == 0:
save(last)
if max_minutes and (time.time() - t_start) / 60 > max_minutes:
print(f"[train] time budget of {max_minutes} min reached")
stopped_early = True
stop.flag = True
if step >= total_steps or stop.flag:
break
else:
epoch += 1
batch_pos = 0
continue
if batch_pos >= batches_per_epoch:
epoch, batch_pos = epoch + 1, 0
save(last)
interrupted = stop.flag and not stopped_early and step < total_steps
train_seconds = elapsed_prev + (time.time() - t_start)
summary = {**{k: v for k, v in info.items() if k != "env"}, "steps_done": step, "samples_seen": samples_seen,
"train_seconds": round(train_seconds, 1),
"throughput_samples_per_sec": round(session_samples / max(t_train, 1e-9), 1),
"best_val_accuracy": best, "complete": step >= total_steps, "run_dir": str(run_dir)}
hourly = cfg.get("cost", {}).get("gpu_hourly_cost") or os.environ.get("MINIJEV_GPU_HOURLY_COST")
if hourly:
summary["cost"] = {"gpu_hourly_cost": float(hourly),
"estimated_cost": round(float(hourly) * train_seconds / 3600, 4)}
if final_eval and not interrupted:
summary.update(finalize(cfg, model, run_dir, {k: e["val"] for k, e in encs.items()},
{k: e["test"] for k, e in encs.items()}, device, prof))
log.write_json("summary.json", summary)
log.close()
return summary
def _pad_logits(parts: list[np.ndarray]) -> np.ndarray:
n = max(p.shape[1] for p in parts)
return np.concatenate([np.pad(p, ((0, 0), (0, n - p.shape[1])), constant_values=-1e9) for p in parts])
def finalize(cfg, model, run_dir: Path, val_encs, test_encs, device, prof) -> dict:
"""Best checkpoint -> temperature fitted on val (global + per task) -> calibrated test report -> final.pt."""
from minijev.calibration.metrics import ece as ece_fn
if not isinstance(val_encs, dict): # single-encoder call (old signature)
val_encs, test_encs = {"task": val_encs}, {"task": test_encs}
ckpt_dir = run_dir / "checkpoints"
best = ckpt_dir / "best.pt"
if best.exists():
model.load_state_dict(torch.load(best, map_location=device, weights_only=False)["model"])
cal = cfg.get("calibration", {})
per_task, V, VT, TL, TT = {}, [], [], [], []
for k in val_encs:
vl, vt = predict_logits(model, val_encs[k], device, profile=prof)
tl, tt = predict_logits(model, test_encs[k], device, profile=prof)
Tk = fit_temperature(vl, vt) if cal.get("method", "temperature") == "temperature" else 1.0
acc_ok = _acceptable(test_encs[k], len(tt))
per_task[k] = {"temperature": Tk, "test_raw": calibration_report(tl, tt, 1.0, acceptable=acc_ok),
"test_calibrated": calibration_report(tl, tt, Tk, acceptable=acc_ok)}
V.append(vl), VT.append(vt), TL.append(tl), TT.append(tt)
val_lg, val_tg, test_lg, test_tg = _pad_logits(V), np.concatenate(VT), _pad_logits(TL), np.concatenate(TT)
T = fit_temperature(val_lg, val_tg) if cal.get("method", "temperature") == "temperature" else 1.0
model.log_temperature.fill_(math.log(T))
single = len(val_encs) == 1
acc_ok = _acceptable(next(iter(test_encs.values())), len(test_tg)) if single else None
raw = calibration_report(test_lg, test_tg, 1.0, acceptable=acc_ok)
calibrated = calibration_report(test_lg, test_tg, T, acceptable=acc_ok)
# isotonic comparison (fit on val confidence, evaluate on test)
pv = softmax(val_lg, T)
iso = IsotonicConfidence().fit(pv.max(1), (pv.argmax(1) == val_tg).astype(float))
pt = softmax(test_lg, T)
iso_ece = ece_fn(iso(pt.max(1)), (pt.argmax(1) == test_tg).astype(float))
(run_dir / "reliability_raw.svg").write_text(reliability_svg(raw["reliability"], "Test - raw"))
(run_dir / "reliability_calibrated.svg").write_text(
reliability_svg(calibrated["reliability"], f"Test - temperature {T:.3f}"))
task_T = {k: v["temperature"] for k, v in per_task.items()}
torch.save({"model": model.state_dict(), "config": cfg, "temperature": T, "task_temperatures": task_T},
ckpt_dir / "final.pt")
print(f"[calibration] T={T:.3f} | test acc {calibrated['accuracy']:.4f} | ECE {raw['ece']:.4f} -> "
f"{calibrated['ece']:.4f} | NLL {raw['nll']:.4f} -> {calibrated['nll']:.4f} | "
f"Brier {calibrated['brier']:.4f} | isotonic ECE {iso_ece:.4f}"
+ (f" | acceptable acc {calibrated['acceptable_accuracy']:.4f}" if acc_ok is not None else ""))
if not single:
for k, v in per_task.items():
c = v["test_calibrated"]
print(f" [{k}] T={v['temperature']:.3f} acc {c['accuracy']:.4f} ECE {v['test_raw']['ece']:.4f} -> "
f"{c['ece']:.4f} NLL {c['nll']:.4f}")
return {"temperature": T, "task_temperatures": task_T, "test_raw": raw, "test_calibrated": calibrated,
"test_isotonic_ece": iso_ece, "per_task": per_task, "final_checkpoint": str(ckpt_dir / "final.pt")}