claimeval 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claimeval/__init__.py +95 -0
- claimeval/__main__.py +177 -0
- claimeval/analysis/__init__.py +26 -0
- claimeval/analysis/baselines.py +135 -0
- claimeval/analysis/claims.py +97 -0
- claimeval/analysis/compare.py +121 -0
- claimeval/analysis/report.py +179 -0
- claimeval/core/__init__.py +17 -0
- claimeval/core/adapters.py +142 -0
- claimeval/core/ingest.py +201 -0
- claimeval/core/schema.py +206 -0
- claimeval/sim/__init__.py +17 -0
- claimeval/sim/coverage.py +331 -0
- claimeval/sim/simulate.py +199 -0
- claimeval/stats/__init__.py +37 -0
- claimeval/stats/bca.py +100 -0
- claimeval/stats/bias.py +142 -0
- claimeval/stats/bootstrap.py +300 -0
- claimeval/stats/calibrate.py +101 -0
- claimeval/stats/cost_axis.py +120 -0
- claimeval/stats/frontier.py +106 -0
- claimeval/stats/wild.py +143 -0
- claimeval-1.0.0.dist-info/METADATA +198 -0
- claimeval-1.0.0.dist-info/RECORD +28 -0
- claimeval-1.0.0.dist-info/WHEEL +5 -0
- claimeval-1.0.0.dist-info/entry_points.txt +2 -0
- claimeval-1.0.0.dist-info/licenses/LICENSE +21 -0
- claimeval-1.0.0.dist-info/top_level.txt +1 -0
claimeval/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""claimeval -- test the claims made about AI agents against the data behind them.
|
|
2
|
+
|
|
3
|
+
A claim ("#1", "beats X", "as good at lower cost", "on the cost-accuracy
|
|
4
|
+
frontier") gets the probability that it holds, from a bootstrap over the right
|
|
5
|
+
unit (tasks, or clusters such as repositories), and a verdict. Input is a
|
|
6
|
+
stream of (config, task, seed, success, cost[, group]) runs from any harness,
|
|
7
|
+
or a public leaderboard release read by `claimeval.core.adapters`. Also: frontier
|
|
8
|
+
membership and dominance probabilities, a Hausdorff frontier band, a
|
|
9
|
+
winner's-curse correction, wild cluster bootstrap intervals, and a task-vs-seed
|
|
10
|
+
variance decomposition.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
# Subpackages
|
|
16
|
+
from . import core
|
|
17
|
+
from . import stats
|
|
18
|
+
from . import analysis
|
|
19
|
+
from . import sim
|
|
20
|
+
|
|
21
|
+
# Core data structures and loaders
|
|
22
|
+
from .core.schema import Rollout, RolloutSet
|
|
23
|
+
from .core.ingest import FieldMap, guess_map, inspect_path, load
|
|
24
|
+
|
|
25
|
+
# Statistical methods, frontier geometry & diagnostics
|
|
26
|
+
from .stats.frontier import dominance, frontier_band, frontier_points, hausdorff, log_cost_range, pareto_mask
|
|
27
|
+
from .stats.bootstrap import BootstrapResult, auto_mode, hierarchical_bootstrap, variance_decomposition
|
|
28
|
+
from .stats.wild import wild_intervals
|
|
29
|
+
from .stats.bias import seed_count_curve, split_sample_debias
|
|
30
|
+
from .stats.cost_axis import diagnose_cost_axis, format_diagnosis
|
|
31
|
+
|
|
32
|
+
# Analysis, claims & reporting
|
|
33
|
+
from .analysis.claims import Claim, evaluate_claims
|
|
34
|
+
from .analysis.report import analyze, format_report, headline
|
|
35
|
+
|
|
36
|
+
# Simulation & coverage validation
|
|
37
|
+
from .sim.simulate import make_population, population_truth, sample_dataset, simulate
|
|
38
|
+
from .sim.coverage import coverage_study, format_coverage
|
|
39
|
+
|
|
40
|
+
# Backward compatibility aliases for module paths
|
|
41
|
+
sys.modules["claimeval.schema"] = core.schema
|
|
42
|
+
sys.modules["claimeval.ingest"] = core.ingest
|
|
43
|
+
sys.modules["claimeval.adapters"] = core.adapters
|
|
44
|
+
sys.modules["claimeval.bootstrap"] = stats.bootstrap
|
|
45
|
+
sys.modules["claimeval.wild"] = stats.wild
|
|
46
|
+
sys.modules["claimeval.bca"] = stats.bca
|
|
47
|
+
sys.modules["claimeval.calibrate"] = stats.calibrate
|
|
48
|
+
sys.modules["claimeval.bias"] = stats.bias
|
|
49
|
+
sys.modules["claimeval.cost_axis"] = stats.cost_axis
|
|
50
|
+
sys.modules["claimeval.frontier"] = stats.frontier
|
|
51
|
+
sys.modules["claimeval.claims"] = analysis.claims
|
|
52
|
+
sys.modules["claimeval.compare"] = analysis.compare
|
|
53
|
+
sys.modules["claimeval.baselines"] = analysis.baselines
|
|
54
|
+
sys.modules["claimeval.report"] = analysis.report
|
|
55
|
+
sys.modules["claimeval.simulate"] = sim.simulate
|
|
56
|
+
sys.modules["claimeval.coverage"] = sim.coverage
|
|
57
|
+
|
|
58
|
+
__version__ = "1.0.0"
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"core",
|
|
62
|
+
"stats",
|
|
63
|
+
"analysis",
|
|
64
|
+
"sim",
|
|
65
|
+
"Claim",
|
|
66
|
+
"evaluate_claims",
|
|
67
|
+
"auto_mode",
|
|
68
|
+
"wild_intervals",
|
|
69
|
+
"Rollout",
|
|
70
|
+
"RolloutSet",
|
|
71
|
+
"FieldMap",
|
|
72
|
+
"guess_map",
|
|
73
|
+
"inspect_path",
|
|
74
|
+
"load",
|
|
75
|
+
"analyze",
|
|
76
|
+
"headline",
|
|
77
|
+
"format_report",
|
|
78
|
+
"hierarchical_bootstrap",
|
|
79
|
+
"BootstrapResult",
|
|
80
|
+
"variance_decomposition",
|
|
81
|
+
"pareto_mask",
|
|
82
|
+
"dominance",
|
|
83
|
+
"frontier_points",
|
|
84
|
+
"hausdorff",
|
|
85
|
+
"frontier_band",
|
|
86
|
+
"log_cost_range",
|
|
87
|
+
"split_sample_debias",
|
|
88
|
+
"seed_count_curve",
|
|
89
|
+
"simulate",
|
|
90
|
+
"make_population",
|
|
91
|
+
"sample_dataset",
|
|
92
|
+
"population_truth",
|
|
93
|
+
"coverage_study",
|
|
94
|
+
"format_coverage",
|
|
95
|
+
]
|
claimeval/__main__.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""CLI: claimeval {demo,inspect,analyze,claims,seed-curve,coverage}"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import glob
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from claimeval.stats.bias import seed_count_curve
|
|
14
|
+
from claimeval.core.ingest import FieldMap, guess_map, inspect_path, load
|
|
15
|
+
from claimeval.analysis.report import analyze
|
|
16
|
+
from claimeval.core.schema import RolloutSet
|
|
17
|
+
from claimeval.sim.simulate import simulate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load(args) -> RolloutSet:
|
|
21
|
+
src = getattr(args, "source", None)
|
|
22
|
+
if src:
|
|
23
|
+
from claimeval.core import adapters
|
|
24
|
+
if src == "tau2":
|
|
25
|
+
rs, skipped = adapters.tau2(sorted(glob.glob(os.path.join(args.path, "*.json")))
|
|
26
|
+
if os.path.isdir(args.path) else [args.path])
|
|
27
|
+
elif src == "swebench-cost":
|
|
28
|
+
rs, skipped = adapters.swebench(args.path, with_cost=True)
|
|
29
|
+
else:
|
|
30
|
+
rs, skipped = {"swebench": adapters.swebench,
|
|
31
|
+
"terminal-bench": adapters.terminal_bench}[src](args.path)
|
|
32
|
+
for k, v in skipped.items():
|
|
33
|
+
print(f"[skipped] {k}: {v}", file=sys.stderr)
|
|
34
|
+
return rs
|
|
35
|
+
if args.map:
|
|
36
|
+
fmap = FieldMap.from_dict(json.loads(open(args.map).read()))
|
|
37
|
+
else:
|
|
38
|
+
info = inspect_path(args.path)
|
|
39
|
+
fmap = info["guess"]
|
|
40
|
+
print(f"[guessed mapping] {fmap}\n", file=sys.stderr)
|
|
41
|
+
rs, dropped = load(args.path, fmap)
|
|
42
|
+
if dropped:
|
|
43
|
+
print(f"[dropped records] {dropped}\n", file=sys.stderr)
|
|
44
|
+
return rs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _print_curve(rows: list[dict]) -> None:
|
|
48
|
+
print(f"\n{'k':>3}{'Jaccard vs full':>18}{'Hausdorff':>12}"
|
|
49
|
+
f"{'acc inflation':>16}{'cost inflation':>16}{'|front|':>9}")
|
|
50
|
+
print("-" * 74)
|
|
51
|
+
for r in rows:
|
|
52
|
+
print(
|
|
53
|
+
f"{r['k']:>3}{r['jaccard_vs_full']:>18.3f}{r['hausdorff_vs_full']:>12.4f}"
|
|
54
|
+
f"{r['acc_inflation']:>+16.4f}{r['cost_inflation']:>+16.4f}"
|
|
55
|
+
f"{r['frontier_size']:>9.1f}"
|
|
56
|
+
)
|
|
57
|
+
print("\nacc inflation = what a k-seed study claims for its own frontier winners,")
|
|
58
|
+
print("minus what the full data says about those same configs.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
ap = argparse.ArgumentParser(prog="claimeval")
|
|
63
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
64
|
+
|
|
65
|
+
d = sub.add_parser("demo", help="end-to-end run on simulated data")
|
|
66
|
+
d.add_argument("--seeds", type=int, default=20)
|
|
67
|
+
d.add_argument("--tasks", type=int, default=50)
|
|
68
|
+
d.add_argument("--configs", type=int, default=9)
|
|
69
|
+
d.add_argument("--boot", type=int, default=2000)
|
|
70
|
+
|
|
71
|
+
i = sub.add_parser("inspect", help="discover the schema of a results dump")
|
|
72
|
+
i.add_argument("path")
|
|
73
|
+
|
|
74
|
+
a = sub.add_parser("analyze", help="audit frontier claims in a results dump")
|
|
75
|
+
a.add_argument("path")
|
|
76
|
+
a.add_argument("--map", help="JSON field mapping; omit to auto-guess")
|
|
77
|
+
a.add_argument("--from", dest="source", choices=["swebench", "swebench-cost", "terminal-bench", "tau2"],
|
|
78
|
+
help="read a public leaderboard release directly (see claimeval.adapters)")
|
|
79
|
+
|
|
80
|
+
a.add_argument("--boot", type=int, default=2000)
|
|
81
|
+
a.add_argument("--threshold", type=float, default=0.90)
|
|
82
|
+
|
|
83
|
+
c = sub.add_parser("claims", help="test published claims (top1, pairwise, margin, "
|
|
84
|
+
"cost_dominance, frontier) against a results dump")
|
|
85
|
+
c.add_argument("path")
|
|
86
|
+
c.add_argument("claims", help="JSON list of {kind, subject, comparators, margin, id}")
|
|
87
|
+
c.add_argument("--map")
|
|
88
|
+
c.add_argument("--from", dest="source", choices=["swebench", "swebench-cost", "terminal-bench", "tau2"],
|
|
89
|
+
help="read a public leaderboard release directly (see claimeval.adapters)")
|
|
90
|
+
|
|
91
|
+
c.add_argument("--boot", type=int, default=4000)
|
|
92
|
+
c.add_argument("--threshold", type=float, default=0.90)
|
|
93
|
+
c.add_argument("--mode", default=None,
|
|
94
|
+
choices=["task", "cluster_t"],
|
|
95
|
+
help="resampling unit; default: clusters if the data has them, else tasks")
|
|
96
|
+
|
|
97
|
+
s = sub.add_parser("seed-curve", help="error of a k-seed frontier vs the full data")
|
|
98
|
+
s.add_argument("path")
|
|
99
|
+
s.add_argument("--map")
|
|
100
|
+
s.add_argument("--ks", default="1,2,3,5,10")
|
|
101
|
+
|
|
102
|
+
cv = sub.add_parser("coverage", help="validate the estimator against known truth")
|
|
103
|
+
cv.add_argument("--reps", type=int, default=200)
|
|
104
|
+
cv.add_argument("--configs", type=int, default=9)
|
|
105
|
+
cv.add_argument("--tasks", type=int, default=50)
|
|
106
|
+
cv.add_argument("--seeds", type=int, default=5)
|
|
107
|
+
cv.add_argument("--boot", type=int, default=500)
|
|
108
|
+
cv.add_argument("--level", type=float, default=0.95)
|
|
109
|
+
cv.add_argument("--threshold", type=float, default=0.90)
|
|
110
|
+
cv.add_argument("--mode", default="task", choices=["task", "both", "seed"],
|
|
111
|
+
help="bootstrap mode; 'both' double-counts within-cell noise")
|
|
112
|
+
|
|
113
|
+
args = ap.parse_args(argv)
|
|
114
|
+
rng = np.random.default_rng(0)
|
|
115
|
+
|
|
116
|
+
if args.cmd == "demo":
|
|
117
|
+
rolls, truth = simulate(args.configs, args.tasks, args.seeds, rng)
|
|
118
|
+
rs = RolloutSet(rolls)
|
|
119
|
+
report, head = analyze(rs, n_boot=args.boot, rng=rng)
|
|
120
|
+
print(report)
|
|
121
|
+
print("\nGround truth accuracy (simulator):")
|
|
122
|
+
for c, t in zip(truth["configs"], truth["accuracy"]):
|
|
123
|
+
print(f" {c:<26}{t:.3f}")
|
|
124
|
+
_print_curve(seed_count_curve(rs, (1, 2, 3, 5, 10), n_repeats=30, rng=rng))
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
if args.cmd == "coverage":
|
|
128
|
+
from claimeval.sim.coverage import coverage_study, format_coverage
|
|
129
|
+
|
|
130
|
+
res = coverage_study(
|
|
131
|
+
n_reps=args.reps, n_configs=args.configs, n_tasks=args.tasks,
|
|
132
|
+
n_seeds=args.seeds, n_boot=args.boot, level=args.level,
|
|
133
|
+
threshold=args.threshold, boot_mode=args.mode,
|
|
134
|
+
)
|
|
135
|
+
print(format_coverage(res))
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
if args.cmd == "inspect":
|
|
139
|
+
info = inspect_path(args.path)
|
|
140
|
+
print(f"{info['n_records']} records scanned\n")
|
|
141
|
+
print(f"{'field':<46}{'count':>8} samples")
|
|
142
|
+
print("-" * 78)
|
|
143
|
+
for k, n in info["keys"].most_common(60):
|
|
144
|
+
ex = ", ".join(str(v)[:18] for v in info["samples"].get(k, []))
|
|
145
|
+
print(f"{k[:46]:<46}{n:>8} {ex[:24]}")
|
|
146
|
+
print(f"\nguessed mapping: {info['guess']}")
|
|
147
|
+
print("Verify this, then pass it with --map. Wrong success/cost fields")
|
|
148
|
+
print("produce a confident, meaningless frontier.")
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
rs = _load(args)
|
|
152
|
+
if args.cmd == "claims":
|
|
153
|
+
from claimeval.analysis.claims import Claim, evaluate_claims
|
|
154
|
+
|
|
155
|
+
spec = json.loads(open(args.claims).read())
|
|
156
|
+
cl = [Claim(x["kind"], x["subject"], x.get("comparators", []), x.get("margin", 0.0),
|
|
157
|
+
x.get("id", ""), x.get("k", 1)) for x in spec]
|
|
158
|
+
res = evaluate_claims(rs, cl, n_boot=args.boot, mode=args.mode, rng=rng, thr=args.threshold)
|
|
159
|
+
print(f"{'id':<10}{'kind':<16}{'subject':<28}{'P(claim)':>9} verdict")
|
|
160
|
+
for r in res:
|
|
161
|
+
v = "supported" if r["supported"] else (
|
|
162
|
+
"NOT supported" if r["holds_at_point_estimate"] else "contradicted by point estimate")
|
|
163
|
+
print(f"{r['id'][:9]:<10}{r['kind']:<16}{r['subject'][:27]:<28}{r['probability']:>9.3f} {v}")
|
|
164
|
+
print(f"\nsupported = P >= {args.threshold}; resampling: {res[0]['mode'] if res else '-'}")
|
|
165
|
+
return 0
|
|
166
|
+
if args.cmd == "analyze":
|
|
167
|
+
report, _ = analyze(rs, n_boot=args.boot, threshold=args.threshold, rng=rng)
|
|
168
|
+
print(report)
|
|
169
|
+
else:
|
|
170
|
+
ks = tuple(int(x) for x in args.ks.split(","))
|
|
171
|
+
print(rs.describe())
|
|
172
|
+
_print_curve(seed_count_curve(rs, ks, n_repeats=30, rng=rng))
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Claim evaluation, comparison, and reporting."""
|
|
2
|
+
|
|
3
|
+
from claimeval.stats.frontier import dominance, frontier_band, frontier_points, hausdorff, log_cost_range, pareto_mask
|
|
4
|
+
from .claims import Claim, evaluate_claims
|
|
5
|
+
from .compare import Scenario, compare, format_rows
|
|
6
|
+
from .baselines import METHODS, run_method
|
|
7
|
+
from .report import analyze, format_report, headline
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Claim",
|
|
11
|
+
"evaluate_claims",
|
|
12
|
+
"dominance",
|
|
13
|
+
"frontier_band",
|
|
14
|
+
"frontier_points",
|
|
15
|
+
"hausdorff",
|
|
16
|
+
"log_cost_range",
|
|
17
|
+
"pareto_mask",
|
|
18
|
+
"Scenario",
|
|
19
|
+
"compare",
|
|
20
|
+
"format_rows",
|
|
21
|
+
"METHODS",
|
|
22
|
+
"run_method",
|
|
23
|
+
"analyze",
|
|
24
|
+
"format_report",
|
|
25
|
+
"headline",
|
|
26
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Existing uncertainty methods, in the same output format as ours.
|
|
2
|
+
|
|
3
|
+
Every method returns a BootstrapResult: B joint draws of (accuracy, cost) for
|
|
4
|
+
all configurations. Frontier-membership and dominance probabilities, intervals
|
|
5
|
+
and certified claims are then computed by the SAME code for every method, so a
|
|
6
|
+
comparison measures the error model and nothing else.
|
|
7
|
+
|
|
8
|
+
Methods (paper names in brackets):
|
|
9
|
+
|
|
10
|
+
naive [current practice] point estimates, no uncertainty.
|
|
11
|
+
wald [per-model error bars] independent normal intervals per
|
|
12
|
+
configuration, tasks treated as independent. The +/- usually
|
|
13
|
+
printed next to a leaderboard score.
|
|
14
|
+
paired_clt [Miller 2024, paired] joint normal over all configurations
|
|
15
|
+
with the full task-level covariance, so comparisons are
|
|
16
|
+
paired. Tasks independent.
|
|
17
|
+
clustered_clt [Miller 2024, clustered] as paired_clt with cluster-robust
|
|
18
|
+
(sandwich) covariance over task groups, small-sample
|
|
19
|
+
corrected. The strongest formula-based competitor.
|
|
20
|
+
boot_both [naive bootstrap] resample tasks and runs together.
|
|
21
|
+
boot_task [ours, unclustered] task resampling.
|
|
22
|
+
boot_cluster_t [ours, clustered] intact clusters + few-cluster widening.
|
|
23
|
+
boot_group [our earlier version] two-stage; double-counts within-cluster noise.
|
|
24
|
+
boot_cluster [ablation] intact clusters, no widening.
|
|
25
|
+
|
|
26
|
+
The formula methods have no native notion of P(on frontier). They get the
|
|
27
|
+
fairest extension available: draws from their own sampling distribution,
|
|
28
|
+
pushed through the same frontier code (a parametric bootstrap).
|
|
29
|
+
|
|
30
|
+
Median cost has no simple standard error. The formula methods use its
|
|
31
|
+
influence function, psi = (1/2 - 1{x <= m}) / f(m), with a Gaussian kernel
|
|
32
|
+
density estimate of f at the median -- the textbook delta-method route.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import warnings
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
|
|
41
|
+
from claimeval.stats.bootstrap import BootstrapResult, hierarchical_bootstrap
|
|
42
|
+
from claimeval.core.schema import RolloutSet
|
|
43
|
+
|
|
44
|
+
METHODS = ("naive", "wald", "paired_clt", "clustered_clt", "boot_both", "boot_task", "boot_group",
|
|
45
|
+
"boot_cluster", "boot_cluster_t", "boot_cluster_cr2", "boot_cluster_kish")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _task_means(rs: RolloutSet) -> tuple[np.ndarray, np.ndarray]:
|
|
49
|
+
"""(C, T) per-task mean success and cost, averaged over runs."""
|
|
50
|
+
with warnings.catch_warnings():
|
|
51
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
52
|
+
return np.nanmean(rs.success, axis=2), np.nanmean(rs.cost, axis=2)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cost_influence(cost_t: np.ndarray, stat: str) -> tuple[np.ndarray, np.ndarray]:
|
|
56
|
+
"""Point estimate and per-task influence values for the cost statistic.
|
|
57
|
+
|
|
58
|
+
Returns (est (C,), psi (C, T)) such that est is approximately the mean of
|
|
59
|
+
est + psi, i.e. psi plays the role of centred per-task contributions.
|
|
60
|
+
"""
|
|
61
|
+
if stat == "mean":
|
|
62
|
+
est = np.nanmean(cost_t, axis=1)
|
|
63
|
+
return est, cost_t - est[:, None]
|
|
64
|
+
est = np.nanmedian(cost_t, axis=1)
|
|
65
|
+
psi = np.zeros_like(cost_t)
|
|
66
|
+
for c in range(cost_t.shape[0]):
|
|
67
|
+
x = cost_t[c][~np.isnan(cost_t[c])]
|
|
68
|
+
n = len(x)
|
|
69
|
+
sd = np.std(x, ddof=1)
|
|
70
|
+
iqr = np.subtract(*np.quantile(x, [0.75, 0.25]))
|
|
71
|
+
h = 0.9 * min(sd, iqr / 1.34 if iqr > 0 else sd) * n ** -0.2 # Silverman
|
|
72
|
+
h = max(h, 1e-12)
|
|
73
|
+
f = np.mean(np.exp(-0.5 * ((x - est[c]) / h) ** 2)) / (h * np.sqrt(2 * np.pi))
|
|
74
|
+
f = max(f, 1e-12)
|
|
75
|
+
psi[c] = (0.5 - (cost_t[c] <= est[c])) / f
|
|
76
|
+
return est, psi
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _normal_draws(mean: np.ndarray, cov: np.ndarray, B: int, rng) -> np.ndarray:
|
|
80
|
+
# eigen-decomposition tolerates the rank deficiency of a covariance
|
|
81
|
+
# estimated from fewer clusters than dimensions
|
|
82
|
+
w, V = np.linalg.eigh((cov + cov.T) / 2)
|
|
83
|
+
w = np.clip(w, 0, None)
|
|
84
|
+
return mean + (rng.standard_normal((B, len(mean))) * np.sqrt(w)) @ V.T
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _formula(rs: RolloutSet, B: int, rng, cost_stat: str, kind: str) -> BootstrapResult:
|
|
88
|
+
acc_t, cost_t = _task_means(rs)
|
|
89
|
+
C, T = acc_t.shape
|
|
90
|
+
acc = np.nanmean(acc_t, axis=1)
|
|
91
|
+
cost_est, psi = _cost_influence(cost_t, cost_stat)
|
|
92
|
+
# centred per-task contributions for all 2C statistics
|
|
93
|
+
Z = np.vstack([acc_t - acc[:, None], psi]) # (2C, T)
|
|
94
|
+
Z = np.nan_to_num(Z)
|
|
95
|
+
mean = np.concatenate([acc, cost_est])
|
|
96
|
+
|
|
97
|
+
if kind == "wald":
|
|
98
|
+
var = (Z ** 2).sum(axis=1) / (T * (T - 1))
|
|
99
|
+
cov = np.diag(var) # every configuration independent
|
|
100
|
+
elif kind == "paired_clt":
|
|
101
|
+
cov = (Z @ Z.T) / (T * (T - 1))
|
|
102
|
+
elif kind == "clustered_clt":
|
|
103
|
+
G = rs.n_groups
|
|
104
|
+
S = np.zeros((2 * C, G))
|
|
105
|
+
for g, idx in enumerate(rs.group_tasks):
|
|
106
|
+
S[:, g] = Z[:, idx].sum(axis=1)
|
|
107
|
+
cov = (S @ S.T) / T ** 2 * (G / (G - 1)) # CR1 small-sample correction
|
|
108
|
+
else:
|
|
109
|
+
raise ValueError(kind)
|
|
110
|
+
|
|
111
|
+
draws = _normal_draws(mean, cov, B, rng)
|
|
112
|
+
return BootstrapResult(draws[:, :C], draws[:, C:], list(rs.configs), kind)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_method(rs: RolloutSet, method: str, B: int = 1000, rng=None,
|
|
116
|
+
cost_stat: str = "median") -> BootstrapResult:
|
|
117
|
+
rng = rng or np.random.default_rng(0)
|
|
118
|
+
if method == "naive":
|
|
119
|
+
acc, cost = rs.point_estimates(cost_stat)
|
|
120
|
+
return BootstrapResult(acc[None, :], cost[None, :], list(rs.configs), "naive")
|
|
121
|
+
if method in ("wald", "paired_clt", "clustered_clt"):
|
|
122
|
+
if method == "clustered_clt" and not rs.is_clustered:
|
|
123
|
+
method = "paired_clt" # no groups: the clustered estimator is the paired one
|
|
124
|
+
return _formula(rs, B, rng, cost_stat, method)
|
|
125
|
+
if method in ("boot_cluster_cr2", "boot_cluster_kish"):
|
|
126
|
+
mode = method[5:] if rs.is_clustered else "task"
|
|
127
|
+
return hierarchical_bootstrap(rs, n_boot=B, mode=mode, rng=rng, cost_stat=cost_stat)
|
|
128
|
+
if method == "boot_cluster_t":
|
|
129
|
+
mode = "cluster_t" if rs.is_clustered else "task"
|
|
130
|
+
return hierarchical_bootstrap(rs, n_boot=B, mode=mode, rng=rng, cost_stat=cost_stat)
|
|
131
|
+
mode = {"boot_both": "both", "boot_task": "task", "boot_group": "group",
|
|
132
|
+
"boot_cluster": "cluster"}[method]
|
|
133
|
+
if mode in ("group", "cluster") and not rs.is_clustered:
|
|
134
|
+
mode = "task"
|
|
135
|
+
return hierarchical_bootstrap(rs, n_boot=B, mode=mode, rng=rng, cost_stat=cost_stat)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Test the kinds of claims papers and leaderboards actually make.
|
|
2
|
+
|
|
3
|
+
A claim names a subject configuration and, usually, comparators:
|
|
4
|
+
|
|
5
|
+
top1 subject is the most accurate of {subject} + comparators
|
|
6
|
+
pairwise subject is more accurate than each comparator
|
|
7
|
+
margin subject beats each comparator by at least `margin` (accuracy, 0-1)
|
|
8
|
+
cost_dominance subject is at least as accurate and no more costly than each comparator
|
|
9
|
+
frontier subject is on the cost-accuracy Pareto frontier of the given set
|
|
10
|
+
topk at most k-1 comparators are more accurate than subject ("#k", "top 5")
|
|
11
|
+
equivalent subject is within `margin` of each comparator ("as good as", "on par")
|
|
12
|
+
cheaper_noninferior subject costs less than each comparator and is no more than
|
|
13
|
+
`margin` less accurate ("as good as, but cheaper")
|
|
14
|
+
|
|
15
|
+
Each claim gets a bootstrap probability. It is *supported* at level `thr`
|
|
16
|
+
(default 0.9) when that probability reaches it -- the same certification rule
|
|
17
|
+
used throughout the paper. "Not supported" means the data cannot distinguish
|
|
18
|
+
the claim from its negation at that confidence; it does not mean the claim is
|
|
19
|
+
false.
|
|
20
|
+
|
|
21
|
+
from claimeval import RolloutSet
|
|
22
|
+
from claimeval.claims import Claim, evaluate_claims
|
|
23
|
+
res = evaluate_claims(rs, [Claim("top1", "agentA", ["agentB", "agentC"])])
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from claimeval.stats.bootstrap import Mode, auto_mode, hierarchical_bootstrap
|
|
33
|
+
from claimeval.stats.frontier import pareto_mask
|
|
34
|
+
from claimeval.core.schema import RolloutSet
|
|
35
|
+
|
|
36
|
+
KINDS = ("top1", "pairwise", "margin", "cost_dominance", "frontier", "topk", "equivalent",
|
|
37
|
+
"cheaper_noninferior")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Claim:
|
|
42
|
+
kind: str
|
|
43
|
+
subject: str
|
|
44
|
+
comparators: list[str] = field(default_factory=list)
|
|
45
|
+
margin: float = 0.0
|
|
46
|
+
id: str = ""
|
|
47
|
+
k: int = 1
|
|
48
|
+
|
|
49
|
+
def __post_init__(self):
|
|
50
|
+
if self.kind not in KINDS:
|
|
51
|
+
raise ValueError(f"unknown claim kind {self.kind!r}; expected one of {KINDS}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def claim_probability(acc: np.ndarray, cost: np.ndarray, claim: Claim, idx: dict) -> np.ndarray:
|
|
55
|
+
"""Per-replicate truth value of the claim. acc, cost: (B, n_configs)."""
|
|
56
|
+
s = idx[claim.subject]
|
|
57
|
+
comp = [idx[c] for c in claim.comparators]
|
|
58
|
+
a, c = acc[:, s], cost[:, s]
|
|
59
|
+
if claim.kind == "top1":
|
|
60
|
+
return np.all(a[:, None] > acc[:, comp], axis=1)
|
|
61
|
+
if claim.kind == "pairwise":
|
|
62
|
+
return np.all(a[:, None] > acc[:, comp], axis=1)
|
|
63
|
+
if claim.kind == "margin":
|
|
64
|
+
return np.all(a[:, None] - acc[:, comp] >= claim.margin, axis=1)
|
|
65
|
+
if claim.kind == "topk":
|
|
66
|
+
return (acc[:, comp] > a[:, None]).sum(axis=1) <= claim.k - 1
|
|
67
|
+
if claim.kind == "equivalent":
|
|
68
|
+
return np.all(np.abs(a[:, None] - acc[:, comp]) <= claim.margin, axis=1)
|
|
69
|
+
if claim.kind == "cheaper_noninferior":
|
|
70
|
+
return np.all((c[:, None] < cost[:, comp]) & (a[:, None] >= acc[:, comp] - claim.margin), axis=1)
|
|
71
|
+
if claim.kind == "cost_dominance":
|
|
72
|
+
return np.all((a[:, None] >= acc[:, comp]) & (c[:, None] <= cost[:, comp]), axis=1)
|
|
73
|
+
keep = [s] + comp
|
|
74
|
+
return pareto_mask(cost[:, keep], acc[:, keep])[:, 0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def evaluate_claims(rs: RolloutSet, claims: list[Claim], n_boot: int = 4000, mode: Mode | None = None,
|
|
78
|
+
rng: np.random.Generator | None = None, thr: float = 0.9,
|
|
79
|
+
cost_stat: str = "median") -> list[dict]:
|
|
80
|
+
"""Bootstrap probability and verdict for each claim, from one shared set of draws."""
|
|
81
|
+
rng = rng or np.random.default_rng(0)
|
|
82
|
+
mode = mode or auto_mode(rs)
|
|
83
|
+
b = hierarchical_bootstrap(rs, n_boot, mode, rng, cost_stat)
|
|
84
|
+
acc0, cost0 = rs.point_estimates(cost_stat)
|
|
85
|
+
idx = {k: i for i, k in enumerate(rs.configs)}
|
|
86
|
+
out = []
|
|
87
|
+
for cl in claims:
|
|
88
|
+
missing = [k for k in [cl.subject, *cl.comparators] if k not in idx]
|
|
89
|
+
if missing:
|
|
90
|
+
raise KeyError(f"claim {cl.id or cl.subject}: unknown configs {missing}")
|
|
91
|
+
p = float(claim_probability(b.acc, b.cost, cl, idx).mean())
|
|
92
|
+
pt = bool(claim_probability(acc0[None], cost0[None], cl, idx)[0])
|
|
93
|
+
out.append({"id": cl.id, "kind": cl.kind, "subject": cl.subject,
|
|
94
|
+
"comparators": list(cl.comparators), "probability": p,
|
|
95
|
+
"holds_at_point_estimate": pt, "supported": p >= thr, "mode": mode,
|
|
96
|
+
"subject_acc": float(acc0[idx[cl.subject]])})
|
|
97
|
+
return out
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Head-to-head comparison of uncertainty methods on shared simulated data.
|
|
2
|
+
|
|
3
|
+
Each replication draws one dataset from a scenario with known truth; every
|
|
4
|
+
method sees that same dataset. Reported per method:
|
|
5
|
+
|
|
6
|
+
acc_cov / cost_cov 95% interval coverage (target 0.95)
|
|
7
|
+
acc_w / cost_w mean interval width (coverage bought by width is no win)
|
|
8
|
+
gap_cov coverage of accuracy differences between adjacent configs
|
|
9
|
+
dom_prec share of certified dominance claims (P >= 0.9) that are true
|
|
10
|
+
dom_n certified dominance claims per replication (power)
|
|
11
|
+
dom_ece calibration error of P(i dominates j)
|
|
12
|
+
front_prec/recall certified frontier membership vs the true frontier
|
|
13
|
+
|
|
14
|
+
"naive" has no intervals; its claims are the point-estimate claims, asserted
|
|
15
|
+
with certainty, which is what current practice does.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from .baselines import run_method
|
|
26
|
+
from claimeval.sim.coverage import Calibration
|
|
27
|
+
from claimeval.stats.frontier import dominance, pareto_mask
|
|
28
|
+
from claimeval.core.schema import RolloutSet
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Scenario:
|
|
33
|
+
name: str
|
|
34
|
+
draw: Callable[[np.random.Generator], RolloutSet] # one dataset
|
|
35
|
+
true_acc: np.ndarray
|
|
36
|
+
true_cost: np.ndarray
|
|
37
|
+
cost_stat: str = "median"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compare(scenario: Scenario, methods, reps: int = 200, B: int = 800,
|
|
41
|
+
thr: float = 0.9, level: float = 0.95, seed: int = 0) -> list[dict]:
|
|
42
|
+
rng = np.random.default_rng(seed)
|
|
43
|
+
ta, tc = scenario.true_acc, scenario.true_cost
|
|
44
|
+
true_dom = dominance(tc, ta)
|
|
45
|
+
np.fill_diagonal(true_dom, False)
|
|
46
|
+
true_front = pareto_mask(tc, ta)
|
|
47
|
+
order = np.argsort(ta)
|
|
48
|
+
adj = list(zip(order[1:], order[:-1])) # (better, worse) by true accuracy
|
|
49
|
+
lo_q, hi_q = (1 - level) / 2, 1 - (1 - level) / 2
|
|
50
|
+
|
|
51
|
+
acc_names = {m: {k: [] for k in ("acc_cov", "cost_cov", "acc_w", "cost_w", "gap_cov",
|
|
52
|
+
"dom_true", "front_p", "front_r")} for m in methods}
|
|
53
|
+
dom_n = {m: 0 for m in methods}
|
|
54
|
+
cal = {m: Calibration() for m in methods}
|
|
55
|
+
|
|
56
|
+
for _ in range(reps):
|
|
57
|
+
rs = scenario.draw(rng)
|
|
58
|
+
idx = {k: i for i, k in enumerate(rs.configs)}
|
|
59
|
+
assert len(idx) == len(ta), "scenario configs must match truth"
|
|
60
|
+
a_hat, c_hat = rs.point_estimates(scenario.cost_stat)
|
|
61
|
+
naive_dom = dominance(c_hat, a_hat)
|
|
62
|
+
np.fill_diagonal(naive_dom, False)
|
|
63
|
+
naive_front = pareto_mask(c_hat, a_hat)
|
|
64
|
+
for m in methods:
|
|
65
|
+
b = run_method(rs, m, B=B, rng=rng, cost_stat=scenario.cost_stat)
|
|
66
|
+
r = acc_names[m]
|
|
67
|
+
if m != "naive":
|
|
68
|
+
alo, ahi = np.quantile(b.acc, [lo_q, hi_q], axis=0)
|
|
69
|
+
clo, chi = np.quantile(b.cost, [lo_q, hi_q], axis=0)
|
|
70
|
+
r["acc_cov"].append(np.mean((alo <= ta) & (ta <= ahi)))
|
|
71
|
+
r["cost_cov"].append(np.mean((clo <= tc) & (tc <= chi)))
|
|
72
|
+
r["acc_w"].append(np.mean(ahi - alo))
|
|
73
|
+
r["cost_w"].append(np.mean((chi - clo) / np.maximum(tc, 1e-12)))
|
|
74
|
+
gd = [b.acc[:, i] - b.acc[:, j] for i, j in adj]
|
|
75
|
+
r["gap_cov"].append(np.mean([
|
|
76
|
+
np.quantile(g, lo_q) <= ta[i] - ta[j] <= np.quantile(g, hi_q)
|
|
77
|
+
for g, (i, j) in zip(gd, adj)]))
|
|
78
|
+
pd = b.dominance_prob()
|
|
79
|
+
pf = b.frontier_prob()
|
|
80
|
+
off = ~np.eye(len(ta), dtype=bool)
|
|
81
|
+
cal[m].pred = np.append(cal[m].pred, pd[off])
|
|
82
|
+
cal[m].actual = np.append(cal[m].actual, true_dom[off].astype(float))
|
|
83
|
+
cert_dom = naive_dom & (pd >= thr)
|
|
84
|
+
cert_front = naive_front & (pf >= thr)
|
|
85
|
+
else:
|
|
86
|
+
cert_dom, cert_front = naive_dom, naive_front
|
|
87
|
+
dom_n[m] += int(cert_dom.sum())
|
|
88
|
+
r["dom_true"].extend(true_dom[cert_dom].tolist())
|
|
89
|
+
if cert_front.any():
|
|
90
|
+
r["front_p"].append(true_front[cert_front].mean())
|
|
91
|
+
r["front_r"].append(cert_front[true_front].mean() if true_front.any() else np.nan)
|
|
92
|
+
|
|
93
|
+
rows = []
|
|
94
|
+
for m in methods:
|
|
95
|
+
r = acc_names[m]
|
|
96
|
+
mean = lambda k: float(np.nanmean(r[k])) if r[k] else None # noqa: E731
|
|
97
|
+
rows.append({
|
|
98
|
+
"scenario": scenario.name, "method": m,
|
|
99
|
+
"acc_cov": mean("acc_cov"), "cost_cov": mean("cost_cov"),
|
|
100
|
+
"acc_w": mean("acc_w"), "cost_w": mean("cost_w"), "gap_cov": mean("gap_cov"),
|
|
101
|
+
"dom_prec": float(np.mean(r["dom_true"])) if r["dom_true"] else None,
|
|
102
|
+
"dom_n": dom_n[m] / reps,
|
|
103
|
+
"dom_ece": cal[m].ece() if m != "naive" else None,
|
|
104
|
+
"front_prec": mean("front_p"), "front_recall": mean("front_r"),
|
|
105
|
+
"reps": reps,
|
|
106
|
+
})
|
|
107
|
+
return rows
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_rows(rows: list[dict]) -> str:
|
|
111
|
+
def f(v, d=3):
|
|
112
|
+
return " --" if v is None else f"{v:.{d}f}"
|
|
113
|
+
head = (f"{'method':14s}{'acc cov':>8s}{'width':>7s}{'cost cov':>9s}{'gap cov':>8s}"
|
|
114
|
+
f"{'dom prec':>9s}{'dom/rep':>8s}{'dom ECE':>8s}{'front P':>8s}{'front R':>8s}")
|
|
115
|
+
out = [head, "-" * len(head)]
|
|
116
|
+
for r in rows:
|
|
117
|
+
out.append(f"{r['method']:14s}{f(r['acc_cov']):>8s}{f(r['acc_w']):>7s}"
|
|
118
|
+
f"{f(r['cost_cov']):>9s}{f(r['gap_cov']):>8s}{f(r['dom_prec']):>9s}"
|
|
119
|
+
f"{r['dom_n']:>8.1f}{f(r['dom_ece']):>8s}{f(r['front_prec']):>8s}"
|
|
120
|
+
f"{f(r['front_recall']):>8s}")
|
|
121
|
+
return "\n".join(out)
|