Groot-1 / groot1 /train.py
marcyano's picture
Groot 1 v1.1: Folha correction (87.8% vs Jev 85.8%), S+M ensemble
17a55cc verified
Raw History Blame Contribute Delete
27.7 kB
"""Training for Groot 1: proper-scoring objective with calibration built in.
Objective per question (soft target t over k options, model probabilities p):
L = w_soft * CE(t, p) + w_hard * CE(onehot(argmax t), p) + w_brier * ||p - t||^2
+ w_ord * E_p|i - argmax t| (score questions only: ordinal distance)
+ w_conf * BCE(conf_logit, 1[argmax p == argmax t]) (correctness head)
CE with a soft target and the Brier term are proper scoring rules, so the
model is rewarded for honest probabilities; this is the supervised core of
"reinforcement learning for calibrated decisions". Augmentation permutes
choice options (the model must not learn list positions) and, optionally,
replaces option names by neutral ids.
Usage (single GPU):
groot-train --preset groot-1-s --train data/train.jsonl --val data/val.jsonl --out runs/groot-1-s
Multi-GPU:
torchrun --nproc_per_node 8 -m groot1.train --preset groot-1-s ...
"""
from __future__ import annotations
import argparse
import datetime
import json
import math
import mmap
import os
import random
import shutil
import sys
import time
from pathlib import Path
from typing import Any, Iterator, Sequence
import numpy as np
import torch
import torch.distributed as dist
import torch.nn.functional as F
from torch import nn
from .calibration import Record, evaluate_records, fit_temperatures
from .config import QTYPE_IDS, GrootConfig, preset
from .data import read_jsonl
from .model import Batch, GrootDecisionModel, collate
from .serialize import Serializer
# ------------------------------------------------------------------- data
class RowStore:
"""Memory-light random access to a JSONL file of decision rows.
Only an int64 offset per line is kept in RAM; rows are parsed on demand from
an mmap of the file, so 4 DDP ranks x N loader workers share one page cache
instead of each holding a full Python copy of the dataset. Picklable (the
mmap is reopened lazily), so it works with fork and forkserver workers.
"""
def __init__(self, path: str | Path, limit: int | None = None):
self.path = str(path)
offsets = [0]
with open(self.path, "rb") as f:
for line in f:
offsets.append(offsets[-1] + len(line))
if limit and len(offsets) > limit:
break
starts = np.array(offsets[:-1], dtype=np.int64)
ends = np.array(offsets[1:], dtype=np.int64)
keep = (ends - starts) > 1 # skip blank lines
self.starts, self.ends = starts[keep], ends[keep]
if len(self.starts) == 0:
raise ValueError(f"{path}: no rows")
self._mm = None
def __len__(self) -> int:
return len(self.starts)
def estimate_lengths(self, early_state_tokens: int, chars_per_token: float = 3.2) -> tuple[np.ndarray, np.ndarray]:
"""Approximate (question-sequence tokens, state tokens) per row, from characters. Cached on the store."""
if getattr(self, "_qlen", None) is not None:
return self._qlen, self._slen
q = np.empty(len(self), dtype=np.int32)
st = np.empty(len(self), dtype=np.int32)
for i in range(len(self)):
r = self[i]
opts = r.get("options") or []
qchars = len(r.get("question") or "") + sum(len(o) for o in opts)
schars = max(0, self.length_key(i) - qchars - 200)
stok = int(schars / chars_per_token)
st[i] = stok
q[i] = int(qchars / chars_per_token) + 2 * len(opts) + 8 + min(stok, early_state_tokens)
self._qlen, self._slen = q, st
return q, st
def length_key(self, i: int) -> int:
return int(self.ends[i] - self.starts[i])
def __getitem__(self, i: int) -> dict:
if self._mm is None:
with open(self.path, "rb") as f:
self._mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
return json.loads(self._mm[int(self.starts[i]) : int(self.ends[i])])
def take(self, n: int | None = None) -> list[dict]:
n = len(self) if n is None else min(n, len(self))
return [self[i] for i in range(n)]
def __getstate__(self):
d = dict(self.__dict__)
d["_mm"] = None
return d
def permute_options(row: dict, rng: random.Random) -> dict:
if row["type"] != "choice" or len(row["options"]) < 2:
return row
perm = list(range(len(row["options"])))
rng.shuffle(perm)
return dict(row, options=[row["options"][j] for j in perm], target=[row["target"][j] for j in perm])
def build_batch(rows: Sequence[dict], ser: Serializer, rng: random.Random | None, max_k: int | None = None) -> tuple[Batch, torch.Tensor]:
"""Encode rows; states shared by several rows are encoded once (q_state map)."""
states, q_state, questions, targets = [], [], [], []
key_to_idx: dict[str, int] = {}
for row in rows:
if rng is not None:
row = permute_options(row, rng)
key = row.get("state_key") or str(row["state"])
if key not in key_to_idx:
key_to_idx[key] = len(states)
states.append(ser.encode_state(row["state"]))
q_state.append(key_to_idx[key])
_, q = ser.encode_row(row, states[key_to_idx[key]])
questions.append(q)
targets.append([float(t) for t in row["target"]][: q.n_options])
batch = collate(states, questions, ser.pad, q_state)
K = int(batch.q_count.max())
target = torch.tensor([t + [0.0] * (K - len(t)) for t in targets], dtype=torch.float32)
return batch, target
def epoch_plan(store: RowStore, batch_size: int, world: int, seed: int, epoch: int,
qlen: np.ndarray | None = None, slen: np.ndarray | None = None,
max_tokens: int | None = None, max_attn: float | None = None, chunk_tokens: int = 512, max_chunks: int = 8) -> list[list[int]]:
"""Batches of row indices for one epoch, identical on every rank.
Global shuffle -> sort by length inside mega-chunks -> greedy batches capped by
`batch_size` rows and, when given, by padded token budgets:
rows * Lmax <= max_tokens (question sequences, activations)
rows * Lmax^2 <= max_attn (block-masked attention matrices)
rows * state_tokens <= 4*max_tokens (state chunks)
-> shuffle batches -> truncate to a multiple of `world`, so every rank gets
exactly the same number of steps (no DDP hang at the end of an epoch).
"""
rng = np.random.default_rng([seed, epoch])
idx = rng.permutation(len(store))
key = qlen if qlen is not None else (store.ends - store.starts)
mega = batch_size * 64
batches: list[list[int]] = []
for start in range(0, len(idx), mega):
chunk = idx[start : start + mega]
chunk = chunk[np.argsort(key[chunk], kind="stable")]
cur: list[int] = []
lmax = 0
smax = 0
for j in chunk.tolist():
lj = int(qlen[j]) if qlen is not None else 0
sj = min(int(slen[j]), chunk_tokens * max_chunks) if slen is not None else 0
nl, ns, n = max(lmax, lj), max(smax, sj), len(cur) + 1
over = n > batch_size
if max_tokens and qlen is not None:
over = over or (n * nl > max_tokens) or (n * ns > 4 * max_tokens)
if max_attn and qlen is not None:
over = over or (n * nl * nl > max_attn)
if over and cur:
batches.append(cur)
cur, lmax, smax = [], 0, 0
nl, ns = lj, sj
cur.append(j)
lmax, smax = nl, ns
if cur:
batches.append(cur)
order = rng.permutation(len(batches))
batches = [batches[i] for i in order]
usable = (len(batches) // world) * world
if usable == 0:
raise ValueError(f"not enough rows for {world} ranks")
return batches[:usable]
class EpochBatches(torch.utils.data.Dataset):
"""One item = one fully collated training batch (tokenized in loader workers)."""
def __init__(self, store: RowStore, batches: list[list[int]], ser: Serializer, seed: int, epoch: int, permute: bool, start: int = 0):
self.store, self.batches, self.ser = store, batches, ser
self.seed, self.epoch, self.permute, self.start = seed, epoch, permute, start
def __len__(self) -> int:
return len(self.batches) - self.start
def __getitem__(self, i: int):
j = i + self.start
rows = [self.store[k] for k in self.batches[j]]
rng = random.Random(f"{self.seed}:{self.epoch}:{j}") if self.permute else None
return build_batch(rows, self.ser, rng)
def _first(items):
return items[0]
def make_loader(ds: EpochBatches, workers: int) -> torch.utils.data.DataLoader:
kw: dict[str, Any] = dict(batch_size=1, shuffle=False, collate_fn=_first, num_workers=workers)
if workers > 0:
kw.update(prefetch_factor=4, persistent_workers=False)
if sys.platform.startswith("linux"):
kw["multiprocessing_context"] = "fork"
return torch.utils.data.DataLoader(ds, **kw)
# ------------------------------------------------------------------- loss
def decision_loss(
logits: torch.Tensor,
target: torch.Tensor,
marker_mask: torch.Tensor,
qtype: torch.Tensor,
conf_logit: torch.Tensor,
weights: dict[str, float],
) -> tuple[torch.Tensor, dict[str, float]]:
logp = logits.masked_fill(~marker_mask, -1e4).log_softmax(-1)
p = logp.exp()
soft_ce = -(target * logp).sum(-1)
hard = target.argmax(-1)
hard_ce = -logp.gather(1, hard[:, None]).squeeze(1)
brier = ((p - target) ** 2 * marker_mask).sum(-1)
idx = torch.arange(logits.shape[1], device=logits.device)[None, :].float()
ordinal = (p * (idx - hard[:, None].float()).abs()).sum(-1) * (qtype == QTYPE_IDS["score"]).float()
correct = (logits.argmax(-1) == hard).float()
conf_bce = F.binary_cross_entropy_with_logits(conf_logit, correct)
loss = (
weights["soft"] * soft_ce.mean()
+ weights["hard"] * hard_ce.mean()
+ weights["brier"] * brier.mean()
+ weights["ord"] * ordinal.mean()
+ weights["conf"] * conf_bce
)
parts = {
"loss": float(loss.detach()),
"soft_ce": float(soft_ce.detach().mean()),
"brier": float(brier.detach().mean()),
"acc": float(correct.mean()),
"conf_bce": float(conf_bce.detach()),
}
return loss, parts
# ---------------------------------------------------------------- evaluate
@torch.no_grad()
def evaluate(model: GrootDecisionModel, rows: list[dict], ser: Serializer, device, batch_size: int = 16, max_rows: int | None = None) -> tuple[list[Record], dict]:
model.eval()
records: list[Record] = []
subset = rows[:max_rows] if max_rows else rows
for start in range(0, len(subset), batch_size):
chunk = subset[start : start + batch_size]
batch, target = build_batch(chunk, ser, None)
batch = batch.to(device)
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
out = model(batch)
logits = out["logits"].float().cpu()
conf = torch.sigmoid(out["confidence_logit"].float()).cpu()
for i, row in enumerate(chunk):
k = len(row["options"])
records.append(Record(logits=logits[i, :k].numpy(), target=np.array(row["target"][:k], dtype=np.float64), qtype=QTYPE_IDS[row["type"]], correct_conf=float(conf[i])))
report = evaluate_records(records).to_dict()
model.train()
return records, report
# ------------------------------------------------------------------- main
def setup_distributed() -> tuple[int, int, torch.device]:
if "WORLD_SIZE" in os.environ and int(os.environ["WORLD_SIZE"]) > 1:
cuda = torch.cuda.is_available()
dist.init_process_group("nccl" if cuda else "gloo", timeout=datetime.timedelta(minutes=90))
rank, world = dist.get_rank(), dist.get_world_size()
if cuda:
local = int(os.environ.get("LOCAL_RANK", rank)) % torch.cuda.device_count()
torch.cuda.set_device(local)
return rank, world, torch.device("cuda", local)
return rank, world, torch.device("cpu")
if torch.cuda.is_available():
return 0, 1, torch.device("cuda")
if torch.backends.mps.is_available():
return 0, 1, torch.device("mps")
return 0, 1, torch.device("cpu")
def barrier(world: int) -> None:
if world > 1:
dist.barrier()
# ---------------------------------------------------------- checkpointing
def save_checkpoint(out: Path, model: GrootDecisionModel, opt: torch.optim.Optimizer, tok, state: dict) -> None:
"""Atomic resumable checkpoint at out/ckpt (weights + optimizer + trainer state)."""
tmp, final = out / "ckpt.tmp", out / "ckpt"
if tmp.exists():
shutil.rmtree(tmp)
model.save_pretrained(tmp, tokenizer=tok)
torch.save(opt.state_dict(), tmp / "optimizer.pt")
(tmp / "trainer_state.json").write_text(json.dumps(state, indent=2) + "\n")
if final.exists():
shutil.rmtree(final)
tmp.rename(final)
def load_checkpoint(ckpt: Path, model: GrootDecisionModel, opt: torch.optim.Optimizer, device) -> dict:
from safetensors.torch import load_file
model.load_state_dict(load_file(str(ckpt / "model.safetensors")), strict=True)
opt.load_state_dict(torch.load(ckpt / "optimizer.pt", map_location=device, weights_only=True))
return json.loads((ckpt / "trainer_state.json").read_text())
def lr_at(step: int, total: int, warmup: int, peak: float, floor: float = 0.1) -> float:
if step < warmup:
return peak * step / max(1, warmup)
progress = (step - warmup) / max(1, total - warmup)
return peak * (floor + (1 - floor) * 0.5 * (1 + math.cos(math.pi * min(1.0, progress))))
def main(argv: list[str] | None = None) -> None:
ap = argparse.ArgumentParser(prog="groot-train")
ap.add_argument("--preset", default="groot-1-s")
ap.add_argument("--backbone", default=None, help="override backbone HF id or local path")
ap.add_argument("--init", default=None, help="resume/initialise from a Groot checkpoint directory")
ap.add_argument("--train", required=True)
ap.add_argument("--val", default=None)
ap.add_argument("--out", required=True)
ap.add_argument("--epochs", type=float, default=2.0)
ap.add_argument("--batch-size", type=int, default=16)
ap.add_argument("--grad-accum", type=int, default=1)
ap.add_argument("--lr", type=float, default=5e-5)
ap.add_argument("--head-lr", type=float, default=2e-4)
ap.add_argument("--weight-decay", type=float, default=0.01)
ap.add_argument("--warmup", type=int, default=500)
ap.add_argument("--clip", type=float, default=1.0)
ap.add_argument("--freeze-encoder", action="store_true")
ap.add_argument("--grad-checkpoint", action="store_true")
ap.add_argument("--w-soft", type=float, default=1.0)
ap.add_argument("--w-hard", type=float, default=0.3)
ap.add_argument("--w-brier", type=float, default=0.3)
ap.add_argument("--w-ord", type=float, default=0.1)
ap.add_argument("--w-conf", type=float, default=0.2)
ap.add_argument("--no-permute", action="store_true")
ap.add_argument("--eval-every", type=int, default=1000)
ap.add_argument("--eval-rows", type=int, default=2000)
ap.add_argument("--save-every", type=int, default=2000)
ap.add_argument("--log-every", type=int, default=50)
ap.add_argument("--limit", type=int, default=None, help="use only the first N training rows (debug)")
ap.add_argument("--tiny", action="store_true", help="random tiny encoder (tests)")
ap.add_argument("--seed", type=int, default=17)
ap.add_argument("--workers", type=int, default=0, help="loader workers per rank (tokenization off the training thread)")
ap.add_argument("--resume", action="store_true", help="continue from <out>/ckpt if it exists")
ap.add_argument("--ckpt-minutes", type=float, default=15.0, help="resumable checkpoint interval")
ap.add_argument("--final-eval-rows", type=int, default=20000)
ap.add_argument("--max-steps", type=int, default=None, help="stop after N optimizer steps (smoke tests)")
ap.add_argument("--find-unused", action="store_true", help="DDP find_unused_parameters")
ap.add_argument("--max-tokens", type=int, default=None, help="padded question tokens per batch (rows * Lmax)")
ap.add_argument("--max-attn", type=float, default=None, help="padded attention budget per batch (rows * Lmax^2)")
ap.add_argument("--max-state-chunks", type=int, default=8, help="truncate states to N chunks during training")
ap.add_argument("--save-every-eval", action="store_true", help="also save out/step_N at every evaluation")
ap.add_argument("--spike-factor", type=float, default=1.8, help="rollback when fast loss EMA > factor * best slow EMA")
ap.add_argument("--max-rollbacks", type=int, default=3)
ap.add_argument("--chunk-pos", choices=["learned", "sinusoidal"], default=None, help="override the chunk-position scheme")
ap.add_argument("--set", action="append", default=[], help="config override key=value (e.g. state_retrieval=true)")
args = ap.parse_args(argv)
rank, world, device = setup_distributed()
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
torch.manual_seed(args.seed)
out = Path(args.out)
if rank == 0:
out.mkdir(parents=True, exist_ok=True)
barrier(world)
from transformers import AutoTokenizer
cfg = preset(args.preset, **({"backbone": args.backbone} if args.backbone else {}))
if args.init:
model = GrootDecisionModel.from_pretrained(args.init, device="cpu")
tok = AutoTokenizer.from_pretrained(Path(args.init) / "tokenizer")
cfg = model.cfg
elif args.tiny:
from huggingface_hub import snapshot_download
tok = AutoTokenizer.from_pretrained(snapshot_download(cfg.backbone, allow_patterns=["tokenizer*", "special_tokens_map.json"]))
torch.manual_seed(args.seed)
model = GrootDecisionModel.tiny(cfg, vocab_size=len(tok), layers=2)
else:
tok = AutoTokenizer.from_pretrained(cfg.backbone)
model = GrootDecisionModel.from_backbone(cfg)
if args.chunk_pos:
model.cfg = GrootConfig.from_dict({**model.cfg.to_dict(), "chunk_pos": args.chunk_pos})
cfg = model.cfg
if args.set:
from .config import parse_overrides
model.cfg = GrootConfig.from_dict({**model.cfg.to_dict(), **parse_overrides(args.set)})
cfg = model.cfg
model.sync_chunk_pos_trainable()
model.to(device)
if args.grad_checkpoint and hasattr(model.encoder, "gradient_checkpointing_enable"):
model.encoder.gradient_checkpointing_enable()
if args.freeze_encoder:
for p in model.encoder.parameters():
p.requires_grad_(False)
train_cfg = GrootConfig.from_dict({**cfg.to_dict(), "max_chunks": min(cfg.max_chunks, args.max_state_chunks)})
ser = Serializer(tok, train_cfg)
store = RowStore(args.train, args.limit)
qlen = slen = None
if args.max_tokens or args.max_attn:
t_est = time.time()
qlen, slen = store.estimate_lengths(cfg.early_state_tokens)
if rank == 0:
print(json.dumps({"length_estimate_s": round(time.time() - t_est, 1), "qlen_p50": int(np.median(qlen)),
"qlen_p99": int(np.percentile(qlen, 99)), "qlen_max": int(qlen.max())}), flush=True)
plan_kw = dict(qlen=qlen, slen=slen, max_tokens=args.max_tokens, max_attn=args.max_attn,
chunk_tokens=cfg.chunk_tokens, max_chunks=args.max_state_chunks)
val_rows = RowStore(args.val).take(max(args.eval_rows, args.final_eval_rows)) if args.val else []
if rank == 0:
print(json.dumps({"params": model.count_parameters(), "train_rows": len(store), "val_rows": len(val_rows),
"device": str(device), "world": world, "workers": args.workers}), flush=True)
enc_params = [p for n, p in model.named_parameters() if n.startswith("encoder.") and p.requires_grad]
head_params = [p for n, p in model.named_parameters() if not n.startswith("encoder.") and p.requires_grad]
groups = [{"params": enc_params, "lr": args.lr}, {"params": head_params, "lr": args.head_lr}]
opt = torch.optim.AdamW(groups, weight_decay=args.weight_decay, betas=(0.9, 0.98))
state = {"step": 0, "epoch": 0, "batch_pos": 0, "best": -1.0, "lr_scale": 1.0, "rollbacks": 0}
ckpt = out / "ckpt"
if args.resume and ckpt.exists():
state = load_checkpoint(ckpt, model, opt, device)
if rank == 0:
print(json.dumps({"resumed": state}), flush=True)
ddp = model
if world > 1:
ddp = nn.parallel.DistributedDataParallel(model, device_ids=[device.index] if device.type == "cuda" else None,
find_unused_parameters=args.find_unused)
batches_per_rank = len(epoch_plan(store, args.batch_size, world, args.seed, 0, **plan_kw)) // world
steps_per_epoch = max(1, batches_per_rank // args.grad_accum)
total_steps = max(1, int(steps_per_epoch * args.epochs))
if args.max_steps:
total_steps = min(total_steps, args.max_steps)
weights = {"soft": args.w_soft, "hard": args.w_hard, "brier": args.w_brier, "ord": args.w_ord, "conf": args.w_conf}
log_path = out / "train_log.jsonl"
def log(rec: dict) -> None:
print(json.dumps(rec), flush=True)
with log_path.open("a") as f:
f.write(json.dumps(rec) + "\n")
step, epoch, best = state["step"], state["epoch"], state["best"]
lr_scale, rollbacks = state.get("lr_scale", 1.0), state.get("rollbacks", 0)
ema_fast = ema_slow = None
best_slow = float("inf")
last_gn = 0.0
start_pos = state["batch_pos"]
t0 = time.time()
last_ckpt = time.time()
win_tokens, win_rows, win_t = 0, 0, time.time()
model.train()
done = step >= total_steps
while not done:
plan = epoch_plan(store, args.batch_size, world, args.seed, epoch, **plan_kw)[rank::world]
loader = make_loader(EpochBatches(store, plan, ser, args.seed + rank, epoch, not args.no_permute, start_pos), args.workers)
pos = start_pos
micro = 0
for batch, target in loader:
pos += 1
batch, target = batch.to(device), target.to(device)
win_tokens += int(batch.chunk_mask.sum()) + int(batch.q_mask.sum())
win_rows += int(target.shape[0])
with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
outp = ddp(batch)
K = int(batch.q_count.max())
marker_mask = torch.arange(K, device=device)[None, :] < batch.q_count[:, None]
loss, parts = decision_loss(outp["logits"].float(), target, marker_mask, batch.q_type, outp["confidence_logit"].float(), weights)
(loss / args.grad_accum).backward()
micro += 1
if micro % args.grad_accum:
continue
step += 1
for g, base in zip(opt.param_groups, (args.lr, args.head_lr)):
g["lr"] = lr_at(step, total_steps, args.warmup, base) * lr_scale
last_gn = float(torch.nn.utils.clip_grad_norm_(model.parameters(), args.clip))
opt.step()
opt.zero_grad(set_to_none=True)
# ---- divergence guard (decision identical on every rank)
lv = torch.tensor([parts["loss"]], device=device)
if world > 1:
dist.all_reduce(lv)
lv /= world
lval = float(lv)
ema_fast = lval if ema_fast is None else 0.9 * ema_fast + 0.1 * lval
ema_slow = lval if ema_slow is None else 0.99 * ema_slow + 0.01 * lval
if step > args.warmup + 200:
best_slow = min(best_slow, ema_slow)
diverged = not math.isfinite(lval) or (step > args.warmup + 300 and ema_fast > args.spike_factor * best_slow)
if diverged and rollbacks < args.max_rollbacks and ckpt.exists():
barrier(world)
saved = load_checkpoint(ckpt, model, opt, device)
rollbacks += 1
lr_scale *= 0.5
ema_fast = ema_slow = None
if rank == 0:
log({"step": step, "event": "rollback", "to_step": saved["step"], "lr_scale": lr_scale, "rollbacks": rollbacks,
"loss_fast": None if not math.isfinite(lval) else round(lval, 4), "best_slow": round(best_slow, 4)})
barrier(world)
continue
if rank == 0 and step % args.log_every == 0:
dt = max(1e-6, time.time() - win_t)
rec = {"step": step, "epoch": epoch, "lr": opt.param_groups[0]["lr"], "elapsed": round(time.time() - t0, 1), "grad_norm": round(last_gn, 3),
"tok_s": round(win_tokens * world / dt), "rows_s": round(win_rows * world / dt, 1), **parts}
if device.type == "cuda":
rec["mem_gb"] = round(torch.cuda.max_memory_allocated(device) / 2**30, 2)
log(rec)
win_tokens, win_rows, win_t = 0, 0, time.time()
if val_rows and step % args.eval_every == 0:
if rank == 0:
_, report = evaluate(model, val_rows, ser, device, args.batch_size, args.eval_rows)
log({"step": step, "val": report})
if report["accuracy"].get("all", -1) > best:
best = report["accuracy"]["all"]
model.save_pretrained(out / "best", tokenizer=tok)
if args.save_every_eval: # keep every evaluated checkpoint for external model selection
model.save_pretrained(out / f"step_{step}", tokenizer=tok)
barrier(world)
want_ckpt = step % args.save_every == 0 or (time.time() - last_ckpt) > args.ckpt_minutes * 60
if world > 1:
flag = torch.tensor([1 if want_ckpt else 0], device=device)
dist.broadcast(flag, 0)
want_ckpt = bool(flag.item())
if want_ckpt:
if rank == 0:
save_checkpoint(out, model, opt, tok, {"step": step, "epoch": epoch, "batch_pos": pos, "best": best,
"lr_scale": lr_scale, "rollbacks": rollbacks})
last_ckpt = time.time()
barrier(world)
if step >= total_steps:
done = True
break
start_pos = 0
if not done:
epoch += 1
barrier(world)
if rank == 0:
if val_rows:
records, report = evaluate(model, val_rows, ser, device, args.batch_size, args.final_eval_rows)
temps = fit_temperatures(records, n_types=len(cfg.qtypes))
model.temperature.copy_(torch.tensor(temps, dtype=model.temperature.dtype, device=model.temperature.device))
calibrated = evaluate_records(records, temps).to_dict()
log({"final_val": report, "calibrated": calibrated, "temperatures": temps})
(out / "eval.json").write_text(json.dumps({"final_val": report, "calibrated": calibrated}, indent=2))
model.save_pretrained(out / "final", tokenizer=tok)
print(f"saved {out / 'final'}", flush=True)
if world > 1:
dist.barrier()
dist.destroy_process_group()
if __name__ == "__main__":
main()