#!/usr/bin/env python3 """Element-head evaluation on held-out sites: does each image get a rendering Haiku judged acceptable? Compares the v2 element head with what v1 shipped for the same images (its 5-class image head for the first 32 images of a page, the hand-written runtime rules for everything else), per kind of image. """ import argparse, collections, gzip, importlib.util, json, pickle, sys from pathlib import Path import numpy as np import torch from model import NadirNet, TREAT from train import collate, prep, element_logits import safety V1_MAP = {"keep": 0, "dim": 0, "invert": 1, "light": 1, "plate": 1, "clear": 3} def v1_decision(t, im, surf_L): """v1 runtime: model treatment (or 'keep' for images it never saw) through the hand-written rules.""" s = safety.safe_treatment(t, im, surf_L) if s.startswith("adapt:"): return 2 # the logo adaptation path return V1_MAP.get(s, 0) def main(): ap = argparse.ArgumentParser() ap.add_argument("--ckpt", required=True) ap.add_argument("--data", required=True) ap.add_argument("--crawl", action="append", required=True, help="TAG=CRAWL_DIR") ap.add_argument("--v1-ckpt", default="") ap.add_argument("--v1-src", default="", help="v1 model.py (its architecture)") ap.add_argument("--out", required=True) ap.add_argument("--rows", default="", help="also write every element (probabilities, labels) as jsonl") args = ap.parse_args() crawls = dict(c.split("=", 1) for c in args.crawl) dev = "cuda" if torch.cuda.is_available() else "cpu" ck = torch.load(args.ckpt, map_location="cpu") T = float(ck.get("temperature", 1.0)) m = NadirNet(**ck["cfg"]); m.load_state_dict(ck["model"]); m.eval().to(dev) v1 = None if args.v1_ckpt and args.v1_src: spec = importlib.util.spec_from_file_location("model_v1", args.v1_src) mv1 = importlib.util.module_from_spec(spec); spec.loader.exec_module(mv1) c1 = torch.load(args.v1_ckpt, map_location="cpu") v1 = mv1.NadirNet(**c1["cfg"]); v1.load_state_dict(c1["model"]); v1.eval().to(dev) data = pickle.load(open(args.data, "rb")) val = [prep(s) for s in data["val"] if len(s.get("E_ut", []))] rows = [] with torch.no_grad(): for s in val: b = collate([s], dev) lab, _, ctx = m(b["tok_f"], b["role"], b["lab"], b["img_f"], b["thumbs"], b["page"], b["src"], b["rel"], b["mask"]) tl, rl = element_logits(m, b, lab.float(), ctx) k = int(b["e_mask"][0].sum()) p = torch.softmax(tl[0, :k] / T, -1).cpu().numpy() role = rl[0, :k].argmax(-1).cpu().numpy() rprob = torch.softmax(rl[0, :k].float() / T, -1).max(-1).values.cpu().numpy() v1t = None if v1 is not None and len(s["IF"]): _, _, il = v1(b["tok_f"], b["role"], b["lab"], b["img_f"][..., :39], b["thumbs"], b["page"], b["src"], b["rel"], b["mask"]) v1t = il[0].float().argmax(-1).cpu().numpy() L = json.load(gzip.open(Path(crawls[s["crawl"]]) / "pages" / s["id"] / "light.json.gz")) n = len(s["role"]) outL = lab[0, :n, 0].float().cpu().numpy() for q in range(k): j = int(s["E_idx"][q]) im = L["images"][j] u = im.get("underTok", -1) surf_L = float(outL[u]) if 0 <= u < n else None ud = [round(float(x), 4) for x in lab[0, u].float().cpu().numpy()] if 0 <= u < n else None old = ["keep", "dim", "invert", "light", "plate"][int(v1t[j])] if (v1t is not None and j < 32 and j < len(v1t)) else "keep" soft = s["E_soft"][q] rows.append({"bucket": s["E_bucket"][q], "kind": s["E_kind"][q], "soft": soft.tolist(), "best": int(np.argmax(soft)), "v2": int(np.argmax(p[q])), "conf": float(p[q].max()), "p": [round(float(x), 4) for x in p[q]], "role_pred": int(role[q]), "role": int(s["E_role"][q]), "v1": v1_decision(old, im, surf_L), "v1_seen": bool(v1t is not None and j < 32), "nlab": int(s["E_nlab"][q]), **({"rp": round(float(rprob[q]), 4), "f": [round(float(x), 4) for x in s["E_f"][q]], "ud": ud, "ul": [round(float(x), 4) for x in s["E_ul"][q]]} if args.rows else {})}) def score(rs, key): if not rs: return {} acc = np.mean([r[key] == r["best"] for r in rs]) ok = np.mean([r["soft"][r[key]] > 0 for r in rs]) return {"n": len(rs), "acc": round(float(acc), 4), "acceptable": round(float(ok), 4)} rep = {"temperature": T, "overall": {"v2": score(rows, "v2"), "v1": score(rows, "v1")}, "by_bucket": {}, "by_kind": {}} for key in ("bucket", "kind"): groups = collections.defaultdict(list) for r in rows: groups[r[key]].append(r) rep["by_" + key] = {g: {"v2": score(rs, "v2"), "v1": score(rs, "v1")} for g, rs in sorted(groups.items())} # calibration: confidence vs accuracy bins = collections.defaultdict(list) for r in rows: bins[min(9, int(r["conf"] * 10))].append(r["v2"] == r["best"]) rep["calibration"] = {f"{b / 10:.1f}": {"n": len(v), "acc": round(float(np.mean(v)), 3)} for b, v in sorted(bins.items())} rr = [r for r in rows if r["role"] >= 0] rep["role_acc"] = round(float(np.mean([r["role_pred"] == r["role"] for r in rr])), 4) if rr else None # Haiku's own consistency where a second, independent label exists rep["n_double_labelled"] = sum(r["nlab"] > 1 for r in rows) json.dump(rep, open(args.out, "w"), indent=1) if args.rows: with open(args.rows, "w") as f: for r in rows: f.write(json.dumps(r) + "\n") print(json.dumps({k: rep[k] for k in ("overall", "role_acc", "temperature")}, indent=1)) print(json.dumps(rep["by_bucket"], indent=1)) if __name__ == "__main__": main()