auditkit 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.
- auditkit/README.md +99 -0
- auditkit/__init__.py +177 -0
- auditkit/__main__.py +3 -0
- auditkit/_bootstrap.py +77 -0
- auditkit/_identity_guard.py +99 -0
- auditkit/adapter.py +264 -0
- auditkit/annotator.py +339 -0
- auditkit/api.py +502 -0
- auditkit/assets/auditkit_logo.png +0 -0
- auditkit/cache.py +47 -0
- auditkit/cli.py +417 -0
- auditkit/comparison.py +563 -0
- auditkit/diff.py +265 -0
- auditkit/errors.py +54 -0
- auditkit/evaluator.py +20 -0
- auditkit/experiment.py +145 -0
- auditkit/hf_publish.py +262 -0
- auditkit/lmeval_engine.py +550 -0
- auditkit/loaders.py +121 -0
- auditkit/logs.py +18 -0
- auditkit/metric.py +199 -0
- auditkit/metrics/README.md +15 -0
- auditkit/metrics/__init__.py +0 -0
- auditkit/metrics/code.py +222 -0
- auditkit/metrics/embedding.py +131 -0
- auditkit/metrics/encoder_judge.py +423 -0
- auditkit/metrics/generation.py +331 -0
- auditkit/metrics/guard.py +412 -0
- auditkit/metrics/hallucination.py +45 -0
- auditkit/metrics/judge.py +547 -0
- auditkit/metrics/pairwise.py +153 -0
- auditkit/metrics/perf.py +53 -0
- auditkit/metrics/rag.py +149 -0
- auditkit/metrics/security.py +64 -0
- auditkit/metrics/toxicity.py +238 -0
- auditkit/model/README.md +16 -0
- auditkit/model/__init__.py +485 -0
- auditkit/model/anthropic.py +94 -0
- auditkit/model/api_gen.py +133 -0
- auditkit/model/groq_gen.py +121 -0
- auditkit/model/hf_gen.py +385 -0
- auditkit/model/lexsi.py +155 -0
- auditkit/model/litellm_gen.py +65 -0
- auditkit/model/openai.py +90 -0
- auditkit/model/openrouter_gen.py +152 -0
- auditkit/model/vllm_gen.py +316 -0
- auditkit/model_compare.py +655 -0
- auditkit/redteam/README.md +9 -0
- auditkit/redteam/__init__.py +26 -0
- auditkit/redteam/detector.py +37 -0
- auditkit/redteam/detectors/README.md +5 -0
- auditkit/redteam/detectors/builtin.py +126 -0
- auditkit/redteam/probe.py +39 -0
- auditkit/redteam/probes/README.md +5 -0
- auditkit/redteam/probes/builtin.py +85 -0
- auditkit/redteam/runner.py +206 -0
- auditkit/registry.py +65 -0
- auditkit/report.py +278 -0
- auditkit/report_format.py +52 -0
- auditkit/router.py +54 -0
- auditkit/runner.py +575 -0
- auditkit/runspec.py +159 -0
- auditkit/sample.py +40 -0
- auditkit/scenario.py +88 -0
- auditkit/scenarios/README.md +10 -0
- auditkit/scenarios/__init__.py +4 -0
- auditkit/scenarios/arc.py +33 -0
- auditkit/scenarios/gsm8k.py +32 -0
- auditkit/scenarios/hellaswag.py +33 -0
- auditkit/scenarios/humaneval.py +32 -0
- auditkit/scenarios/mmlu.py +34 -0
- auditkit/scenarios/truthfulqa.py +33 -0
- auditkit/score.py +165 -0
- auditkit/scorers.py +117 -0
- auditkit/scoring.py +79 -0
- auditkit/types.py +69 -0
- auditkit-1.0.0.dist-info/METADATA +396 -0
- auditkit-1.0.0.dist-info/RECORD +81 -0
- auditkit-1.0.0.dist-info/WHEEL +4 -0
- auditkit-1.0.0.dist-info/entry_points.txt +2 -0
- auditkit-1.0.0.dist-info/licenses/LICENSE.md +92 -0
auditkit/diff.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Iterable, Optional
|
|
5
|
+
|
|
6
|
+
from .report import RunResult, Prediction
|
|
7
|
+
from .types import Direction
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DeltaGrade(str, Enum):
|
|
11
|
+
PASS = "pass"
|
|
12
|
+
WARN = "warn"
|
|
13
|
+
FAIL = "fail"
|
|
14
|
+
# A metric that was scored on only one side (added after the baseline
|
|
15
|
+
# ran, or inapplicable to every sample there) has no real delta to
|
|
16
|
+
# grade at all. Previously the missing side silently defaulted to 0.0,
|
|
17
|
+
# producing a fabricated (and often FAIL-grade) delta indistinguishable
|
|
18
|
+
# from a genuine regression to zero. This is the explicit "nothing to
|
|
19
|
+
# compare" signal instead.
|
|
20
|
+
NOT_COMPARABLE = "not_comparable"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def grade_delta(
|
|
24
|
+
delta: float,
|
|
25
|
+
direction: Direction = Direction.MAXIMIZE,
|
|
26
|
+
pass_threshold: float = 0.02,
|
|
27
|
+
warn_threshold: float = 0.05,
|
|
28
|
+
) -> DeltaGrade:
|
|
29
|
+
"""Grade a candidate−baseline delta, accounting for the metric's direction.
|
|
30
|
+
|
|
31
|
+
The *regression* is how much worse the candidate is: for a MAXIMIZE metric a
|
|
32
|
+
drop (negative delta) is a regression; for a MINIMIZE metric (latency, WER,
|
|
33
|
+
toxicity) a rise (positive delta) is. An improvement, or a regression within
|
|
34
|
+
``pass_threshold``, is PASS; within ``warn_threshold``, WARN; beyond, FAIL.
|
|
35
|
+
"""
|
|
36
|
+
regression = -delta if direction == Direction.MAXIMIZE else delta
|
|
37
|
+
if regression <= pass_threshold:
|
|
38
|
+
return DeltaGrade.PASS
|
|
39
|
+
if regression <= warn_threshold:
|
|
40
|
+
return DeltaGrade.WARN
|
|
41
|
+
return DeltaGrade.FAIL
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def metric_directions(runs: Iterable[RunResult]) -> dict[str, Direction]:
|
|
45
|
+
"""Every metric name -> its declared :class:`Direction`, read straight
|
|
46
|
+
from each run's stored per-score dicts (``Prediction.metadata["scores"]``,
|
|
47
|
+
populated by ``Runner.score_one()`` from ``Metric.direction`` -- see
|
|
48
|
+
``metric.py``'s compulsory-direction enforcement). Shared by
|
|
49
|
+
:class:`RunDiff`, :class:`~auditkit.comparison.RunComparison` (2 runs),
|
|
50
|
+
and :class:`~auditkit.model_compare.CompareResult` (N runs) so all three
|
|
51
|
+
read direction the exact same way, not independently-maintained lookups
|
|
52
|
+
that could drift apart.
|
|
53
|
+
"""
|
|
54
|
+
dirs: dict[str, Direction] = {}
|
|
55
|
+
for run in runs:
|
|
56
|
+
for p in run.predictions:
|
|
57
|
+
for s in p.metadata.get("scores", []):
|
|
58
|
+
name, raw = s.get("name"), s.get("direction")
|
|
59
|
+
if name and name not in dirs and raw is not None:
|
|
60
|
+
try:
|
|
61
|
+
dirs[name] = Direction(raw)
|
|
62
|
+
except ValueError:
|
|
63
|
+
dirs[name] = Direction.MAXIMIZE
|
|
64
|
+
return dirs
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def direction_for(metric: str, dirs: dict[str, Direction]) -> Direction:
|
|
68
|
+
"""Look up *metric*'s direction in *dirs* (from :func:`metric_directions`),
|
|
69
|
+
falling back from a benchmark-style ``"task:metric"`` key to the base
|
|
70
|
+
metric name, and finally to MAXIMIZE if the metric was never scored at
|
|
71
|
+
all (nothing to look up)."""
|
|
72
|
+
base = metric.split(":", 1)[1] if ":" in metric else metric
|
|
73
|
+
return dirs.get(metric) or dirs.get(base) or Direction.MAXIMIZE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def best_model(scores: dict[str, float], direction: Direction) -> Optional[str]:
|
|
77
|
+
"""The winning model name for *scores* (``{model_name: value}``),
|
|
78
|
+
respecting *direction* -- the lowest value wins for MINIMIZE metrics
|
|
79
|
+
(latency, error rate, ...), not just whichever is numerically largest."""
|
|
80
|
+
if not scores:
|
|
81
|
+
return None
|
|
82
|
+
if direction == Direction.MINIMIZE:
|
|
83
|
+
return min(scores, key=scores.get)
|
|
84
|
+
return max(scores, key=scores.get)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def relative_pct(baseline: Optional[float], delta: Optional[float]) -> Optional[float]:
|
|
88
|
+
"""The change as a percentage of the baseline (``delta / baseline * 100``).
|
|
89
|
+
|
|
90
|
+
This is the "percentage difference in the metric" the comparison surfaces so
|
|
91
|
+
the reader can judge whether a change matters, rather than the library
|
|
92
|
+
imposing a fixed pass/warn/fail threshold that isn't meaningful across every
|
|
93
|
+
metric. Returns ``None`` when the baseline is missing or ``<= 0`` (a percent
|
|
94
|
+
of zero/negative is undefined -- fall back to the absolute delta there).
|
|
95
|
+
"""
|
|
96
|
+
if baseline is None or delta is None or baseline <= 0:
|
|
97
|
+
return None
|
|
98
|
+
return delta / baseline * 100.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RunDiff:
|
|
104
|
+
def __init__(self, baseline: RunResult, contrast: RunResult) -> None:
|
|
105
|
+
self.baseline = baseline
|
|
106
|
+
self.contrast = contrast
|
|
107
|
+
|
|
108
|
+
def _directions(self) -> dict[str, Direction]:
|
|
109
|
+
return metric_directions((self.baseline, self.contrast))
|
|
110
|
+
|
|
111
|
+
def _direction(self, metric: str) -> Direction:
|
|
112
|
+
return direction_for(metric, self._directions())
|
|
113
|
+
|
|
114
|
+
def metric_deltas(self) -> dict[str, dict[str, float]]:
|
|
115
|
+
all_metrics = set(self.baseline.stats.keys()) | set(self.contrast.stats.keys())
|
|
116
|
+
result: dict[str, dict[str, float]] = {}
|
|
117
|
+
for name in sorted(all_metrics):
|
|
118
|
+
in_baseline = name in self.baseline.stats
|
|
119
|
+
in_contrast = name in self.contrast.stats
|
|
120
|
+
b_mean = self.baseline.stats[name].mean if in_baseline else None
|
|
121
|
+
c_mean = self.contrast.stats[name].mean if in_contrast else None
|
|
122
|
+
result[name] = {
|
|
123
|
+
"baseline": b_mean,
|
|
124
|
+
"contrast": c_mean,
|
|
125
|
+
# None (not 0.0 - c_mean's old fallback) when either side
|
|
126
|
+
# never scored this metric at all -- a fabricated delta
|
|
127
|
+
# against a phantom zero is worse than an honest "no data".
|
|
128
|
+
"delta": (c_mean - b_mean) if (in_baseline and in_contrast) else None,
|
|
129
|
+
"comparable": in_baseline and in_contrast,
|
|
130
|
+
}
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def grade(self, metric_name: str,
|
|
134
|
+
pass_threshold: float = 0.02,
|
|
135
|
+
warn_threshold: float = 0.05) -> DeltaGrade:
|
|
136
|
+
deltas = self.metric_deltas()
|
|
137
|
+
if metric_name not in deltas:
|
|
138
|
+
raise KeyError(f"metric {metric_name!r} not found in deltas")
|
|
139
|
+
info = deltas[metric_name]
|
|
140
|
+
if not info["comparable"]:
|
|
141
|
+
return DeltaGrade.NOT_COMPARABLE
|
|
142
|
+
return grade_delta(info["delta"], self._direction(metric_name), pass_threshold, warn_threshold)
|
|
143
|
+
|
|
144
|
+
def grades(self, pass_threshold: float = 0.02,
|
|
145
|
+
warn_threshold: float = 0.05) -> dict[str, DeltaGrade]:
|
|
146
|
+
return {name: self.grade(name, pass_threshold, warn_threshold)
|
|
147
|
+
for name in self.metric_deltas()}
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _predictions_by_id(result: RunResult) -> dict[str, Prediction]:
|
|
151
|
+
return {p.sample_id: p for p in result.predictions}
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _is_correct(pred: Prediction | None, metric_name: str) -> bool | None:
|
|
155
|
+
if pred is None:
|
|
156
|
+
return None
|
|
157
|
+
if not metric_name:
|
|
158
|
+
return pred.correct
|
|
159
|
+
for score_doc in pred.metadata.get("scores", []):
|
|
160
|
+
if score_doc.get("name") == metric_name:
|
|
161
|
+
# Use the metric's own gate (passed) when it has a threshold;
|
|
162
|
+
# bool(value) was wrong for any thresholded/continuous score
|
|
163
|
+
# (e.g. value=0.6, threshold=0.7, passed=False -- bool(0.6)
|
|
164
|
+
# is truthy, silently reporting a failing sample as correct).
|
|
165
|
+
# Falls back to the same value==1.0 convention Runner.score_one()
|
|
166
|
+
# itself uses for Prediction.correct when there's no threshold.
|
|
167
|
+
passed = score_doc.get("passed")
|
|
168
|
+
if passed is not None:
|
|
169
|
+
return bool(passed)
|
|
170
|
+
return score_doc.get("value") == 1.0
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def sample_diff(self, metric_name: str = "") -> list[dict]:
|
|
174
|
+
base_by_id = self._predictions_by_id(self.baseline)
|
|
175
|
+
cont_by_id = self._predictions_by_id(self.contrast)
|
|
176
|
+
all_ids = set(base_by_id.keys()) | set(cont_by_id.keys())
|
|
177
|
+
|
|
178
|
+
entries = []
|
|
179
|
+
for sid in sorted(all_ids):
|
|
180
|
+
bp = base_by_id.get(sid)
|
|
181
|
+
cp = cont_by_id.get(sid)
|
|
182
|
+
if bp is None or cp is None:
|
|
183
|
+
change = "unknown"
|
|
184
|
+
else:
|
|
185
|
+
b_correct = self._is_correct(bp, metric_name)
|
|
186
|
+
c_correct = self._is_correct(cp, metric_name)
|
|
187
|
+
if b_correct is True and c_correct is False:
|
|
188
|
+
change = "newly_wrong"
|
|
189
|
+
elif b_correct is False and c_correct is True:
|
|
190
|
+
change = "newly_correct"
|
|
191
|
+
elif b_correct is False and c_correct is False:
|
|
192
|
+
change = "still_wrong"
|
|
193
|
+
elif b_correct is True and c_correct is True:
|
|
194
|
+
change = "still_correct"
|
|
195
|
+
else:
|
|
196
|
+
change = "unknown"
|
|
197
|
+
|
|
198
|
+
entries.append({
|
|
199
|
+
"sample_id": sid,
|
|
200
|
+
"baseline_correct": self._is_correct(bp, metric_name) if bp else None,
|
|
201
|
+
"contrast_correct": self._is_correct(cp, metric_name) if cp else None,
|
|
202
|
+
"change": change,
|
|
203
|
+
})
|
|
204
|
+
return entries
|
|
205
|
+
|
|
206
|
+
def _filter_by_change(self, change: str, metric_name: str = "") -> list[Prediction]:
|
|
207
|
+
cont_by_id = self._predictions_by_id(self.contrast)
|
|
208
|
+
result = []
|
|
209
|
+
for entry in self.sample_diff(metric_name):
|
|
210
|
+
if entry["change"] == change:
|
|
211
|
+
sid = entry["sample_id"]
|
|
212
|
+
cp = cont_by_id.get(sid)
|
|
213
|
+
if cp is not None:
|
|
214
|
+
result.append(cp)
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
def regressed(self, metric_name: str = "") -> list[Prediction]:
|
|
218
|
+
"""Samples correct in ``baseline`` but wrong in ``contrast`` -- what broke.
|
|
219
|
+
|
|
220
|
+
Was ``newly_wrong()`` -- renamed because "newly" implied history/
|
|
221
|
+
tracking over time that doesn't exist here; this is always a one-shot,
|
|
222
|
+
two-run comparison.
|
|
223
|
+
"""
|
|
224
|
+
return self._filter_by_change("newly_wrong", metric_name)
|
|
225
|
+
|
|
226
|
+
def improved(self, metric_name: str = "") -> list[Prediction]:
|
|
227
|
+
"""Samples wrong in ``baseline`` but fixed in ``contrast``. Was ``newly_correct()``."""
|
|
228
|
+
return self._filter_by_change("newly_correct", metric_name)
|
|
229
|
+
|
|
230
|
+
def still_wrong(self, metric_name: str = "") -> list[Prediction]:
|
|
231
|
+
return self._filter_by_change("still_wrong", metric_name)
|
|
232
|
+
|
|
233
|
+
def still_correct(self, metric_name: str = "") -> list[Prediction]:
|
|
234
|
+
return self._filter_by_change("still_correct", metric_name)
|
|
235
|
+
|
|
236
|
+
def sample_summary(self, metric_name: str = "") -> dict[str, int]:
|
|
237
|
+
counts: dict[str, int] = {
|
|
238
|
+
"newly_wrong": 0,
|
|
239
|
+
"newly_correct": 0,
|
|
240
|
+
"still_wrong": 0,
|
|
241
|
+
"still_correct": 0,
|
|
242
|
+
"unknown": 0,
|
|
243
|
+
}
|
|
244
|
+
for entry in self.sample_diff(metric_name):
|
|
245
|
+
counts[entry["change"]] += 1
|
|
246
|
+
return counts
|
|
247
|
+
|
|
248
|
+
def summary(self) -> str:
|
|
249
|
+
# Reports the change per metric -- absolute delta AND relative % -- and
|
|
250
|
+
# nothing else. No pass/warn/fail verdict, and no better/worse framing:
|
|
251
|
+
# a fixed threshold isn't meaningful across every metric, and it's the
|
|
252
|
+
# reader's job to interpret the numbers for their own metrics. (An opt-in
|
|
253
|
+
# threshold gate is still available via .grade()/.grades().) ASCII only
|
|
254
|
+
# -- this string is often printed to a console that can't encode
|
|
255
|
+
# arrows/greek.
|
|
256
|
+
lines = [f"baseline {self.baseline.run_id} -> contrast {self.contrast.run_id}"]
|
|
257
|
+
for name, info in sorted(self.metric_deltas().items()):
|
|
258
|
+
if not info["comparable"]:
|
|
259
|
+
lines.append(f" {name}: N/A -- only scored on one side")
|
|
260
|
+
continue
|
|
261
|
+
pct = relative_pct(info["baseline"], info["delta"])
|
|
262
|
+
pct_s = f"{pct:+.1f}% rel" if pct is not None else "rel n/a"
|
|
263
|
+
lines.append(f" {name}: {info['baseline']:.4f} -> {info['contrast']:.4f} "
|
|
264
|
+
f"delta={info['delta']:+.4f} ({pct_s})")
|
|
265
|
+
return "\n".join(lines)
|
auditkit/errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Typed errors AuditKIT raises.
|
|
2
|
+
|
|
3
|
+
Everything derives from :class:`AuditKitError` so a caller can catch the whole
|
|
4
|
+
family with one ``except``. The platform maps each type to a ``#08-xxx`` code at
|
|
5
|
+
its service boundary; the library itself stays framework-free.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuditKitError(Exception):
|
|
12
|
+
"""Base class for every error AuditKIT raises."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CapabilityError(AuditKitError):
|
|
16
|
+
"""A model was asked for something it doesn't declare (e.g. loglikelihood)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RegistryError(AuditKitError):
|
|
20
|
+
"""A name was not found in, or already taken in, a registry."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExtraNotInstalled(AuditKitError):
|
|
24
|
+
"""An optional backend/metric was used without its extra installed.
|
|
25
|
+
|
|
26
|
+
*hint*, when given, is the complete, ready-to-run fix (e.g. ``"pip
|
|
27
|
+
install auditkit[vllm]"``) and becomes the message verbatim -- every
|
|
28
|
+
caller already passes a full actionable instruction here, so prefixing
|
|
29
|
+
a second, separately-generated ``"install auditkit[{extra}]"`` in front
|
|
30
|
+
of it (the previous behavior) just produced a redundant, confusing
|
|
31
|
+
message like ``"install auditkit[vllm] for pip install auditkit[vllm]"``.
|
|
32
|
+
Falls back to ``"install auditkit[{extra}]"`` only when no *hint* is
|
|
33
|
+
given at all.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, extra: str, hint: str = "") -> None:
|
|
37
|
+
super().__init__(hint if hint else f"install auditkit[{extra}]")
|
|
38
|
+
self.extra = extra
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ModelError(AuditKitError):
|
|
42
|
+
"""Model backend failure (possibly transient)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MetricError(AuditKitError):
|
|
46
|
+
"""Metric computation failure."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SampleSkipped(AuditKitError):
|
|
50
|
+
"""Sample-level error (non-fatal, result still returned)."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ModelTimeout(AuditKitError):
|
|
54
|
+
"""Model call exceeded the configured timeout."""
|
auditkit/evaluator.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from auditkit.scenario import Scenario
|
|
8
|
+
from auditkit.runner import RunConfig
|
|
9
|
+
from auditkit.score import Score
|
|
10
|
+
from auditkit.model import Model
|
|
11
|
+
from auditkit.sample import Prediction
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Evaluator(ABC):
|
|
15
|
+
technique: str = "generic"
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def evaluate(
|
|
19
|
+
self, scenario: Scenario, model: Model, config: RunConfig
|
|
20
|
+
) -> tuple[list[Score], list[Prediction]]: ...
|
auditkit/experiment.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Experiment tracking for multi-run comparison and persistence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .diff import RunDiff
|
|
11
|
+
from .errors import ExtraNotInstalled
|
|
12
|
+
from .report import RunResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Experiment:
|
|
17
|
+
name: str
|
|
18
|
+
runs: list[RunResult] = field(default_factory=list)
|
|
19
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
def add(self, run: RunResult) -> None:
|
|
22
|
+
self.runs.append(run)
|
|
23
|
+
|
|
24
|
+
def aggregate(self, metric: str | None = None) -> dict[str, float]:
|
|
25
|
+
if not self.runs:
|
|
26
|
+
return {}
|
|
27
|
+
if metric:
|
|
28
|
+
vals = [r.headline.get(metric, 0.0) for r in self.runs if r.headline]
|
|
29
|
+
return {metric: sum(vals) / len(vals) if vals else 0.0}
|
|
30
|
+
all_metrics = set()
|
|
31
|
+
for r in self.runs:
|
|
32
|
+
all_metrics.update(r.headline.keys())
|
|
33
|
+
result = {}
|
|
34
|
+
for m in sorted(all_metrics):
|
|
35
|
+
vals = [r.headline.get(m, 0.0) for r in self.runs if r.headline]
|
|
36
|
+
result[m] = sum(vals) / len(vals) if vals else 0.0
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
def leaderboard(self) -> list[dict[str, Any]]:
|
|
40
|
+
if not self.runs:
|
|
41
|
+
return []
|
|
42
|
+
first_metric = next(iter(self.runs[0].headline.keys()), None)
|
|
43
|
+
rows = []
|
|
44
|
+
for r in self.runs:
|
|
45
|
+
row = {"run_id": r.run_id, "fingerprint": r.fingerprint}
|
|
46
|
+
row.update(r.headline)
|
|
47
|
+
rows.append(row)
|
|
48
|
+
if first_metric:
|
|
49
|
+
rows.sort(key=lambda x: x.get(first_metric, 0), reverse=True)
|
|
50
|
+
return rows
|
|
51
|
+
|
|
52
|
+
def pairwise_diff(self, run_a_index: int = 0, run_b_index: int = -1) -> RunDiff:
|
|
53
|
+
return RunDiff(self.runs[run_a_index], self.runs[run_b_index])
|
|
54
|
+
|
|
55
|
+
def log_mlflow(self, experiment_name: str | None = None, tracking_uri: str | None = None) -> None:
|
|
56
|
+
try:
|
|
57
|
+
import mlflow
|
|
58
|
+
except ImportError:
|
|
59
|
+
raise ExtraNotInstalled("mlflow", "pip install auditkit[mlflow]")
|
|
60
|
+
if tracking_uri:
|
|
61
|
+
mlflow.set_tracking_uri(tracking_uri)
|
|
62
|
+
exp_name = experiment_name or self.name
|
|
63
|
+
mlflow.set_experiment(exp_name)
|
|
64
|
+
for run in self.runs:
|
|
65
|
+
with mlflow.start_run(run_name=run.run_id):
|
|
66
|
+
for metric_name, value in run.headline.items():
|
|
67
|
+
mlflow.log_metric(metric_name, value)
|
|
68
|
+
mlflow.log_param("fingerprint", run.fingerprint)
|
|
69
|
+
mlflow.log_param("model", run.model_spec or "")
|
|
70
|
+
if run.config:
|
|
71
|
+
for k in ("temperature", "seed", "limit", "num_fewshot", "concurrency"):
|
|
72
|
+
v = getattr(run.config, k, None)
|
|
73
|
+
if v is not None:
|
|
74
|
+
mlflow.log_param(k, v)
|
|
75
|
+
if run.errors:
|
|
76
|
+
mlflow.log_metric("failed_count", run.failed_count)
|
|
77
|
+
mlflow.log_text(json.dumps(run.errors, default=str), "errors.json")
|
|
78
|
+
|
|
79
|
+
def significance(self, metric: str, method: str = "bootstrap", n_resamples: int = 1000) -> dict[str, Any]:
|
|
80
|
+
"""Compare the best two runs and return a p-value.
|
|
81
|
+
|
|
82
|
+
Aligns per-sample scores **by sample_id** via the shared paired bootstrap
|
|
83
|
+
(see :mod:`auditkit._bootstrap`), rather than zipping by list position.
|
|
84
|
+
"""
|
|
85
|
+
if len(self.runs) < 2:
|
|
86
|
+
return {"error": "Need at least 2 runs for significance test"}
|
|
87
|
+
from ._bootstrap import paired_bootstrap, score_pairs
|
|
88
|
+
|
|
89
|
+
scored = []
|
|
90
|
+
for r in self.runs:
|
|
91
|
+
vals = [p.score for p in r.predictions if p.score is not None]
|
|
92
|
+
if vals:
|
|
93
|
+
scored.append((sum(vals) / len(vals), r))
|
|
94
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
95
|
+
if len(scored) < 2:
|
|
96
|
+
return {"error": "Need at least 2 runs with predictions for significance test"}
|
|
97
|
+
baseline_run = scored[0][1]
|
|
98
|
+
contrast_run = scored[1][1]
|
|
99
|
+
bs = paired_bootstrap(score_pairs(baseline_run, contrast_run, None), n_resamples=n_resamples)
|
|
100
|
+
if "error" in bs:
|
|
101
|
+
return {"error": "Need at least 2 paired predictions per run"}
|
|
102
|
+
return {
|
|
103
|
+
"baseline": baseline_run.run_id,
|
|
104
|
+
"contrast": contrast_run.run_id,
|
|
105
|
+
"delta": round(bs["mean_baseline"] - bs["mean_candidate"], 6),
|
|
106
|
+
"p_value": bs["p_value"],
|
|
107
|
+
"significant": bs["significant"],
|
|
108
|
+
"method": method,
|
|
109
|
+
"n_resamples": n_resamples,
|
|
110
|
+
"n_samples": bs["n"],
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ExperimentDB:
|
|
115
|
+
def __init__(self, path: str | None = None) -> None:
|
|
116
|
+
self._dir = Path(path or os.path.join(
|
|
117
|
+
os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")),
|
|
118
|
+
"auditkit", "experiments",
|
|
119
|
+
))
|
|
120
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
|
|
122
|
+
def _path(self, name: str) -> Path:
|
|
123
|
+
safe = name.replace("/", "_").replace(" ", "_")
|
|
124
|
+
return self._dir / f"{safe}.json"
|
|
125
|
+
|
|
126
|
+
def save(self, experiment: Experiment) -> None:
|
|
127
|
+
data = {
|
|
128
|
+
"name": experiment.name,
|
|
129
|
+
"metadata": experiment.metadata,
|
|
130
|
+
"run_count": len(experiment.runs),
|
|
131
|
+
"runs": [r.to_dict() for r in experiment.runs],
|
|
132
|
+
}
|
|
133
|
+
with open(self._path(experiment.name), "w") as f:
|
|
134
|
+
json.dump(data, f, indent=2, default=str)
|
|
135
|
+
|
|
136
|
+
def load(self, name: str) -> Experiment:
|
|
137
|
+
with open(self._path(name)) as f:
|
|
138
|
+
data = json.load(f)
|
|
139
|
+
exp = Experiment(name=data["name"], metadata=data.get("metadata", {}))
|
|
140
|
+
for rd in data.get("runs", []):
|
|
141
|
+
exp.add(RunResult.from_dict(rd))
|
|
142
|
+
return exp
|
|
143
|
+
|
|
144
|
+
def list_experiments(self) -> list[str]:
|
|
145
|
+
return [p.stem for p in self._dir.iterdir() if p.suffix == ".json"]
|