xDecision / src /xdecision /evaluate.py
mccoysc's picture
Upload 43 files
ceaaf21 verified
Raw History Blame Contribute Delete
11 kB
"""Evaluate a checkpoint directory with laya's own PyTorch forward (the code path consumers load),
and fit calibration temperatures.
python -m xdecision.evaluate --ckpt DIR [--calibrate] [--suites typed_decisions,emotion,...]
--calibrate fits one temperature per (question type, option-count bucket) on data/built/calib.pt
by held-out NLL and writes them into DIR/rl_agent_config.json before evaluating.
"""
import argparse
import json
import math
import os
import time
from collections import defaultdict
import numpy as np
import torch
from laya.common import QTYPE_NAMES, clamp_temperature, ece_score, temp_bucket
from .model_io import load_config, load_model, load_tokenizer
from .train import collate
from .devices import pick_device
SUITES = ["typed_decisions", "emotion", "sst5", "prompt_injection", "xcopa", "xwinograd", "belebele", "xstory",
"wd_heldout", "wiki_heldout", "hans_formats", "unseen_workflows", "market_heldout", "uncertainty", "fitness_unseen",
"unseen_workflows_extra"]
@torch.no_grad()
def logits_for(model, items, pad_id, device, max_tokens=16384):
order = sorted(range(len(items)), key=lambda i: len(items[i]["ids"]))
out = [None] * len(items)
act_out = [None] * len(items)
i = 0
while i < len(order):
L = len(items[order[i]]["ids"])
bs = max(1, min(128, max_tokens // max(64, ((L + 63) // 64) * 64)))
idx = order[i:i + bs]
# the longest item in this slice sets the padded length
while len(idx) > 1 and len(items[idx[-1]]["ids"]) * len(idx) > max_tokens * 1.5:
idx = idx[:-1]
b = collate([items[j] for j in idx], pad_id)
b = {k: v.to(device) for k, v in b.items()}
lg, act = model(b["input_ids"], b["attention_mask"], b["marker_pos"], b["marker_mask"], b["qtype"])
lg = lg.float().cpu().numpy()
act = torch.softmax(act.float(), -1)[:, 0].cpu().numpy()
for r, j in enumerate(idx):
out[j] = lg[r, : len(items[j]["markers"])]
act_out[j] = float(act[r])
i += len(idx)
return out, act_out
def temps_from_cfg(cfg):
t = [clamp_temperature(x) for x in cfg.get("temperature", [1.0, 1.0, 1.0])]
tb = {k: clamp_temperature(v) for k, v in (cfg.get("temperature_by_options") or {}).items()}
return t, tb
def probs(z, qtype, t, tb):
T = tb.get(temp_bucket(qtype, len(z)), t[qtype])
z = np.asarray(z, dtype=np.float64) / T
p = np.exp(z - z.max())
return p / p.sum()
def fit_temperature(pairs, lo=0.5, hi=5.0):
"""1-D NLL minimisation over log T (golden section; the NLL is unimodal in log T)."""
def nll(logT):
T = math.exp(logT)
s = 0.0
for z, tgt in pairs:
z = np.asarray(z, dtype=np.float64) / T
lse = z.max() + math.log(np.exp(z - z.max()).sum())
s -= float(np.dot(tgt, z - lse))
return s / len(pairs)
a, b = math.log(lo), math.log(hi)
g = (math.sqrt(5) - 1) / 2
c, d = b - g * (b - a), a + g * (b - a)
fc, fd = nll(c), nll(d)
for _ in range(40):
if fc < fd:
b, d, fd = d, c, fc
c = b - g * (b - a)
fc = nll(c)
else:
a, c, fc = c, d, fd
d = a + g * (b - a)
fd = nll(d)
return math.exp((a + b) / 2)
def calibrate(model, tok, device, calib_items, min_n=40):
lg, _ = logits_for(model, calib_items, tok.pad_token_id, device)
by_bucket, by_type = defaultdict(list), defaultdict(list)
for z, it in zip(lg, calib_items):
pair = (z, np.asarray(it["target"], dtype=np.float64))
by_bucket[temp_bucket(it["qtype"], len(z))].append(pair)
by_type[it["qtype"]].append(pair)
t = [round(fit_temperature(by_type[q]), 4) if len(by_type[q]) >= min_n else 1.0 for q in range(3)]
tb = {k: round(fit_temperature(v), 4) for k, v in sorted(by_bucket.items()) if len(v) >= min_n}
return t, tb, {k: len(v) for k, v in by_bucket.items()}
def auroc(scores, labels):
s, y = np.asarray(scores), np.asarray(labels).astype(bool)
if y.all() or (~y).all():
return float("nan")
ranks = s.argsort().argsort() + 1
return float((ranks[y].sum() - y.sum() * (y.sum() + 1) / 2) / (y.sum() * (~y).sum()))
def score_items(items, lg, act, t, tb):
rows = []
for it, z, a in zip(items, lg, act):
q = it["qtype"]
p = probs(z, q, t, tb)
tgt = np.asarray(it["target"])
gold = int(it.get("label", int(tgt.argmax())))
pred = int(p.argmax())
row = {"src": it["src"], "lang": it.get("lang", "en"), "t": QTYPE_NAMES[q], "correct": pred == gold,
"conf": float(p.max()), "soft": float((p * tgt).sum()), "act": a, "pred": pred, "gold": gold,
"gold_prob": float(p[gold]),
"brier": float(np.sum((p - np.eye(len(p), dtype=np.float64)[gold]) ** 2)),
"determinate": bool(it.get("determinate", bool(tgt.max() >= 0.99) or "label" in it))}
if q == 1:
lv = np.arange(len(p))
row["mae"] = abs(float((p * lv).sum()) - gold)
row["first"] = pred == 0
if "workflow" in it:
row["workflow"] = it["workflow"]
rows.append(row)
return rows
def summarize(rows):
def agg(rs):
det = [r for r in rs if r["determinate"]]
if not det:
conf = np.asarray([r["conf"] for r in rs], dtype=np.float64)
act = np.asarray([r["act"] for r in rs], dtype=np.float64)
return {"n": len(rs), "mean_conf": round(float(conf.mean()), 4),
"p95_conf": round(float(np.percentile(conf, 95)), 4),
"high_conf_rate_075": round(float(np.mean(conf >= 0.75)), 4),
"mean_act": round(float(act.mean()), 4),
"p95_act": round(float(np.percentile(act, 95)), 4)}
conf = np.asarray([r["conf"] for r in det], dtype=np.float64)
correct = np.asarray([r["correct"] for r in det], dtype=bool)
accepted = conf >= 0.8
order = np.argsort(-conf)
cumulative_risk = np.cumsum(~correct[order]) / np.arange(1, len(det) + 1)
d = {"n": len(det), "acc": round(float(np.mean([r["correct"] for r in det])), 4),
"soft_acc": round(float(np.mean([r["soft"] for r in det])), 4),
"ece": round(ece_score(np.array([r["conf"] for r in det]), np.array([r["correct"] for r in det], float)), 4),
"act_auroc": round(auroc([r["act"] for r in det], [r["correct"] for r in det]), 4),
"nll": round(float(np.mean([-math.log(max(r["gold_prob"], 1e-12)) for r in det])), 4),
"brier": round(float(np.mean([r["brier"] for r in det])), 4),
"wrong_high_conf_rate_080": round(float(np.mean((~correct) & accepted)), 4),
"coverage_at_080": round(float(accepted.mean()), 4),
"accuracy_at_080": round(float(correct[accepted].mean()), 4) if accepted.any() else None,
"aurc": round(float(cumulative_risk.mean()), 4)}
sc = [r for r in det if "mae" in r]
if sc:
d["score_mae"] = round(float(np.mean([r["mae"] for r in sc])), 4)
d["first_level_rate"] = round(float(np.mean([r["first"] for r in sc])), 4)
d["first_level_gold_rate"] = round(float(np.mean([r["gold"] == 0 for r in sc])), 4)
return d
out = {"all": agg(rows)}
for key in ("t", "lang", "workflow", "src"):
groups = defaultdict(list)
for r in rows:
if key in r:
groups[r[key]].append(r)
if len(groups) > 1:
out["by_" + key] = {k: agg(v) for k, v in sorted(groups.items())}
und = [r for r in rows if not r["determinate"]]
if und:
conf = np.asarray([r["conf"] for r in und], dtype=np.float64)
act = np.asarray([r["act"] for r in und], dtype=np.float64)
out["undetermined_mean_conf"] = round(float(conf.mean()), 4)
out["undetermined_p95_conf"] = round(float(np.percentile(conf, 95)), 4)
out["undetermined_high_conf_rate_075"] = round(float(np.mean(conf >= 0.75)), 4)
out["undetermined_mean_act"] = round(float(act.mean()), 4)
out["undetermined_p95_act"] = round(float(np.percentile(act, 95)), 4)
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ckpt", required=True)
ap.add_argument("--data", default="data/built")
ap.add_argument("--suites", default=",".join(SUITES + ["val"]))
ap.add_argument("--calibrate", action="store_true")
ap.add_argument("--calib-file", default="calib.pt")
ap.add_argument("--device", default="auto", help="auto, cuda[:index], mps or cpu; evaluation remains FP32")
ap.add_argument("--out", default=None)
ap.add_argument("--max-per-suite", type=int, default=0)
ap.add_argument("--threads", type=int, default=0)
args = ap.parse_args()
device = pick_device(args.device)
print(f"device={device} precision=fp32", flush=True)
if args.threads:
torch.set_num_threads(args.threads)
tok = load_tokenizer(args.ckpt)
model, cfg = load_model(args.ckpt, device)
model.eval()
t0 = time.time()
shipped = temps_from_cfg(cfg)
if args.calibrate:
calib = torch.load(os.path.join(args.data, args.calib_file), weights_only=False)
t, tb, counts = calibrate(model, tok, device, calib)
cfg["temperature"] = t
cfg["temperature_by_options"] = tb
cfg["calibration"] = {"items": len(calib), "per_bucket": counts, "method": "held-out NLL, T in [0.5, 5]"}
json.dump(cfg, open(os.path.join(args.ckpt, "rl_agent_config.json"), "w"), indent=2, ensure_ascii=False)
print(f"calibrated in {time.time()-t0:.0f}s: temperature={t} by_options={tb}", flush=True)
t, tb = temps_from_cfg(cfg)
report = {"ckpt": os.path.abspath(args.ckpt), "device": str(device), "precision": "fp32",
"temperature": t, "temperature_by_options": tb, "suites": {},
"suites_as_shipped": {}}
for name in args.suites.split(","):
path = os.path.join(args.data, f"{name}.pt" if name == "val" else f"eval_{name}.pt")
if not os.path.exists(path):
continue
items = torch.load(path, weights_only=False)
if args.max_per_suite:
items = items[: args.max_per_suite]
lg, act = logits_for(model, items, tok.pad_token_id, device)
report["suites"][name] = summarize(score_items(items, lg, act, t, tb))
if args.calibrate:
report["suites_as_shipped"][name] = summarize(score_items(items, lg, act, *shipped))
a = report["suites"][name]["all"]
print(f"[{time.time()-t0:5.0f}s] {name:17s} n={a.get('n')} acc={a.get('acc')} ece={a.get('ece')} "
f"act_auroc={a.get('act_auroc')}", flush=True)
out = args.out or os.path.join(args.ckpt, "eval_report.json")
json.dump(report, open(out, "w"), indent=1, ensure_ascii=False)
print("wrote", out)
if __name__ == "__main__":
main()