PEFT
Safetensors
echoforest's picture
Upload naver-psy classifier (adapter, heads, calibration)
210f9af verified
Raw History Blame Contribute Delete
16.3 kB
"""Evaluation report on val and test (pure Python). Run as ``python -m training.evaluate``.
Spec: docs/classifier-plan.md §5.1-5.3. All "accuracy" is agreement with the teacher. Per split:
- **variants**: raw/gated x uncalibrated/calibrated; per field accuracy, macro-F1, kappa, log-loss, ECE
(gold = ``targets[field]`` unless the field is in ``mask``); the headline macro-F1 leaves out the taxonomy's
``macro_f1_exclude`` options (nsb-v2: emotion_fine ``mixed``; ``--macro-f1-exclude`` overrides), which stay in
``per_class`` and in ``macro_f1_all``;
- **key classes** (gated, calibrated, eval rows only): precision, recall, class ECE, predicted vs teacher share
weighted by 1/inclusion_prob;
- **agreement** vs teacher passes r1 and r2 separately, next to r1-vs-r2 (the noise ceiling); the student's gated
calibrated argmax is compared with the full pass labels (gate-decided absences included);
- **aggregate parity** per stock group x KST day (§7.2 primary index): student shares from calibrated gated
probabilities, teacher shares from one-hot targets, on the same eval posts. A cell counts when the teacher side has
>= MIN_AUTHORS contributing authors and the student side at least one. Unweighted (eval posts are random/audit);
- **breakdowns** per code, stock group, session tag, flag (``possible_injection`` separately);
- **intervals**: 95% author-cluster bootstrap of kappa and accuracy for the product fields.
Across splits: crossfit calibration on val (out-of-sample ECE) and drift (val minus test accuracy, points).
"""
from __future__ import annotations
import argparse
import json
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Iterable, Mapping
from . import metrics as mt
from .calibrate import crossfit, pair_up, probs
from .kst import day_bucket, session_tag
from .taxonomy import AD_OPTIONS_ANY, COMMERCIAL_AD, PRODUCT_FIELDS, RELEVANT, Taxonomy, gate, gate_fill
VERSION = "nsb-eval-v1"
VARIANTS = ("raw_uncalibrated", "raw_calibrated", "gated_uncalibrated", "gated_calibrated")
def key_class_list(ad_options: Iterable[str] = COMMERCIAL_AD) -> list[tuple[str, str]]:
"""The §5.1 key classes; the commercial ones are the taxonomy's ad options."""
return ([("relevance", RELEVANT), ("direction", "bullish"), ("direction", "bearish")]
+ [("commercial", o) for o in ad_options]
+ [("emotion_valence", "positive"), ("emotion_valence", "negative")])
KEY_CLASSES = key_class_list() # nsb-v1
FLAGS = ("image_dependent", "truncated_input")
MIN_AUTHORS = 10
MIN_WEIGHT = 0.5
SPLITS = ("val", "test")
# ---------------------------------------------------------------------- helpers
def gold(row: Mapping, field: str) -> str | None:
return None if field in set(row.get("mask") or ()) else row["targets"].get(field)
def distributions(pred: Mapping, tax: Taxonomy, temps: Mapping[str, float]) -> dict[str, dict]:
raw_u = probs(pred["logits"], tax, None)
raw_c = probs(pred["logits"], tax, temps)
return {"raw_uncalibrated": raw_u, "raw_calibrated": raw_c, "gated_uncalibrated": gate(raw_u),
"gated_calibrated": gate(raw_c)}
def scores(dist: Mapping[str, Mapping[str, float]]) -> dict[str, float]:
"""§7.2 per-post quantities r, t, b, s, q from one set of distributions (or one-hot labels)."""
return {"r": dist["relevance"][RELEVANT], "t": dist["target"]["this_stock"], "b": dist["direction"]["bullish"],
"s": dist["direction"]["bearish"],
"q": 1.0 - sum(dist["commercial"].get(o, 0.0) for o in AD_OPTIONS_ANY)}
def one_hot(labels: Mapping[str, str | None], tax: Taxonomy) -> dict[str, dict[str, float]] | None:
"""Teacher labels as distributions; None when a field the shares need is unresolved."""
filled = gate_fill(labels)
if any(filled.get(f) is None for f in ("relevance", "target", "direction", "commercial")):
return None
return {f: {o: (1.0 if filled.get(f) == o else 0.0) for o in tax.fields[f]} for f in tax.names}
def author_balanced(posts: Iterable[tuple[str, Mapping[str, float]]]) -> dict:
"""Author-balanced bull/bear shares: per author B_a = sum(w b)/sum(w), w = r t q; mean over authors with
sum(w) >= MIN_WEIGHT (one author, one vote)."""
acc: dict[str, list[float]] = defaultdict(lambda: [0.0, 0.0, 0.0])
for author, s in posts:
w = s["r"] * s["t"] * s["q"]
a = acc[author]
a[0] += w
a[1] += w * s["b"]
a[2] += w * s["s"]
contrib = [(b / w, s / w) for w, b, s in acc.values() if w >= MIN_WEIGHT]
if not contrib:
return {"n_authors": len(acc), "n_contrib": 0, "bull": None, "bear": None, "net": None}
bull = sum(x for x, _ in contrib) / len(contrib)
bear = sum(y for _, y in contrib) / len(contrib)
return {"n_authors": len(acc), "n_contrib": len(contrib), "bull": bull, "bear": bear, "net": bull - bear}
def _written(row: Mapping) -> datetime:
return datetime.fromisoformat(row["written_at_kst"])
# ---------------------------------------------------------------------- sections
def variant_summaries(pairs, dists, tax: Taxonomy) -> dict:
out = {}
for v in VARIANTS:
out[v] = {f: mt.field_summary([gold(r, f) for _, r in pairs], [d[v][f] for d in dists], tax.fields[f],
tax.macro_f1_exclude.get(f, ()))
for f in tax.names}
return out
def key_classes(pairs, dists, ad_options: Iterable[str] = COMMERCIAL_AD) -> dict:
keep = [i for i, (_, r) in enumerate(pairs) if r.get("eval")]
weights = mt.inverse_weights(pairs[i][1].get("inclusion_prob") for i in keep)
out = {}
for field, cls in key_class_list(ad_options):
out[f"{field}={cls}"] = mt.key_class([gold(pairs[i][1], field) for i in keep],
[dists[i]["gated_calibrated"][field] for i in keep], cls, weights)
return out
def agreement(pairs, dists, tax: Taxonomy) -> dict:
"""Student vs r1, vs r2, and r1 vs r2 (ceiling), per field; rows with both passes only."""
keep = [i for i, (_, r) in enumerate(pairs) if (r.get("passes") or {}).get("r1") and r["passes"].get("r2")]
out: dict = {"n": len(keep), "r1": {}, "r2": {}, "ceiling": {}}
for f in tax.names:
student = [mt.top(dists[i]["gated_calibrated"][f]) for i in keep]
r1 = [gate_fill(pairs[i][1]["passes"]["r1"]).get(f) for i in keep]
r2 = [gate_fill(pairs[i][1]["passes"]["r2"]).get(f) for i in keep]
out["r1"][f] = {"kappa": mt.kappa(r1, student), "accuracy": mt.accuracy(r1, student)}
out["r2"][f] = {"kappa": mt.kappa(r2, student), "accuracy": mt.accuracy(r2, student)}
out["ceiling"][f] = {"kappa": mt.kappa(r1, r2), "accuracy": mt.accuracy(r1, r2)}
return out
def aggregate_parity(pairs, dists, tax: Taxonomy, groups: Mapping[str, str]) -> dict:
cells: dict[tuple, dict[str, list]] = defaultdict(lambda: {"student": [], "teacher": []})
for (_, row), d in zip(pairs, dists):
if not row.get("eval"):
continue
teacher = one_hot(row["targets"], tax)
if teacher is None:
continue
key = (groups.get(row["code"], row["code"]), day_bucket(_written(row)).isoformat())
cells[key]["student"].append((row["author_key"], scores(d["gated_calibrated"])))
cells[key]["teacher"].append((row["author_key"], scores(teacher)))
rows = []
for (group, day), c in sorted(cells.items()):
st, te = author_balanced(c["student"]), author_balanced(c["teacher"])
if te["n_contrib"] < MIN_AUTHORS or not st["n_contrib"]:
continue
rows.append({"group": group, "day": day, "n_authors": te["n_authors"], "teacher_contrib": te["n_contrib"],
"student_contrib": st["n_contrib"], "student": {k: st[k] for k in ("bull", "bear", "net")},
"teacher": {k: te[k] for k in ("bull", "bear", "net")}})
def series(side, k):
return [c[side][k] for c in rows]
bull_mae = mt.mae(series("student", "bull"), series("teacher", "bull"))
bear_mae = mt.mae(series("student", "bear"), series("teacher", "bear"))
return {"n_cells": len(rows), "cells_considered": len(cells), "min_authors": MIN_AUTHORS,
"bull_mae_points": None if bull_mae is None else 100 * bull_mae,
"bear_mae_points": None if bear_mae is None else 100 * bear_mae,
"net_r": mt.pearson(series("student", "net"), series("teacher", "net")), "cells": rows}
def _slice(pairs, dists, idx: list[int], tax: Taxonomy) -> dict:
out: dict = {"n": len(idx), "n_eval": sum(1 for i in idx if pairs[i][1].get("eval")), "accuracy": {}, "kappa": {}}
for f in tax.names:
g = [gold(pairs[i][1], f) for i in idx]
p = [mt.top(dists[i]["gated_calibrated"][f]) for i in idx]
out["accuracy"][f] = mt.accuracy(g, p)
if f in PRODUCT_FIELDS:
out["kappa"][f] = mt.kappa(g, p)
return out
def breakdowns(pairs, dists, tax: Taxonomy, groups: Mapping[str, str], holidays) -> dict:
keyed: dict[str, dict[str, list[int]]] = {"code": defaultdict(list), "group": defaultdict(list),
"session": defaultdict(list), "flags": defaultdict(list)}
injection: list[int] = []
for i, (_, row) in enumerate(pairs):
keyed["code"][row["code"]].append(i)
keyed["group"][groups.get(row["code"], row["code"])].append(i)
keyed["session"][session_tag(_written(row), holidays)].append(i)
flags = set(row.get("flags") or ())
for flag in FLAGS:
if flag in flags:
keyed["flags"][flag].append(i)
if "possible_injection" in flags:
injection.append(i)
out = {k: {name: _slice(pairs, dists, idx, tax) for name, idx in sorted(v.items())} for k, v in keyed.items()}
out["possible_injection"] = _slice(pairs, dists, injection, tax)
authors = {r["author_key"] for _, r in pairs}
flagged = {pairs[i][1]["author_key"] for i in injection}
out["injection_author_share"] = len(flagged) / len(authors) if authors else None
return out
def intervals(pairs, dists, reps: int, seed: str) -> dict:
by_id = {r["id"]: (r, d) for (_, r), d in zip(pairs, dists)}
authors = {i: by_id[i][0]["author_key"] for i in by_id}
out = {}
for f in PRODUCT_FIELDS:
def stat(ids, kind, f=f):
g = [gold(by_id[i][0], f) for i in ids]
p = [mt.top(by_id[i][1]["gated_calibrated"][f]) for i in ids]
return mt.kappa(g, p) if kind == "kappa" else mt.accuracy(g, p)
out[f] = {k: mt.bootstrap(list(by_id), authors, lambda ids, k=k: stat(ids, k), reps=reps,
seed=f"{seed}|{f}|{k}")["interval"] for k in ("kappa", "accuracy")}
return out
def split_report(rows, preds, tax, temps, groups, holidays, reps) -> dict:
pairs = pair_up(preds, rows)
dists = [distributions(p, tax, temps) for p, _ in pairs]
split = rows[0].get("split", "?") if rows else "?"
return {"n": len(pairs), "n_eval": sum(1 for _, r in pairs if r.get("eval")),
"variants": variant_summaries(pairs, dists, tax),
"key_classes": key_classes(pairs, dists, tax.commercial_ad),
"agreement": agreement(pairs, dists, tax), "aggregate_parity": aggregate_parity(pairs, dists, tax, groups),
"breakdowns": breakdowns(pairs, dists, tax, groups, holidays),
"intervals": intervals(pairs, dists, reps, f"{mt.SEED}|{split}")}
def crossfit_ece(rows, preds, tax: Taxonomy) -> dict:
"""Out-of-sample calibration check: temperatures fitted on one author half of val, ECE on the other."""
pairs = pair_up(preds, rows)
cf = crossfit(pairs, tax)
out = {"temperatures_by_half": cf["temperatures_by_half"], "sizes": cf["sizes"], "ece": {}, "ece_gated": {}}
for f in tax.names:
g = [gold(r, f) for _, r in pairs]
out["ece"][f] = mt.ece(g, [cf["calibrated"][p["id"]][f] for p, _ in pairs])
out["ece_gated"][f] = mt.ece(g, [gate(cf["calibrated"][p["id"]])[f] for p, _ in pairs])
return out
def report(rows: list[dict], preds: list[dict], tax: Taxonomy, temps: Mapping[str, float], *,
groups: Mapping[str, str] | None = None, holidays: Iterable = (), reps: int = mt.REPS) -> dict:
"""The full §5 report. ``rows`` and ``preds`` may mix splits; each split present is reported."""
holidays = set(holidays)
groups = dict(groups or {})
by_split: dict[str, list[dict]] = defaultdict(list)
for r in rows:
by_split[r["split"]].append(r)
preds_by_id = {p["id"]: p for p in preds}
out: dict = {"version": VERSION, "metrics_version": mt.VERSION, "taxonomy_version": tax.version,
"macro_f1_exclude": tax.macro_f1_exclude, "temperatures": dict(temps), "splits": {}}
for split in SPLITS:
if not by_split.get(split):
continue
split_preds = [preds_by_id[r["id"]] for r in by_split[split] if r["id"] in preds_by_id]
out["splits"][split] = split_report(by_split[split], split_preds, tax, temps, groups, holidays, reps)
if "val" in out["splits"]:
out["calibration"] = {"crossfit": crossfit_ece(by_split["val"],
[preds_by_id[r["id"]] for r in by_split["val"]], tax)}
if "val" in out["splits"] and "test" in out["splits"]:
va = out["splits"]["val"]["variants"]["gated_calibrated"]
te = out["splits"]["test"]["variants"]["gated_calibrated"]
out["drift"] = {f: (None if va[f]["accuracy"] is None or te[f]["accuracy"] is None
else 100 * (va[f]["accuracy"] - te[f]["accuracy"])) for f in tax.names}
return out
def parse_exclude(items: Iterable[str]) -> dict[str, list[str]]:
"""``["emotion_fine=mixed", ...]`` -> ``{"emotion_fine": ["mixed"]}``; ``["none"]`` -> ``{}``."""
out: dict[str, list[str]] = {}
for item in items:
if item == "none":
continue
field, sep, option = item.partition("=")
if not sep or not field or not option:
raise SystemExit(f"--macro-f1-exclude wants FIELD=OPTION, got {item!r}")
out.setdefault(field, []).append(option)
return out
def main(argv=None) -> int:
from .data import load_jsonl, load_split
from .kst import load_groups, load_holidays
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--data", required=True, help="Bundle directory (val.jsonl, test.jsonl, taxonomy.json)")
p.add_argument("--run", required=True, help="Run directory (predictions/{val,test}.jsonl, temperatures.json)")
p.add_argument("--groups", help="config/stocks.yaml (code -> stock group)")
p.add_argument("--holidays", help="config/krx_holidays.yaml")
p.add_argument("--reps", type=int, default=mt.REPS)
p.add_argument("--macro-f1-exclude", action="append", metavar="FIELD=OPTION",
help="Leave an option out of the headline macro-F1 (repeatable; 'none' clears; default: the "
"taxonomy's macro_f1_exclude)")
p.add_argument("--out", required=True)
args = p.parse_args(argv)
if Path(args.out).exists():
raise SystemExit("refusing to overwrite " + args.out)
run = Path(args.run)
tax = Taxonomy.load(args.data)
if args.macro_f1_exclude:
tax = tax.replace(macro_f1_exclude=parse_exclude(args.macro_f1_exclude))
temps = json.loads((run / "temperatures.json").read_text())
rows, preds = [], []
for split in SPLITS:
path = run / "predictions" / f"{split}.jsonl"
if path.exists():
rows += load_split(args.data, split)
preds += load_jsonl(path)
groups = load_groups(args.groups, {r["code"] for r in rows})
rep = report(rows, preds, tax, temps, groups=groups, holidays=load_holidays(args.holidays), reps=args.reps)
Path(args.out).write_text(json.dumps(rep, indent=1, ensure_ascii=False) + "\n")
print(json.dumps({s: {"n": v["n"], "n_eval": v["n_eval"]} for s, v in rep["splits"].items()}))
return 0
if __name__ == "__main__":
raise SystemExit(main())