sys1bench 0.3.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.
- sys1bench/__init__.py +3 -0
- sys1bench/adapters/__init__.py +19 -0
- sys1bench/adapters/base.py +159 -0
- sys1bench/adapters/embed_knn.py +61 -0
- sys1bench/adapters/encoder_finetuned.py +114 -0
- sys1bench/adapters/generic_http.py +161 -0
- sys1bench/adapters/hybrid_router.py +49 -0
- sys1bench/adapters/jev_openrouter.py +172 -0
- sys1bench/adapters/jev_typesafe.py +165 -0
- sys1bench/adapters/laya_local.py +192 -0
- sys1bench/adapters/llm_constrained.py +104 -0
- sys1bench/adapters/majority_prior.py +53 -0
- sys1bench/adapters/mock.py +77 -0
- sys1bench/adapters/nli_zeroshot.py +50 -0
- sys1bench/adapters/regex_keyword.py +70 -0
- sys1bench/analysis/__init__.py +4 -0
- sys1bench/analysis/arms.py +46 -0
- sys1bench/analysis/decision_value.py +43 -0
- sys1bench/analysis/decomposition.py +47 -0
- sys1bench/analysis/meta_eval.py +67 -0
- sys1bench/analysis/stats.py +76 -0
- sys1bench/cli.py +434 -0
- sys1bench/data/__init__.py +37 -0
- sys1bench/data/canary/canary.jsonl +200 -0
- sys1bench/data/framings/guardrail_intent.yaml +21 -0
- sys1bench/data/framings/log_triage.yaml +20 -0
- sys1bench/data/framings/phishing_email.yaml +25 -0
- sys1bench/data/framings/policy_compliance.yaml +18 -0
- sys1bench/data/framings/support_tickets.yaml +41 -0
- sys1bench/framing/__init__.py +10 -0
- sys1bench/framing/expand.py +214 -0
- sys1bench/framing/perturb.py +67 -0
- sys1bench/generators/__init__.py +10 -0
- sys1bench/generators/base.py +105 -0
- sys1bench/generators/guardrail_intent.py +95 -0
- sys1bench/generators/log_triage.py +100 -0
- sys1bench/generators/multilingual_tickets.py +84 -0
- sys1bench/generators/phishing_email.py +127 -0
- sys1bench/generators/policy_compliance.py +90 -0
- sys1bench/generators/rag_relevance.py +122 -0
- sys1bench/generators/support_tickets.py +212 -0
- sys1bench/metrics/__init__.py +9 -0
- sys1bench/metrics/calibration.py +164 -0
- sys1bench/metrics/consistency.py +71 -0
- sys1bench/metrics/decision_value.py +43 -0
- sys1bench/metrics/efficiency.py +35 -0
- sys1bench/metrics/ordinal.py +80 -0
- sys1bench/metrics/robustness.py +46 -0
- sys1bench/metrics/selective.py +67 -0
- sys1bench/report/__init__.py +1 -0
- sys1bench/report/dashboard.py +177 -0
- sys1bench/report/latex.py +40 -0
- sys1bench/report/plots.py +143 -0
- sys1bench/report/results_doc.py +237 -0
- sys1bench/report/scorecard.py +170 -0
- sys1bench/runners/__init__.py +2 -0
- sys1bench/runners/benchmark_runner.py +149 -0
- sys1bench/runners/cache.py +53 -0
- sys1bench/runners/canary.py +43 -0
- sys1bench/runners/hybrid_sweep.py +37 -0
- sys1bench/runners/noul_consistency.py +121 -0
- sys1bench/runners/ordinal_probes.py +90 -0
- sys1bench/runners/robustness.py +112 -0
- sys1bench/runners/suite.py +183 -0
- sys1bench/runners/sweeps.py +190 -0
- sys1bench/schemas.py +274 -0
- sys1bench-0.3.0.dist-info/METADATA +195 -0
- sys1bench-0.3.0.dist-info/RECORD +72 -0
- sys1bench-0.3.0.dist-info/WHEEL +5 -0
- sys1bench-0.3.0.dist-info/entry_points.txt +2 -0
- sys1bench-0.3.0.dist-info/licenses/LICENSE +202 -0
- sys1bench-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Class-prior baseline. Priors are fitted from a manifest (or uniform if none given)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
from ..schemas import (
|
|
9
|
+
DecisionRequest,
|
|
10
|
+
DecisionResponse,
|
|
11
|
+
LatencyRecord,
|
|
12
|
+
ModelCapabilities,
|
|
13
|
+
ProviderRecord,
|
|
14
|
+
TaskItem,
|
|
15
|
+
)
|
|
16
|
+
from .base import BaseAdapter, finalize_answer, register
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@register("majority_prior")
|
|
20
|
+
class MajorityPriorAdapter(BaseAdapter):
|
|
21
|
+
def __init__(self, model_id: str = "majority_prior", **tunables) -> None:
|
|
22
|
+
super().__init__(model_id, **tunables)
|
|
23
|
+
self.priors: dict[str, Counter] = {}
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def capabilities(self) -> ModelCapabilities:
|
|
27
|
+
return ModelCapabilities(name=self.model_id, deployment="local", supports_batching=True)
|
|
28
|
+
|
|
29
|
+
def fit(self, items: list[TaskItem]) -> None:
|
|
30
|
+
for it in items:
|
|
31
|
+
for k, q in it.questions.items():
|
|
32
|
+
if q.ground_truth is None:
|
|
33
|
+
continue
|
|
34
|
+
gt = str(q.ground_truth).lower() if q.type == "noul" else str(q.ground_truth)
|
|
35
|
+
self.priors.setdefault(q.framing_group or k, Counter())[gt] += 1
|
|
36
|
+
|
|
37
|
+
def decide(self, request: DecisionRequest) -> DecisionResponse:
|
|
38
|
+
t0 = self._now_ms()
|
|
39
|
+
answers = {}
|
|
40
|
+
for k, q in request.questions.items():
|
|
41
|
+
keys = q.option_keys
|
|
42
|
+
counts = self.priors.get(q.framing_group or k)
|
|
43
|
+
if counts:
|
|
44
|
+
total = sum(counts.get(x, 0) for x in keys) + len(keys) * 1e-3
|
|
45
|
+
probs = [(counts.get(x, 0) + 1e-3) / total for x in keys]
|
|
46
|
+
else:
|
|
47
|
+
probs = [1.0 / len(keys)] * len(keys)
|
|
48
|
+
answers[k] = finalize_answer(q, probs)
|
|
49
|
+
return DecisionResponse(
|
|
50
|
+
answers=answers,
|
|
51
|
+
latency=LatencyRecord(client_ms=self._now_ms() - t0, timestamp=time.time()),
|
|
52
|
+
provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id, cost_usd=0.0),
|
|
53
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Deterministic synthetic model for offline tests and CI.
|
|
2
|
+
|
|
3
|
+
Behaviour is controlled so metric kernels can be validated end to end:
|
|
4
|
+
`skill` sets P(argmax == truth); `temperature` scales logits (>1 under-confident,
|
|
5
|
+
<1 over-confident); `position_bias` adds mass to index 0; `quantise` rounds probs.
|
|
6
|
+
It reads `ground_truth` only through a hash of the state so it behaves like a real
|
|
7
|
+
model that is right some fraction of the time, not like an oracle.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from ..schemas import DecisionRequest, DecisionResponse, LatencyRecord, ModelCapabilities, ProviderRecord
|
|
19
|
+
from .base import BaseAdapter, finalize_answer, register
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@register("mock")
|
|
23
|
+
class MockAdapter(BaseAdapter):
|
|
24
|
+
def __init__(self, model_id: str = "mock-v1", skill: float = 0.8, temperature: float = 1.0,
|
|
25
|
+
position_bias: float = 0.0, quantise: float | None = None, framing_sensitivity: float = 0.0,
|
|
26
|
+
latency_ms: float = 5.0, **tunables) -> None:
|
|
27
|
+
super().__init__(model_id, **tunables)
|
|
28
|
+
self.skill, self.temperature, self.position_bias = skill, temperature, position_bias
|
|
29
|
+
self.quantise, self.framing_sensitivity, self.latency_ms = quantise, framing_sensitivity, latency_ms
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def capabilities(self) -> ModelCapabilities:
|
|
33
|
+
return ModelCapabilities(name=self.model_id, deployment="local", max_options=255, emits_confidence=True,
|
|
34
|
+
supports_native_abstain=True, supports_batching=True)
|
|
35
|
+
|
|
36
|
+
def _rng(self, request: DecisionRequest, key: str) -> random.Random:
|
|
37
|
+
seed_src = f"{request.state_text()}|{key}|{self.model_id}"
|
|
38
|
+
if self.framing_sensitivity > 0:
|
|
39
|
+
seed_src += "|" + request.questions[key].instructions
|
|
40
|
+
return random.Random(int(hashlib.sha256(seed_src.encode()).hexdigest()[:16], 16))
|
|
41
|
+
|
|
42
|
+
def decide(self, request: DecisionRequest) -> DecisionResponse:
|
|
43
|
+
t0 = self._now_ms()
|
|
44
|
+
answers = {}
|
|
45
|
+
for key, q in request.questions.items():
|
|
46
|
+
rng = self._rng(request, key)
|
|
47
|
+
n = q.cardinality
|
|
48
|
+
keys = q.option_keys
|
|
49
|
+
truth = q.ground_truth
|
|
50
|
+
truth_key = None
|
|
51
|
+
if truth is not None:
|
|
52
|
+
truth_key = str(truth).lower() if q.type == "noul" else str(truth)
|
|
53
|
+
skill = self.skill
|
|
54
|
+
if self.framing_sensitivity > 0:
|
|
55
|
+
skill = max(0.0, min(1.0, skill + self.framing_sensitivity * (rng.random() - 0.5)))
|
|
56
|
+
if truth_key in keys and rng.random() < skill:
|
|
57
|
+
target = keys.index(truth_key)
|
|
58
|
+
else:
|
|
59
|
+
candidates = [i for i in range(n) if keys[i] != truth_key] or list(range(n))
|
|
60
|
+
target = rng.choice(candidates)
|
|
61
|
+
logits = np.array([rng.gauss(0, 1) for _ in range(n)])
|
|
62
|
+
logits[target] += 3.0
|
|
63
|
+
logits[0] += self.position_bias
|
|
64
|
+
logits = logits / self.temperature
|
|
65
|
+
probs = np.exp(logits - logits.max())
|
|
66
|
+
probs /= probs.sum()
|
|
67
|
+
if self.quantise:
|
|
68
|
+
probs = np.round(probs / self.quantise) * self.quantise
|
|
69
|
+
probs[int(probs.argmax())] += 1.0 - probs.sum() # keep sum exactly 1 like a vendor would
|
|
70
|
+
answers[key] = finalize_answer(q, list(probs), confidence=float(probs.max()))
|
|
71
|
+
return DecisionResponse(
|
|
72
|
+
answers=answers,
|
|
73
|
+
latency=LatencyRecord(client_ms=self.latency_ms + (self._now_ms() - t0), compute_ms=self.latency_ms,
|
|
74
|
+
timestamp=time.time()),
|
|
75
|
+
provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id,
|
|
76
|
+
model_id_returned=self.model_id, version_hash="mock", cost_usd=0.0),
|
|
77
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Zero-shot NLI baseline (BART-large-MNLI / DeBERTa-v3-large-MNLI). Hypothesis = label description."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ..schemas import DecisionRequest, DecisionResponse, LatencyRecord, ModelCapabilities, ProviderRecord
|
|
10
|
+
from .base import BaseAdapter, finalize_answer, register
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@register("nli_zeroshot")
|
|
14
|
+
class NLIZeroShotAdapter(BaseAdapter):
|
|
15
|
+
def __init__(self, model_id: str = "facebook/bart-large-mnli", device: int | str = -1,
|
|
16
|
+
hypothesis_template: str = "This text is about {}.", **tunables) -> None:
|
|
17
|
+
super().__init__(model_id, **tunables)
|
|
18
|
+
self.device, self.template = device, hypothesis_template
|
|
19
|
+
self._pipe = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def capabilities(self) -> ModelCapabilities:
|
|
23
|
+
return ModelCapabilities(name=f"nli:{self.model_id}", deployment="local", supports_batching=True)
|
|
24
|
+
|
|
25
|
+
def _p(self):
|
|
26
|
+
if self._pipe is None:
|
|
27
|
+
from transformers import pipeline # type: ignore
|
|
28
|
+
|
|
29
|
+
self._pipe = pipeline("zero-shot-classification", model=self.model_id, device=self.device)
|
|
30
|
+
return self._pipe
|
|
31
|
+
|
|
32
|
+
def decide(self, request: DecisionRequest) -> DecisionResponse:
|
|
33
|
+
t0 = time.perf_counter()
|
|
34
|
+
text = request.state_text()
|
|
35
|
+
answers = {}
|
|
36
|
+
for k, q in request.questions.items():
|
|
37
|
+
if q.type == "noul":
|
|
38
|
+
labels = [f"{q.instructions} yes", f"{q.instructions} no"]
|
|
39
|
+
elif q.type == "choice":
|
|
40
|
+
labels = [c.description or c.key for c in q.criteria] # type: ignore[union-attr]
|
|
41
|
+
else:
|
|
42
|
+
labels = [c.description or f"level {c.level}" for c in q.criteria] # type: ignore[union-attr]
|
|
43
|
+
out = self._p()(text, candidate_labels=labels, hypothesis_template=self.template, multi_label=False)
|
|
44
|
+
score = dict(zip(out["labels"], out["scores"]))
|
|
45
|
+
p = np.array([score[lab] for lab in labels])
|
|
46
|
+
p /= p.sum()
|
|
47
|
+
answers[k] = finalize_answer(q, [float(x) for x in p], confidence=float(p.max()))
|
|
48
|
+
ms = (time.perf_counter() - t0) * 1000
|
|
49
|
+
return DecisionResponse(answers=answers, latency=LatencyRecord(client_ms=ms, compute_ms=ms, timestamp=time.time()),
|
|
50
|
+
provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id, cost_usd=0.0))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Hand-written rule baseline. Rules come from a YAML file per domain:
|
|
2
|
+
|
|
3
|
+
queue:
|
|
4
|
+
billing: ["refund", "charged", "invoice"]
|
|
5
|
+
shipping: ["deliver", "tracking", "package"]
|
|
6
|
+
is_angry: ["unacceptable", "furious", "ridiculous"] # noul: any match -> true
|
|
7
|
+
priority: {"3": ["urgent", "asap"], "4": ["outage", "down"]} # score: highest matching level wins
|
|
8
|
+
|
|
9
|
+
Confidence is a fixed soft score (match -> 0.9 on the winner) so the baseline gets calibration
|
|
10
|
+
metrics too, which shows what a rule system's probabilities are worth.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from ..schemas import DecisionRequest, DecisionResponse, LatencyRecord, ModelCapabilities, ProviderRecord
|
|
22
|
+
from .base import BaseAdapter, finalize_answer, register
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@register("regex_keyword")
|
|
26
|
+
class RegexKeywordAdapter(BaseAdapter):
|
|
27
|
+
def __init__(self, model_id: str = "regex_keyword", rules_path: str | None = None, rules: dict | None = None,
|
|
28
|
+
hit_confidence: float = 0.9, **tunables) -> None:
|
|
29
|
+
super().__init__(model_id, **tunables)
|
|
30
|
+
if rules is None and rules_path:
|
|
31
|
+
rules = yaml.safe_load(Path(rules_path).read_text())
|
|
32
|
+
self.rules = rules or {}
|
|
33
|
+
self.hit_confidence = hit_confidence
|
|
34
|
+
self._compiled: dict[str, dict[str, list[re.Pattern]]] = {}
|
|
35
|
+
for qkey, spec in self.rules.items():
|
|
36
|
+
if isinstance(spec, list):
|
|
37
|
+
self._compiled[qkey] = {"true": [re.compile(p, re.IGNORECASE) for p in spec]}
|
|
38
|
+
else:
|
|
39
|
+
self._compiled[qkey] = {str(k): [re.compile(p, re.IGNORECASE) for p in v] for k, v in spec.items()}
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def capabilities(self) -> ModelCapabilities:
|
|
43
|
+
return ModelCapabilities(name=self.model_id, deployment="local", emits_confidence=True, supports_batching=True)
|
|
44
|
+
|
|
45
|
+
def decide(self, request: DecisionRequest) -> DecisionResponse:
|
|
46
|
+
t0 = self._now_ms()
|
|
47
|
+
text = request.state_text()
|
|
48
|
+
answers = {}
|
|
49
|
+
for k, q in request.questions.items():
|
|
50
|
+
keys = q.option_keys
|
|
51
|
+
rules = self._compiled.get(q.framing_group or k) or self._compiled.get(k) or {}
|
|
52
|
+
hits = {opt: sum(1 for p in pats if p.search(text)) for opt, pats in rules.items()}
|
|
53
|
+
hits = {o: h for o, h in hits.items() if h > 0 and (o in keys or q.type == "noul")}
|
|
54
|
+
n = len(keys)
|
|
55
|
+
if not hits:
|
|
56
|
+
probs = [1.0 / n] * n
|
|
57
|
+
elif q.type == "noul":
|
|
58
|
+
probs = [self.hit_confidence, 1.0 - self.hit_confidence]
|
|
59
|
+
elif q.type == "score":
|
|
60
|
+
winner = str(max(int(o) for o in hits))
|
|
61
|
+
probs = [(self.hit_confidence if o == winner else (1 - self.hit_confidence) / (n - 1)) for o in keys]
|
|
62
|
+
else:
|
|
63
|
+
winner = max(hits, key=hits.get)
|
|
64
|
+
probs = [(self.hit_confidence if o == winner else (1 - self.hit_confidence) / (n - 1)) for o in keys]
|
|
65
|
+
answers[k] = finalize_answer(q, probs, confidence=max(probs))
|
|
66
|
+
return DecisionResponse(
|
|
67
|
+
answers=answers,
|
|
68
|
+
latency=LatencyRecord(client_ms=self._now_ms() - t0, timestamp=time.time()),
|
|
69
|
+
provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id, cost_usd=0.0),
|
|
70
|
+
)
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
from .arms import arm_accuracy, label_noise_report, unknowable_report # noqa: F401
|
|
2
|
+
from .decomposition import decomposition_report # noqa: F401
|
|
3
|
+
from .meta_eval import label_order_report, leakage_report, short_circuit_report # noqa: F401
|
|
4
|
+
from .stats import cluster_bootstrap, holm, mcnemar, paired_bootstrap # noqa: F401
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Scoring for the control arms: unknowable (calibration ceiling), label-noise, corruption, short-circuit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ..schemas import PredictionRow, TaskItem
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def unknowable_report(rows: list[PredictionRow], question_key: str = "priority") -> dict:
|
|
11
|
+
"""On items whose label depends on removed information, a calibrated model should spread mass. For the ticket
|
|
12
|
+
priority arm the tier is removed, so the truth is one of two adjacent levels: report mass on the two candidates,
|
|
13
|
+
max-probability, and how often confidence exceeds 0.7."""
|
|
14
|
+
rs = [r for r in rows if r.question_key == question_key and not r.error and r.controls.unknowable]
|
|
15
|
+
if not rs:
|
|
16
|
+
return {"n": 0}
|
|
17
|
+
conf = np.array([max(r.probs) for r in rs])
|
|
18
|
+
return {"n": len(rs), "mean_max_prob": float(conf.mean()), "frac_conf_over_0.7": float((conf > 0.7).mean()),
|
|
19
|
+
"frac_conf_over_0.9": float((conf > 0.9).mean()),
|
|
20
|
+
"accuracy_vs_hidden_truth": float(np.mean([r.correct for r in rs if r.correct is not None])),
|
|
21
|
+
"mean_entropy_bits": float(np.mean([-(np.array(r.probs) + 1e-12).dot(np.log2(np.array(r.probs) + 1e-12)) for r in rs]))}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def label_noise_report(rows: list[PredictionRow], items: list["TaskItem"]) -> dict:
|
|
25
|
+
"""Manifest with a corrupted-label fraction: accuracy on corrupted rows should be near zero (the label is wrong, the
|
|
26
|
+
model should still answer the true thing) and confidence on them should not exceed confidence on clean rows.
|
|
27
|
+
Joined on task_id against the manifest so it works for rows produced by any runner version."""
|
|
28
|
+
noisy_ids = {it.task_id for it in items if it.label_provenance.noise_injected}
|
|
29
|
+
clean = [r for r in rows if not r.error and r.task_id not in noisy_ids]
|
|
30
|
+
noisy = [r for r in rows if not r.error and r.task_id in noisy_ids]
|
|
31
|
+
|
|
32
|
+
def f(rs):
|
|
33
|
+
acc = float(np.mean([r.correct for r in rs if r.correct is not None])) if rs else float("nan")
|
|
34
|
+
conf = float(np.mean([max(r.probs) for r in rs])) if rs else float("nan")
|
|
35
|
+
return acc, conf
|
|
36
|
+
|
|
37
|
+
ac, cc = f(clean)
|
|
38
|
+
an, cn = f(noisy)
|
|
39
|
+
return {"n_clean": len(clean), "n_noisy": len(noisy), "accuracy_clean": ac, "accuracy_on_corrupted_labels": an,
|
|
40
|
+
"confidence_clean": cc, "confidence_on_corrupted": cn,
|
|
41
|
+
"flag_more_confident_on_corrupted": bool(cn > cc + 0.02) if noisy else False}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def arm_accuracy(rows: list[PredictionRow], arm: str, question_key: str | None = None) -> float:
|
|
45
|
+
rs = [r for r in rows if r.arm == arm and r.correct is not None and (question_key is None or r.question_key == question_key)]
|
|
46
|
+
return float(np.mean([r.correct for r in rs])) if rs else float("nan")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Suite I: downstream decision value from the cost matrices shipped in every Tier G manifest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ..metrics.decision_value import expected_cost, value_of_calibration
|
|
10
|
+
from ..schemas import PredictionRow, TaskItem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def decision_value_report(rows: list[PredictionRow], items: list[TaskItem], escalate_cost: float | None = None,
|
|
14
|
+
thresholds: tuple[float, ...] = (0.5, 0.7, 0.9)) -> dict[str, Any]:
|
|
15
|
+
"""Per question with a cost matrix: mean realised cost per 10k decisions under argmax, Bayes (uses probabilities)
|
|
16
|
+
and act-or-escalate at each threshold, plus regret vs oracle and the value of calibration (argmax minus Bayes)."""
|
|
17
|
+
by_task = {it.task_id: it for it in items}
|
|
18
|
+
out: dict[str, Any] = {}
|
|
19
|
+
keys = sorted({r.question_key for r in rows})
|
|
20
|
+
for k in keys:
|
|
21
|
+
cm = next((it.cost_matrix.get(k) for it in items if it.cost_matrix and k in it.cost_matrix), None)
|
|
22
|
+
if not cm:
|
|
23
|
+
continue
|
|
24
|
+
rs = [r for r in rows if r.question_key == k and not r.error and r.ground_truth in r.option_keys and r.task_id in by_task
|
|
25
|
+
and r.permutation_id == "p0" and r.framing_id == "f0"]
|
|
26
|
+
if len(rs) < 20:
|
|
27
|
+
continue
|
|
28
|
+
labels = rs[0].option_keys
|
|
29
|
+
C = np.array([[float(cm.get(str(t), {}).get(str(p), 1.0 if t != p else 0.0)) for p in labels] for t in labels])
|
|
30
|
+
P = np.array([r.probs for r in rs])
|
|
31
|
+
y = np.array([labels.index(r.ground_truth) for r in rs])
|
|
32
|
+
esc = escalate_cost if escalate_cost is not None else float(np.median(C[C > 0])) if (C > 0).any() else 1.0
|
|
33
|
+
res = {"n": len(rs), "labels": labels, "escalate_cost": esc,
|
|
34
|
+
"argmax": expected_cost(P, y, C, "argmax"), "bayes": expected_cost(P, y, C, "bayes"),
|
|
35
|
+
"value_of_calibration_per_10k": value_of_calibration(P, y, C) * 10_000}
|
|
36
|
+
for t in thresholds:
|
|
37
|
+
res[f"escalate@{t}"] = expected_cost(P, y, C, "threshold", escalate_cost=esc, threshold=t)
|
|
38
|
+
# trivial policies for reference
|
|
39
|
+
prior = np.bincount(y, minlength=len(labels)) / len(y)
|
|
40
|
+
Pprior = np.tile(prior, (len(y), 1))
|
|
41
|
+
res["majority_prior_bayes"] = expected_cost(Pprior, y, C, "bayes")
|
|
42
|
+
out[k] = res
|
|
43
|
+
return out
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Suite B decomposition analysis: holistic question vs sub-questions combined by a fixed rule vs fitted weights.
|
|
2
|
+
|
|
3
|
+
Rows must contain, per task_id, one holistic noul row (question_key == target) and one row per sub-question
|
|
4
|
+
(metadata.decomposition_of == target). Weights are fitted on the first half of task_ids and evaluated on the second.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ..framing.expand import apply_decomposition_weights, decompose_fixed_rule, fit_decomposition_weights
|
|
12
|
+
from ..metrics.calibration import brier, ece
|
|
13
|
+
from ..schemas import PredictionRow
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def decomposition_report(rows: list[PredictionRow], target: str, rule: str = "any") -> dict:
|
|
17
|
+
hol = {r.task_id: r for r in rows if r.question_key == target and not r.error and r.framing_id == "f0" and r.permutation_id == "p0"}
|
|
18
|
+
subs: dict[str, dict[str, PredictionRow]] = {}
|
|
19
|
+
for r in rows:
|
|
20
|
+
if r.metadata.get("decomposition_of") == target and not r.error and r.framing_id == "f0" and r.permutation_id == "p0":
|
|
21
|
+
subs.setdefault(r.task_id, {})[r.question_key] = r
|
|
22
|
+
tids = sorted(t for t in hol if t in subs)
|
|
23
|
+
if len(tids) < 20:
|
|
24
|
+
return {"n": len(tids), "note": "too few paired items"}
|
|
25
|
+
sub_keys = sorted({k for t in tids for k in subs[t]})
|
|
26
|
+
X = np.array([[subs[t][k].probs[0] if k in subs[t] else 0.5 for k in sub_keys] for t in tids])
|
|
27
|
+
y = np.array([1.0 if hol[t].ground_truth == "true" else 0.0 for t in tids])
|
|
28
|
+
p_hol = np.array([hol[t].probs[0] for t in tids])
|
|
29
|
+
p_rule = np.array([decompose_fixed_rule(dict(zip(sub_keys, x)), rule) for x in X])
|
|
30
|
+
half = len(tids) // 2
|
|
31
|
+
w = fit_decomposition_weights(X[:half], y[:half])
|
|
32
|
+
p_fit = apply_decomposition_weights(X[half:], w)
|
|
33
|
+
|
|
34
|
+
def summarise(p: np.ndarray, yy: np.ndarray) -> dict:
|
|
35
|
+
pred = p >= 0.5
|
|
36
|
+
P2 = np.stack([p, 1 - p], 1)
|
|
37
|
+
yi = (1 - yy).astype(int) # index 0 == "true"
|
|
38
|
+
return {"accuracy": float((pred == yy.astype(bool)).mean()), "brier": brier(P2, yi),
|
|
39
|
+
"ece15": ece(P2.max(1), (P2.argmax(1) == yi).astype(float))}
|
|
40
|
+
|
|
41
|
+
out = {"n": len(tids), "sub_questions": sub_keys, "rule": rule,
|
|
42
|
+
"holistic": summarise(p_hol, y), "fixed_rule": summarise(p_rule, y),
|
|
43
|
+
"holistic_eval_half": summarise(p_hol[half:], y[half:]), "fitted_weights_eval_half": summarise(p_fit, y[half:]),
|
|
44
|
+
"weights": dict(zip(sub_keys + ["bias"], [float(v) for v in w]))}
|
|
45
|
+
out["decomposition_gain_fixed"] = out["fixed_rule"]["accuracy"] - out["holistic"]["accuracy"]
|
|
46
|
+
out["decomposition_gain_fitted"] = out["fitted_weights_eval_half"]["accuracy"] - out["holistic_eval_half"]["accuracy"]
|
|
47
|
+
return out
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Benchmark audits: short-circuit, leakage, label-order bias, label-noise control."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ..metrics.consistency import positional_bias_chi2
|
|
11
|
+
from ..schemas import PredictionRow, TaskItem
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _acc(rows: list[PredictionRow]) -> float:
|
|
15
|
+
c = [r.correct for r in rows if r.correct is not None]
|
|
16
|
+
return float(np.mean(c)) if c else float("nan")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def short_circuit_report(full: list[PredictionRow], state_only: list[PredictionRow], options_only: list[PredictionRow],
|
|
20
|
+
prior_acc: float, margin: float = 0.10) -> dict:
|
|
21
|
+
a_full, a_state, a_opts = _acc(full), _acc(state_only), _acc(options_only)
|
|
22
|
+
return {"accuracy_full": a_full, "accuracy_state_only": a_state, "accuracy_options_only": a_opts,
|
|
23
|
+
"accuracy_prior": prior_acc,
|
|
24
|
+
"flag_state_only": bool(a_state > prior_acc + margin), "flag_options_only": bool(a_opts > prior_acc + margin)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ngrams(text: str, n: int) -> set[tuple[str, ...]]:
|
|
28
|
+
toks = re.findall(r"[a-z0-9]+", text.lower())
|
|
29
|
+
return {tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def leakage_report(items: list[TaskItem], n: int = 1) -> dict:
|
|
33
|
+
"""Fraction of items where the true option's key or description n-grams appear in the state."""
|
|
34
|
+
hits, total, per_q = 0, 0, Counter()
|
|
35
|
+
for it in items:
|
|
36
|
+
state = it.state_text()
|
|
37
|
+
sg = _ngrams(state, n)
|
|
38
|
+
for k, q in it.questions.items():
|
|
39
|
+
if q.type != "choice" or q.ground_truth is None:
|
|
40
|
+
continue
|
|
41
|
+
total += 1
|
|
42
|
+
opt = next((o for o in q.criteria if o.key == q.ground_truth), None) # type: ignore[union-attr]
|
|
43
|
+
if opt is None:
|
|
44
|
+
continue
|
|
45
|
+
key_words = set(re.findall(r"[a-z0-9]+", opt.key.lower()))
|
|
46
|
+
leak = bool(sg & {(w,) for w in key_words}) if n == 1 else bool(sg & _ngrams(opt.description, n))
|
|
47
|
+
if leak:
|
|
48
|
+
hits += 1
|
|
49
|
+
per_q[k] += 1
|
|
50
|
+
return {"n_choice_questions": total, "leak_rate": hits / total if total else float("nan"), "per_question": dict(per_q)}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def label_order_report(rows: list[PredictionRow]) -> dict:
|
|
54
|
+
"""Over permuted rows, test whether argmax index prefers a position."""
|
|
55
|
+
by_k: dict[int, list[int]] = {}
|
|
56
|
+
for r in rows:
|
|
57
|
+
if r.primitive != "choice" or r.error or not r.argmax:
|
|
58
|
+
continue
|
|
59
|
+
by_k.setdefault(r.cardinality, []).append(r.option_keys.index(r.argmax))
|
|
60
|
+
return {k: positional_bias_chi2(np.array(v), k) for k, v in by_k.items()}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def label_noise_control(rows_clean: list[PredictionRow], rows_noisy: list[PredictionRow]) -> dict:
|
|
64
|
+
"""On corrupted labels a model should be ~wrong at the corruption rate and NOT more confident."""
|
|
65
|
+
conf = lambda rs: float(np.mean([max(r.probs) for r in rs if not r.error])) if rs else float("nan")
|
|
66
|
+
return {"accuracy_clean": _acc(rows_clean), "accuracy_noisy_labels": _acc(rows_noisy),
|
|
67
|
+
"confidence_clean": conf(rows_clean), "confidence_noisy_labels": conf(rows_noisy)}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Item-level statistics. The item (or framing group) is the unit; seeds are not."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.stats import binomtest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def paired_bootstrap(a: np.ndarray, b: np.ndarray, stat: Callable[[np.ndarray], float] = np.mean,
|
|
12
|
+
n_boot: int = 10_000, seed: int = 0, ci: float = 0.95) -> dict[str, float]:
|
|
13
|
+
"""Bootstrap CI for stat(a) - stat(b) over paired items."""
|
|
14
|
+
a, b = np.asarray(a, float), np.asarray(b, float)
|
|
15
|
+
assert a.shape == b.shape
|
|
16
|
+
rng = np.random.default_rng(seed)
|
|
17
|
+
n = len(a)
|
|
18
|
+
idx = rng.integers(0, n, size=(n_boot, n))
|
|
19
|
+
diffs = np.array([stat(a[i]) - stat(b[i]) for i in idx])
|
|
20
|
+
lo, hi = np.quantile(diffs, [(1 - ci) / 2, 1 - (1 - ci) / 2])
|
|
21
|
+
point = float(stat(a) - stat(b))
|
|
22
|
+
return {"diff": point, "ci_low": float(lo), "ci_high": float(hi),
|
|
23
|
+
"frac_sign_holds": float((np.sign(diffs) == np.sign(point)).mean()) if point != 0 else 0.5,
|
|
24
|
+
"n": int(n)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bootstrap_ci(x: np.ndarray, stat: Callable[[np.ndarray], float] = np.mean, n_boot: int = 10_000, seed: int = 0,
|
|
28
|
+
ci: float = 0.95) -> tuple[float, float, float]:
|
|
29
|
+
x = np.asarray(x, float)
|
|
30
|
+
rng = np.random.default_rng(seed)
|
|
31
|
+
vals = np.array([stat(x[rng.integers(0, len(x), len(x))]) for _ in range(n_boot)])
|
|
32
|
+
lo, hi = np.quantile(vals, [(1 - ci) / 2, 1 - (1 - ci) / 2])
|
|
33
|
+
return float(stat(x)), float(lo), float(hi)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def mcnemar(correct_a: np.ndarray, correct_b: np.ndarray) -> dict[str, float]:
|
|
37
|
+
a, b = np.asarray(correct_a, bool), np.asarray(correct_b, bool)
|
|
38
|
+
n01 = int((a & ~b).sum())
|
|
39
|
+
n10 = int((~a & b).sum())
|
|
40
|
+
if n01 + n10 == 0:
|
|
41
|
+
return {"n_a_only": n01, "n_b_only": n10, "p": 1.0}
|
|
42
|
+
p = binomtest(n01, n01 + n10, 0.5).pvalue
|
|
43
|
+
return {"n_a_only": n01, "n_b_only": n10, "p": float(p)}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cluster_bootstrap(values: np.ndarray, clusters: np.ndarray, stat: Callable[[np.ndarray], float] = np.mean,
|
|
47
|
+
n_boot: int = 5_000, seed: int = 0) -> tuple[float, float, float]:
|
|
48
|
+
"""Resample clusters (e.g. task_id when paraphrases are pooled) rather than rows."""
|
|
49
|
+
values, clusters = np.asarray(values, float), np.asarray(clusters)
|
|
50
|
+
uniq = np.unique(clusters)
|
|
51
|
+
groups = [values[clusters == c] for c in uniq]
|
|
52
|
+
rng = np.random.default_rng(seed)
|
|
53
|
+
vals = []
|
|
54
|
+
for _ in range(n_boot):
|
|
55
|
+
pick = rng.integers(0, len(groups), len(groups))
|
|
56
|
+
vals.append(stat(np.concatenate([groups[i] for i in pick])))
|
|
57
|
+
lo, hi = np.quantile(vals, [0.025, 0.975])
|
|
58
|
+
return float(stat(values)), float(lo), float(hi)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def holm(pvals: dict[str, float], alpha: float = 0.05) -> dict[str, bool]:
|
|
62
|
+
items = sorted(pvals.items(), key=lambda kv: kv[1])
|
|
63
|
+
m = len(items)
|
|
64
|
+
out, stop = {}, False
|
|
65
|
+
for i, (k, p) in enumerate(items):
|
|
66
|
+
if not stop and p <= alpha / (m - i):
|
|
67
|
+
out[k] = True
|
|
68
|
+
else:
|
|
69
|
+
stop = True
|
|
70
|
+
out[k] = False
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def power_note(n: int) -> str:
|
|
75
|
+
half_width = 1.96 * np.sqrt(0.25 / n) * 100
|
|
76
|
+
return f"n={n}: accuracy 95% CI half-width about ±{half_width:.1f} points"
|