poorjev 0.1.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.
- poorjev/__init__.py +33 -0
- poorjev/backends/__init__.py +4 -0
- poorjev/backends/local_nli.py +108 -0
- poorjev/calibration.py +164 -0
- poorjev/cli.py +184 -0
- poorjev/client.py +96 -0
- poorjev/evaluate.py +95 -0
- poorjev/mcp_server.py +185 -0
- poorjev/metrics.py +137 -0
- poorjev/plots.py +119 -0
- poorjev/primitives.py +280 -0
- poorjev-0.1.0.dist-info/METADATA +264 -0
- poorjev-0.1.0.dist-info/RECORD +16 -0
- poorjev-0.1.0.dist-info/WHEEL +4 -0
- poorjev-0.1.0.dist-info/entry_points.txt +2 -0
- poorjev-0.1.0.dist-info/licenses/LICENSE +21 -0
poorjev/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""poorjev: the poor man's Jev.
|
|
2
|
+
|
|
3
|
+
A local-first "System One" decision layer. Ask typed questions about some state,
|
|
4
|
+
get typed answers with calibrated confidence, in one pass, with no API key by
|
|
5
|
+
default. The one thing we prove: the confidence is honest.
|
|
6
|
+
|
|
7
|
+
M1 ships the contract (the three primitives + typed answers). Backends (M2/M5)
|
|
8
|
+
and calibration (M4) build on top without ever being able to break schema
|
|
9
|
+
validity, which is structural. See PRD.md.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .primitives import (
|
|
13
|
+
Choice,
|
|
14
|
+
Score,
|
|
15
|
+
Noul,
|
|
16
|
+
ChoiceAnswer,
|
|
17
|
+
ScoreAnswer,
|
|
18
|
+
NoulAnswer,
|
|
19
|
+
)
|
|
20
|
+
from .client import Client
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Client",
|
|
26
|
+
"Choice",
|
|
27
|
+
"Score",
|
|
28
|
+
"Noul",
|
|
29
|
+
"ChoiceAnswer",
|
|
30
|
+
"ScoreAnswer",
|
|
31
|
+
"NoulAnswer",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Local, keyless NLI backend, the hero.
|
|
2
|
+
|
|
3
|
+
One small natural-language-inference model does all three primitives by scoring
|
|
4
|
+
(state, hypothesis) pairs for entailment:
|
|
5
|
+
|
|
6
|
+
- Noul -> P(entailment) of the statement given the state = P(true).
|
|
7
|
+
- Choice -> score each option as a hypothesis, softmax across options.
|
|
8
|
+
- Score -> same, over the ordered levels.
|
|
9
|
+
|
|
10
|
+
Why NLI and not a sampled LLM: it is one forward pass (Jev's speed ballpark, not
|
|
11
|
+
seconds), it is fully local and offline after a one-time ~400MB download, and its
|
|
12
|
+
softmax output is a real probability we can calibrate directly, no sampling tax.
|
|
13
|
+
It is not the smartest option; that is the point of the optional [llm] backend.
|
|
14
|
+
Here, calibration + abstention are what make moderate intelligence safe: the
|
|
15
|
+
model knows when it is unsure and escalates.
|
|
16
|
+
|
|
17
|
+
The heavy imports (torch, transformers) are lazy so importing poorjev, and the
|
|
18
|
+
whole M1 contract, stays dependency-free.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
DEFAULT_MODEL = "MoritzLaurer/deberta-v3-base-zeroshot-v2.0"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LocalNLIBackend:
|
|
27
|
+
"""Scores (premise, hypothesis) pairs and returns P(entailment) for each.
|
|
28
|
+
|
|
29
|
+
This is the only method the Client needs. Everything primitive-specific
|
|
30
|
+
(templating options, normalising across a Choice) lives in the Client so a
|
|
31
|
+
future backend can reuse the same orchestration.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, model_name: str = DEFAULT_MODEL, device: str | None = None,
|
|
35
|
+
batch_size: int = 16, max_length: int = 512):
|
|
36
|
+
self.model_name = model_name
|
|
37
|
+
self.batch_size = batch_size
|
|
38
|
+
self.max_length = max_length
|
|
39
|
+
self._device = device
|
|
40
|
+
self._tokenizer = None
|
|
41
|
+
self._model = None
|
|
42
|
+
self._entail_idx: int | None = None
|
|
43
|
+
|
|
44
|
+
# -- lazy load -------------------------------------------------------- #
|
|
45
|
+
def _ensure_loaded(self) -> None:
|
|
46
|
+
if self._model is not None:
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
import torch
|
|
50
|
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
|
51
|
+
except ImportError as e: # pragma: no cover - env dependent
|
|
52
|
+
raise ImportError(
|
|
53
|
+
"The local NLI backend needs the 'local' extra. "
|
|
54
|
+
"Install it with: pip install 'poorjev[local]'"
|
|
55
|
+
) from e
|
|
56
|
+
|
|
57
|
+
if self._device is None:
|
|
58
|
+
if torch.cuda.is_available():
|
|
59
|
+
self._device = "cuda"
|
|
60
|
+
elif getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
|
|
61
|
+
self._device = "mps"
|
|
62
|
+
else:
|
|
63
|
+
self._device = "cpu"
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
self._tokenizer = AutoTokenizer.from_pretrained(self.model_name)
|
|
67
|
+
except Exception: # deberta-v3 fast tokenizer can be finicky; fall back
|
|
68
|
+
self._tokenizer = AutoTokenizer.from_pretrained(self.model_name, use_fast=False)
|
|
69
|
+
|
|
70
|
+
self._model = AutoModelForSequenceClassification.from_pretrained(self.model_name)
|
|
71
|
+
self._model.to(self._device)
|
|
72
|
+
self._model.eval()
|
|
73
|
+
self._entail_idx = self._find_entail_idx(self._model.config)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _find_entail_idx(config) -> int:
|
|
77
|
+
id2label = getattr(config, "id2label", None) or {}
|
|
78
|
+
for idx, label in id2label.items():
|
|
79
|
+
name = str(label).lower()
|
|
80
|
+
if "entail" in name and "not" not in name and "non" not in name:
|
|
81
|
+
return int(idx)
|
|
82
|
+
# Fallback: many NLI heads put entailment first or last; default to 0.
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
# -- the one method the Client calls ---------------------------------- #
|
|
86
|
+
def entail_probs(self, pairs: list[tuple[str, str]]) -> list[float]:
|
|
87
|
+
"""Return P(entailment) in [0, 1] for each (premise, hypothesis) pair,
|
|
88
|
+
computed in batched forward passes (the single-pass promise)."""
|
|
89
|
+
if not pairs:
|
|
90
|
+
return []
|
|
91
|
+
self._ensure_loaded()
|
|
92
|
+
import torch
|
|
93
|
+
|
|
94
|
+
out: list[float] = []
|
|
95
|
+
for start in range(0, len(pairs), self.batch_size):
|
|
96
|
+
chunk = pairs[start:start + self.batch_size]
|
|
97
|
+
premises = [p for p, _ in chunk]
|
|
98
|
+
hypotheses = [h for _, h in chunk]
|
|
99
|
+
enc = self._tokenizer(
|
|
100
|
+
premises, hypotheses,
|
|
101
|
+
return_tensors="pt", padding=True,
|
|
102
|
+
truncation=True, max_length=self.max_length,
|
|
103
|
+
).to(self._device)
|
|
104
|
+
with torch.no_grad():
|
|
105
|
+
logits = self._model(**enc).logits
|
|
106
|
+
probs = torch.softmax(logits, dim=-1)[:, self._entail_idx]
|
|
107
|
+
out.extend(probs.detach().cpu().tolist())
|
|
108
|
+
return out
|
poorjev/calibration.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Calibration: the one thing poorjev gets provably right.
|
|
2
|
+
|
|
3
|
+
Two pieces:
|
|
4
|
+
|
|
5
|
+
1. Temperature scaling. A classifier's probabilities are usually wrong as
|
|
6
|
+
confidences: this model is overconfident by 0.170 ECE raw. Temperature scaling
|
|
7
|
+
fits a single scalar T that rescales every distribution, softmax(log p / T),
|
|
8
|
+
so predicted confidence lines up with real accuracy. T is fit by minimising
|
|
9
|
+
negative log-likelihood on held-out data. It is monotonic, so it never
|
|
10
|
+
changes which answer wins, only how confident we are in it.
|
|
11
|
+
|
|
12
|
+
2. Selective prediction. Given a target risk (max error rate you will tolerate
|
|
13
|
+
on answered items), pick the confidence threshold that holds that risk on a
|
|
14
|
+
calibration set. Below it, abstain, that is the honest "I don't know,
|
|
15
|
+
escalate" signal.
|
|
16
|
+
|
|
17
|
+
To report an honest "after" number we fit T on training folds and measure the
|
|
18
|
+
calibrated ECE on the held-out fold (k-fold CV), so we never grade calibration
|
|
19
|
+
on data it was fit on. Pure stdlib.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
import random
|
|
26
|
+
|
|
27
|
+
from .metrics import DecisionRecord, ece as _ece
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --------------------------------------------------------------------------- #
|
|
31
|
+
# Temperature scaling
|
|
32
|
+
# --------------------------------------------------------------------------- #
|
|
33
|
+
|
|
34
|
+
def apply_temperature(dist, T: float):
|
|
35
|
+
"""Rescale a probability distribution by temperature T (softmax(log p / T)).
|
|
36
|
+
|
|
37
|
+
T = 1 is a no-op. T > 1 softens (less confident); T < 1 sharpens. Argmax is
|
|
38
|
+
preserved, so the picked class never changes.
|
|
39
|
+
"""
|
|
40
|
+
if T <= 0:
|
|
41
|
+
raise ValueError("temperature must be > 0")
|
|
42
|
+
logits = [math.log(max(p, 1e-12)) / T for p in dist]
|
|
43
|
+
m = max(logits)
|
|
44
|
+
exps = [math.exp(l - m) for l in logits]
|
|
45
|
+
s = sum(exps)
|
|
46
|
+
return [e / s for e in exps]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _nll(dists, gold_idx, T: float) -> float:
|
|
50
|
+
total = 0.0
|
|
51
|
+
for d, g in zip(dists, gold_idx):
|
|
52
|
+
q = apply_temperature(d, T)
|
|
53
|
+
total += -math.log(max(q[g], 1e-12))
|
|
54
|
+
return total / len(dists)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fit_temperature(dists, gold_idx, coarse=None) -> float:
|
|
58
|
+
"""Fit T by minimising NLL, coarse grid then a local refine. Robust and
|
|
59
|
+
transparent, no optimiser dependency."""
|
|
60
|
+
if not dists:
|
|
61
|
+
return 1.0
|
|
62
|
+
coarse = coarse or [0.25, 0.4, 0.55, 0.7, 0.85, 1.0, 1.25, 1.5,
|
|
63
|
+
2.0, 2.5, 3.0, 4.0, 5.0, 6.0]
|
|
64
|
+
best = min(coarse, key=lambda T: _nll(dists, gold_idx, T))
|
|
65
|
+
# refine in a window around the coarse winner
|
|
66
|
+
step = 0.05
|
|
67
|
+
fine = [round(best + k * step, 4) for k in range(-6, 7) if best + k * step > 0.05]
|
|
68
|
+
best = min(fine + [best], key=lambda T: _nll(dists, gold_idx, T))
|
|
69
|
+
return best
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _records_with_dist(records: list[DecisionRecord]) -> list[DecisionRecord]:
|
|
73
|
+
return [r for r in records if r.dist and r.gold_index >= 0]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _recalibrate_record(r: DecisionRecord, T: float) -> DecisionRecord:
|
|
77
|
+
q = apply_temperature(r.dist, T)
|
|
78
|
+
return DecisionRecord(
|
|
79
|
+
confidence=max(q),
|
|
80
|
+
correct=r.correct, # monotonic: winner unchanged
|
|
81
|
+
prob_gold=q[r.gold_index],
|
|
82
|
+
n_classes=r.n_classes,
|
|
83
|
+
task=r.task, question=r.question,
|
|
84
|
+
dist=tuple(q), gold_index=r.gold_index,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cross_val_calibrate(records: list[DecisionRecord], k: int = 5, seed: int = 0):
|
|
89
|
+
"""Fit T on train folds, apply to the held-out fold, aggregate.
|
|
90
|
+
|
|
91
|
+
Returns (calibrated_records, mean_T). The calibrated records are honest
|
|
92
|
+
held-out predictions: no record was calibrated by a T fit on itself.
|
|
93
|
+
"""
|
|
94
|
+
usable = _records_with_dist(records)
|
|
95
|
+
if len(usable) < k:
|
|
96
|
+
T = fit_temperature([r.dist for r in usable], [r.gold_index for r in usable])
|
|
97
|
+
return [_recalibrate_record(r, T) for r in usable], T
|
|
98
|
+
|
|
99
|
+
rng = random.Random(seed)
|
|
100
|
+
idx = list(range(len(usable)))
|
|
101
|
+
rng.shuffle(idx)
|
|
102
|
+
folds = [idx[i::k] for i in range(k)]
|
|
103
|
+
|
|
104
|
+
calibrated: list[DecisionRecord] = []
|
|
105
|
+
temps: list[float] = []
|
|
106
|
+
for f in range(k):
|
|
107
|
+
test_ids = set(folds[f])
|
|
108
|
+
train = [usable[i] for i in idx if i not in test_ids]
|
|
109
|
+
test = [usable[i] for i in folds[f]]
|
|
110
|
+
T = fit_temperature([r.dist for r in train], [r.gold_index for r in train])
|
|
111
|
+
temps.append(T)
|
|
112
|
+
calibrated.extend(_recalibrate_record(r, T) for r in test)
|
|
113
|
+
return calibrated, sum(temps) / len(temps)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def fit_global_temperature(records: list[DecisionRecord]) -> float:
|
|
117
|
+
"""Fit one T on all data, for saving into a deployable calibrator."""
|
|
118
|
+
usable = _records_with_dist(records)
|
|
119
|
+
return fit_temperature([r.dist for r in usable], [r.gold_index for r in usable])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --------------------------------------------------------------------------- #
|
|
123
|
+
# Selective prediction (conformal-style thresholding)
|
|
124
|
+
# --------------------------------------------------------------------------- #
|
|
125
|
+
|
|
126
|
+
def fit_abstention_threshold(records: list[DecisionRecord], target_risk: float = 0.1):
|
|
127
|
+
"""Pick the lowest confidence threshold that keeps error rate on accepted
|
|
128
|
+
decisions at or below ``target_risk`` on this set.
|
|
129
|
+
|
|
130
|
+
Returns (threshold, coverage, realized_risk). Accept a decision at inference
|
|
131
|
+
when its confidence >= threshold; otherwise abstain and escalate.
|
|
132
|
+
"""
|
|
133
|
+
if not records:
|
|
134
|
+
return 1.0, 0.0, 0.0
|
|
135
|
+
ordered = sorted(records, key=lambda r: r.confidence, reverse=True)
|
|
136
|
+
n = len(ordered)
|
|
137
|
+
best = None # (threshold, coverage, risk) with the largest coverage under risk
|
|
138
|
+
errors = 0
|
|
139
|
+
for i, r in enumerate(ordered, start=1):
|
|
140
|
+
if not r.correct:
|
|
141
|
+
errors += 1
|
|
142
|
+
risk = errors / i
|
|
143
|
+
if risk <= target_risk:
|
|
144
|
+
best = (r.confidence, i / n, risk)
|
|
145
|
+
if best is None:
|
|
146
|
+
# even the single most-confident decision is wrong; abstain on everything
|
|
147
|
+
return 1.01, 0.0, 0.0
|
|
148
|
+
return best
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def summarize_calibration(raw: list[DecisionRecord], k: int = 5, target_risk: float = 0.1):
|
|
152
|
+
"""The M4 headline: ECE before vs after (held-out), plus a selective point."""
|
|
153
|
+
calibrated, mean_T = cross_val_calibrate(raw, k=k)
|
|
154
|
+
thr, cov, risk = fit_abstention_threshold(calibrated, target_risk)
|
|
155
|
+
return {
|
|
156
|
+
"temperature": mean_T,
|
|
157
|
+
"ece_before": _ece(_records_with_dist(raw)),
|
|
158
|
+
"ece_after": _ece(calibrated),
|
|
159
|
+
"target_risk": target_risk,
|
|
160
|
+
"abstain_threshold": thr,
|
|
161
|
+
"coverage_at_target": cov,
|
|
162
|
+
"risk_at_target": risk,
|
|
163
|
+
"calibrated_records": calibrated,
|
|
164
|
+
}
|
poorjev/cli.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""poorjev command line.
|
|
2
|
+
|
|
3
|
+
poorjev eval --set evalset/tasks.jsonl [--plots] [--verbose]
|
|
4
|
+
poorjev ask --state-file ticket.txt --questions questions.yaml (M4+)
|
|
5
|
+
poorjev calibrate --set evalset/tasks.jsonl (M4)
|
|
6
|
+
|
|
7
|
+
M3 ships ``eval``: run the set through the local backend and print accuracy +
|
|
8
|
+
ECE + Brier + AURC, overall and per task. ``--plots`` writes the reliability
|
|
9
|
+
diagram (the launch asset) once matplotlib is installed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fmt(summary: dict) -> str:
|
|
19
|
+
return (
|
|
20
|
+
f"n={summary['n']:>4} "
|
|
21
|
+
f"acc={summary['accuracy']:.3f} "
|
|
22
|
+
f"ECE={summary['ece']:.3f} "
|
|
23
|
+
f"Brier={summary['brier']:.3f} "
|
|
24
|
+
f"AURC={summary['aurc']:.3f}"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_eval(args) -> int:
|
|
29
|
+
from .evaluate import evaluate, report
|
|
30
|
+
from .client import Client
|
|
31
|
+
|
|
32
|
+
print(f"Running eval set: {args.set}")
|
|
33
|
+
print("(first run downloads a ~400MB model; then offline)\n")
|
|
34
|
+
client = Client()
|
|
35
|
+
records, _ = evaluate(args.set, client=client, verbose=args.verbose)
|
|
36
|
+
rep = report(records)
|
|
37
|
+
|
|
38
|
+
print("\n== overall ==")
|
|
39
|
+
print(" " + _fmt(rep["overall"]))
|
|
40
|
+
print("\n== by task ==")
|
|
41
|
+
for task, s in rep["by_task"].items():
|
|
42
|
+
print(f" {task:<16} " + _fmt(s))
|
|
43
|
+
|
|
44
|
+
if args.plots:
|
|
45
|
+
try:
|
|
46
|
+
from .plots import reliability_diagram
|
|
47
|
+
out = reliability_diagram(records, title="poorjev, raw (uncalibrated)",
|
|
48
|
+
path=args.plot_path)
|
|
49
|
+
print(f"\nwrote reliability diagram -> {out}")
|
|
50
|
+
except ImportError:
|
|
51
|
+
print("\n[--plots needs the 'plots' extra: pip install 'poorjev[plots]']")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_calibrate(args) -> int:
|
|
56
|
+
import json
|
|
57
|
+
from .evaluate import evaluate
|
|
58
|
+
from .client import Client
|
|
59
|
+
from .calibration import summarize_calibration, fit_global_temperature
|
|
60
|
+
|
|
61
|
+
print(f"Calibrating on: {args.set}")
|
|
62
|
+
print("(runs the eval set through the local model once)\n")
|
|
63
|
+
raw, _ = evaluate(args.set, client=Client())
|
|
64
|
+
|
|
65
|
+
summ = summarize_calibration(raw, k=args.folds, target_risk=args.target_risk)
|
|
66
|
+
global_T = fit_global_temperature(raw) # the deployable scalar
|
|
67
|
+
|
|
68
|
+
print("== calibration (5-fold held-out) ==")
|
|
69
|
+
print(f" temperature (mean) : {summ['temperature']:.3f}")
|
|
70
|
+
print(f" ECE before -> after : {summ['ece_before']:.3f} -> {summ['ece_after']:.3f}")
|
|
71
|
+
print(f" selective @ risk<={summ['target_risk']:.2f}: "
|
|
72
|
+
f"coverage={summ['coverage_at_target']:.2f} "
|
|
73
|
+
f"(threshold {summ['abstain_threshold']:.3f})")
|
|
74
|
+
|
|
75
|
+
saved = {
|
|
76
|
+
"temperature": global_T,
|
|
77
|
+
"target_risk": summ["target_risk"],
|
|
78
|
+
"abstain_threshold": summ["abstain_threshold"],
|
|
79
|
+
"ece_before": summ["ece_before"],
|
|
80
|
+
"ece_after": summ["ece_after"],
|
|
81
|
+
}
|
|
82
|
+
with open(args.out, "w") as f:
|
|
83
|
+
json.dump(saved, f, indent=2)
|
|
84
|
+
print(f"\nsaved calibrator -> {args.out}")
|
|
85
|
+
|
|
86
|
+
if args.plots:
|
|
87
|
+
try:
|
|
88
|
+
from .plots import reliability_pair, risk_coverage_plot
|
|
89
|
+
from .calibration import cross_val_calibrate
|
|
90
|
+
cal, _ = cross_val_calibrate(raw, k=args.folds)
|
|
91
|
+
p1 = reliability_pair(raw, cal, path=args.plot_path)
|
|
92
|
+
p2 = risk_coverage_plot(cal)
|
|
93
|
+
print(f"wrote {p1}")
|
|
94
|
+
print(f"wrote {p2}")
|
|
95
|
+
except ImportError:
|
|
96
|
+
print("[--plots needs the 'plots' extra: pip install 'poorjev[plots]']")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_ask(args) -> int:
|
|
101
|
+
import json
|
|
102
|
+
from .client import Client
|
|
103
|
+
|
|
104
|
+
temperature = 1.0
|
|
105
|
+
if args.calibrator:
|
|
106
|
+
try:
|
|
107
|
+
with open(args.calibrator) as f:
|
|
108
|
+
temperature = json.load(f).get("temperature", 1.0)
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
print(f"[no calibrator at {args.calibrator}; using raw confidence]",
|
|
111
|
+
file=sys.stderr)
|
|
112
|
+
|
|
113
|
+
if args.state_file:
|
|
114
|
+
with open(args.state_file) as f:
|
|
115
|
+
state = f.read().strip()
|
|
116
|
+
else:
|
|
117
|
+
state = args.state
|
|
118
|
+
if not state:
|
|
119
|
+
print("provide --state or --state-file", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
with open(args.questions) as f:
|
|
123
|
+
specs = json.load(f) # {name: {"type":..., ...}}
|
|
124
|
+
|
|
125
|
+
from .evaluate import _build_primitive
|
|
126
|
+
questions = {name: _build_primitive(spec) for name, spec in specs.items()}
|
|
127
|
+
res = Client(temperature=temperature).ask(state, questions)
|
|
128
|
+
|
|
129
|
+
print(json.dumps({
|
|
130
|
+
name: {
|
|
131
|
+
"value": ans.value,
|
|
132
|
+
"confidence": round(ans.confidence, 4),
|
|
133
|
+
} for name, ans in res.items()
|
|
134
|
+
}, indent=2))
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_serve(args) -> int:
|
|
139
|
+
from .mcp_server import main as serve_main
|
|
140
|
+
print("Starting poorjev MCP server (stdio). Add it to your MCP client config.",
|
|
141
|
+
file=sys.stderr)
|
|
142
|
+
serve_main(calibrator_path=args.calibrator)
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main(argv=None) -> int:
|
|
147
|
+
p = argparse.ArgumentParser(prog="poorjev", description="the poor man's Jev")
|
|
148
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
149
|
+
|
|
150
|
+
pe = sub.add_parser("eval", help="score the eval set (accuracy + calibration)")
|
|
151
|
+
pe.add_argument("--set", default="evalset/tasks.jsonl", help="path to tasks.jsonl")
|
|
152
|
+
pe.add_argument("--plots", action="store_true", help="write the reliability diagram")
|
|
153
|
+
pe.add_argument("--plot-path", default="reliability.png")
|
|
154
|
+
pe.add_argument("--verbose", action="store_true", help="print each item's answers")
|
|
155
|
+
pe.set_defaults(func=cmd_eval)
|
|
156
|
+
|
|
157
|
+
pa = sub.add_parser("ask", help="answer typed questions about one state")
|
|
158
|
+
pa.add_argument("--state", default="", help="the state text")
|
|
159
|
+
pa.add_argument("--state-file", help="read state from a file")
|
|
160
|
+
pa.add_argument("--questions", required=True, help="JSON file: {name: spec}")
|
|
161
|
+
pa.add_argument("--calibrator", default="calibration.json",
|
|
162
|
+
help="JSON from `poorjev calibrate` (applies the fitted temperature)")
|
|
163
|
+
pa.set_defaults(func=cmd_ask)
|
|
164
|
+
|
|
165
|
+
pc = sub.add_parser("calibrate", help="fit calibration on the eval set")
|
|
166
|
+
pc.add_argument("--set", default="evalset/tasks.jsonl")
|
|
167
|
+
pc.add_argument("--folds", type=int, default=5)
|
|
168
|
+
pc.add_argument("--target-risk", type=float, default=0.1)
|
|
169
|
+
pc.add_argument("--plots", action="store_true", help="write before/after diagrams")
|
|
170
|
+
pc.add_argument("--plot-path", default="docs/reliability_before_after.png")
|
|
171
|
+
pc.add_argument("--out", default="calibration.json")
|
|
172
|
+
pc.set_defaults(func=cmd_calibrate)
|
|
173
|
+
|
|
174
|
+
ps = sub.add_parser("serve", help="run the MCP server (for Claude Code etc.)")
|
|
175
|
+
ps.add_argument("--calibrator", default="calibration.json",
|
|
176
|
+
help="calibration.json to apply (fitted temperature)")
|
|
177
|
+
ps.set_defaults(func=cmd_serve)
|
|
178
|
+
|
|
179
|
+
args = p.parse_args(argv)
|
|
180
|
+
return args.func(args)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
raise SystemExit(main())
|
poorjev/client.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The Client: ask a batch of typed questions about one state, in a single pass.
|
|
2
|
+
|
|
3
|
+
The Client owns orchestration so backends stay simple. It:
|
|
4
|
+
1. turns each question into (state, hypothesis) pairs,
|
|
5
|
+
2. concatenates every pair across every question into ONE batched call,
|
|
6
|
+
3. hands the raw entailment probabilities back to each primitive to decide.
|
|
7
|
+
|
|
8
|
+
A backend only has to implement ``entail_probs(pairs) -> list[float]``. The
|
|
9
|
+
default backend is the local NLI model (keyless, offline). Pass your own backend
|
|
10
|
+
(e.g. a fake in tests, or the future [llm] backend) to swap it out.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
from .primitives import Choice, Score, Noul, ChoiceAnswer, ScoreAnswer, NoulAnswer
|
|
18
|
+
|
|
19
|
+
DEFAULT_TEMPLATE = "This example is {}."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Backend(Protocol):
|
|
23
|
+
def entail_probs(self, pairs: list[tuple[str, str]]) -> list[float]: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Client:
|
|
27
|
+
def __init__(self, backend: Backend | None = None,
|
|
28
|
+
hypothesis_template: str = DEFAULT_TEMPLATE,
|
|
29
|
+
temperature: float = 1.0):
|
|
30
|
+
self._backend = backend
|
|
31
|
+
self.hypothesis_template = hypothesis_template
|
|
32
|
+
# A fitted temperature (from `poorjev calibrate`) makes ask()'s
|
|
33
|
+
# confidences calibrated. 1.0 is a no-op (raw).
|
|
34
|
+
self.temperature = temperature
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def backend(self) -> Backend:
|
|
38
|
+
if self._backend is None:
|
|
39
|
+
from .backends.local_nli import LocalNLIBackend
|
|
40
|
+
self._backend = LocalNLIBackend()
|
|
41
|
+
return self._backend
|
|
42
|
+
|
|
43
|
+
def ask(self, state: str, questions: dict):
|
|
44
|
+
"""Answer every question about ``state`` in one batched pass.
|
|
45
|
+
|
|
46
|
+
Returns a dict mapping each question name to its typed answer
|
|
47
|
+
(ChoiceAnswer / ScoreAnswer / NoulAnswer).
|
|
48
|
+
"""
|
|
49
|
+
if not isinstance(state, str) or state == "":
|
|
50
|
+
raise ValueError("state must be a non-empty string")
|
|
51
|
+
if not questions:
|
|
52
|
+
raise ValueError("ask() needs at least one question")
|
|
53
|
+
|
|
54
|
+
pairs: list[tuple[str, str]] = []
|
|
55
|
+
plan: list[tuple[str, object, int, int, str]] = [] # name, prim, start, count, mode
|
|
56
|
+
|
|
57
|
+
for name, prim in questions.items():
|
|
58
|
+
if isinstance(prim, Noul):
|
|
59
|
+
hyps = [prim.statement]
|
|
60
|
+
mode = "noul"
|
|
61
|
+
elif isinstance(prim, (Choice, Score)):
|
|
62
|
+
hyps = [self.hypothesis_template.format(h) for h in prim.hypotheses]
|
|
63
|
+
mode = "dist"
|
|
64
|
+
else:
|
|
65
|
+
raise TypeError(
|
|
66
|
+
f"question {name!r} must be a Choice, Score or Noul, got {type(prim).__name__}"
|
|
67
|
+
)
|
|
68
|
+
start = len(pairs)
|
|
69
|
+
pairs.extend((state, h) for h in hyps)
|
|
70
|
+
plan.append((name, prim, start, len(hyps), mode))
|
|
71
|
+
|
|
72
|
+
probs = self.backend.entail_probs(pairs) # the single pass
|
|
73
|
+
if len(probs) != len(pairs):
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
f"backend returned {len(probs)} scores for {len(pairs)} pairs"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
out: dict[str, object] = {}
|
|
79
|
+
for name, prim, start, count, mode in plan:
|
|
80
|
+
seg = probs[start:start + count]
|
|
81
|
+
if mode == "noul":
|
|
82
|
+
p_true = seg[0]
|
|
83
|
+
if self.temperature != 1.0:
|
|
84
|
+
from .calibration import apply_temperature
|
|
85
|
+
p_true = apply_temperature([1.0 - p_true, p_true], self.temperature)[1]
|
|
86
|
+
out[name] = prim.decide(p_true, kind="prob")
|
|
87
|
+
else:
|
|
88
|
+
# entailment probs per option/level, normalised across the set
|
|
89
|
+
dist = seg
|
|
90
|
+
if self.temperature != 1.0:
|
|
91
|
+
from .calibration import apply_temperature
|
|
92
|
+
total = sum(max(0.0, s) for s in seg) or 1.0
|
|
93
|
+
norm = [max(0.0, s) / total for s in seg]
|
|
94
|
+
dist = apply_temperature(norm, self.temperature)
|
|
95
|
+
out[name] = prim.decide(dist, kind="probs")
|
|
96
|
+
return out
|
poorjev/evaluate.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Run the eval set through a backend and score it into DecisionRecords.
|
|
2
|
+
|
|
3
|
+
This is the bridge between the labelled data and the metrics. It builds the
|
|
4
|
+
right primitive for each question, asks the Client, then compares the picked
|
|
5
|
+
value to gold and records the confidence and the probability mass on the gold
|
|
6
|
+
answer. M4 will reuse the collected records (and their gold prob mass) to fit
|
|
7
|
+
and evaluate calibration.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from .primitives import Choice, Score, Noul
|
|
15
|
+
from .client import Client
|
|
16
|
+
from .metrics import DecisionRecord, summarize
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_tasks(path: str) -> list[dict]:
|
|
20
|
+
items = []
|
|
21
|
+
with open(path) as f:
|
|
22
|
+
for line in f:
|
|
23
|
+
line = line.strip()
|
|
24
|
+
if line:
|
|
25
|
+
items.append(json.loads(line))
|
|
26
|
+
return items
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_primitive(spec: dict):
|
|
30
|
+
t = spec["type"]
|
|
31
|
+
if t == "choice":
|
|
32
|
+
return Choice(spec["options"])
|
|
33
|
+
if t == "score":
|
|
34
|
+
return Score(spec["levels"])
|
|
35
|
+
if t == "noul":
|
|
36
|
+
return Noul(spec["statement"])
|
|
37
|
+
raise ValueError(f"unknown question type {t!r}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _score_one(spec: dict, answer) -> DecisionRecord:
|
|
41
|
+
t = spec["type"]
|
|
42
|
+
gold = spec["gold"]
|
|
43
|
+
if t == "noul":
|
|
44
|
+
correct = bool(answer.value) == bool(gold)
|
|
45
|
+
prob_gold = answer.prob if gold else (1.0 - answer.prob)
|
|
46
|
+
# class order: index 0 = False, index 1 = True
|
|
47
|
+
dist = (1.0 - answer.prob, answer.prob)
|
|
48
|
+
gold_index = 1 if gold else 0
|
|
49
|
+
return DecisionRecord(answer.confidence, correct, prob_gold, n_classes=2,
|
|
50
|
+
dist=dist, gold_index=gold_index)
|
|
51
|
+
# choice / score share the same shape
|
|
52
|
+
order = spec["options"] if t == "choice" else spec["levels"]
|
|
53
|
+
probs = answer.probs if t == "choice" else answer.distribution
|
|
54
|
+
correct = answer.value == gold
|
|
55
|
+
prob_gold = probs.get(gold, 0.0)
|
|
56
|
+
dist = tuple(probs[k] for k in order)
|
|
57
|
+
gold_index = order.index(gold)
|
|
58
|
+
return DecisionRecord(answer.confidence, correct, prob_gold, n_classes=len(probs),
|
|
59
|
+
dist=dist, gold_index=gold_index)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def evaluate(path: str, client: Client | None = None, verbose: bool = False):
|
|
63
|
+
"""Return (records, per_question_specs) after running the whole set.
|
|
64
|
+
|
|
65
|
+
``records`` is a flat list of DecisionRecord across every question of every
|
|
66
|
+
item, tagged with task + question so callers can slice per task.
|
|
67
|
+
"""
|
|
68
|
+
client = client or Client()
|
|
69
|
+
items = load_tasks(path)
|
|
70
|
+
records: list[DecisionRecord] = []
|
|
71
|
+
|
|
72
|
+
for item in items:
|
|
73
|
+
questions = {name: _build_primitive(spec) for name, spec in item["questions"].items()}
|
|
74
|
+
answers = client.ask(item["state"], questions)
|
|
75
|
+
for name, spec in item["questions"].items():
|
|
76
|
+
r = _score_one(spec, answers[name])
|
|
77
|
+
records.append(DecisionRecord(
|
|
78
|
+
r.confidence, r.correct, r.prob_gold, r.n_classes,
|
|
79
|
+
task=item["task"], question=name,
|
|
80
|
+
dist=r.dist, gold_index=r.gold_index,
|
|
81
|
+
))
|
|
82
|
+
if verbose:
|
|
83
|
+
print(f" {item['id']}: " + ", ".join(
|
|
84
|
+
f"{n}={answers[n].value!r}" for n in item["questions"]
|
|
85
|
+
))
|
|
86
|
+
return records, items
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def report(records: list[DecisionRecord]) -> dict:
|
|
90
|
+
"""Overall summary plus a per-task breakdown."""
|
|
91
|
+
out = {"overall": summarize(records), "by_task": {}}
|
|
92
|
+
tasks = sorted({r.task for r in records})
|
|
93
|
+
for task in tasks:
|
|
94
|
+
out["by_task"][task] = summarize([r for r in records if r.task == task])
|
|
95
|
+
return out
|