File size: 23,311 Bytes
026f63e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
"""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")}