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
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""Multi-model comparison: run the same evaluation across several models."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import statistics
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable, Iterable, Sequence, Union
|
|
8
|
+
|
|
9
|
+
from .adapter import Adapter
|
|
10
|
+
from .api import evaluate
|
|
11
|
+
from .diff import best_model, direction_for, metric_directions
|
|
12
|
+
from .model import AutoModel, Model
|
|
13
|
+
from .report import RunResult
|
|
14
|
+
from .runspec import RunConfig
|
|
15
|
+
from .sample import Sample
|
|
16
|
+
from .scenario import Scenario
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class PerMetricComparison:
|
|
23
|
+
"""Comparison of one metric across models."""
|
|
24
|
+
|
|
25
|
+
metric: str
|
|
26
|
+
scores: dict[str, float]
|
|
27
|
+
winner: str | None
|
|
28
|
+
deltas: dict[str, dict[str, float]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class CompareResult:
|
|
33
|
+
"""Result of comparing multiple models on the same dataset."""
|
|
34
|
+
|
|
35
|
+
runs: dict[str, RunResult]
|
|
36
|
+
dataset_size: int
|
|
37
|
+
scorers: list[str]
|
|
38
|
+
# Models that failed to run AT ALL (raised before producing a RunResult --
|
|
39
|
+
# e.g. an invalid/decommissioned model spec), keyed by name, value = the
|
|
40
|
+
# error message. Previously an exception from any one model crashed
|
|
41
|
+
# compare_models() entirely, losing every other model's already-computed
|
|
42
|
+
# results too; these are now caught and the comparison continues without
|
|
43
|
+
# that model, with the failure recorded here instead of silently lost.
|
|
44
|
+
errors: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def _warn_if_issues(self) -> None:
|
|
47
|
+
if self.errors:
|
|
48
|
+
logger.warning("CompareResult: %d model(s) failed to run entirely: %s",
|
|
49
|
+
len(self.errors), self.errors)
|
|
50
|
+
cw = self.coverage_warnings()
|
|
51
|
+
if cw:
|
|
52
|
+
logger.warning("CompareResult: coverage issues across models -- %s", cw)
|
|
53
|
+
|
|
54
|
+
def per_metric(self) -> list[PerMetricComparison]:
|
|
55
|
+
"""For each metric, show each model's average score and pairwise deltas."""
|
|
56
|
+
self._warn_if_issues()
|
|
57
|
+
by_metric: dict[str, dict[str, float]] = {}
|
|
58
|
+
for model_name, run in self.runs.items():
|
|
59
|
+
for metric_name, value in run.headline.items():
|
|
60
|
+
by_metric.setdefault(metric_name, {})[model_name] = value
|
|
61
|
+
|
|
62
|
+
dirs = metric_directions(self.runs.values())
|
|
63
|
+
results = []
|
|
64
|
+
for metric, model_scores in by_metric.items():
|
|
65
|
+
winner = best_model(model_scores, direction_for(metric, dirs))
|
|
66
|
+
deltas = {}
|
|
67
|
+
for m1 in model_scores:
|
|
68
|
+
deltas[m1] = {}
|
|
69
|
+
for m2 in model_scores:
|
|
70
|
+
if m1 != m2:
|
|
71
|
+
deltas[m1][m2] = round(model_scores[m1] - model_scores[m2], 4)
|
|
72
|
+
results.append(PerMetricComparison(
|
|
73
|
+
metric=metric,
|
|
74
|
+
scores=model_scores,
|
|
75
|
+
winner=winner,
|
|
76
|
+
deltas=deltas,
|
|
77
|
+
))
|
|
78
|
+
return results
|
|
79
|
+
|
|
80
|
+
def winner(self, metric: str | None = None) -> str | None:
|
|
81
|
+
"""Return the model with the highest average on *metric* (or the first metric)."""
|
|
82
|
+
metrics = self.per_metric()
|
|
83
|
+
if not metrics:
|
|
84
|
+
return None
|
|
85
|
+
target = metric or metrics[0].metric
|
|
86
|
+
for m in metrics:
|
|
87
|
+
if m.metric == target:
|
|
88
|
+
return m.winner
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def significance(self, model_a: str, model_b: str, metric: str | None = None) -> dict[str, Any]:
|
|
92
|
+
"""Paired-bootstrap significance test between two models on a given metric.
|
|
93
|
+
|
|
94
|
+
Per-sample scores are aligned **by sample_id** (via the shared
|
|
95
|
+
:func:`~auditkit._bootstrap.score_pairs`), so the test is robust even if
|
|
96
|
+
the two runs' prediction order diverges.
|
|
97
|
+
"""
|
|
98
|
+
from ._bootstrap import paired_bootstrap, score_pairs
|
|
99
|
+
|
|
100
|
+
run_a = self.runs.get(model_a)
|
|
101
|
+
run_b = self.runs.get(model_b)
|
|
102
|
+
if not run_a or not run_b:
|
|
103
|
+
return {"error": f"Missing run for {model_a} or {model_b}"}
|
|
104
|
+
target = metric or next(iter(run_a.headline.keys()), None)
|
|
105
|
+
if target is None:
|
|
106
|
+
return {"error": "No metrics available"}
|
|
107
|
+
|
|
108
|
+
bs = paired_bootstrap(score_pairs(run_a, run_b, target))
|
|
109
|
+
if "error" in bs:
|
|
110
|
+
return {"error": bs["error"], "model_a": model_a, "model_b": model_b, "metric": target}
|
|
111
|
+
return {
|
|
112
|
+
"model_a": model_a,
|
|
113
|
+
"model_b": model_b,
|
|
114
|
+
"metric": target,
|
|
115
|
+
"mean_a": bs["mean_baseline"],
|
|
116
|
+
"mean_b": bs["mean_candidate"],
|
|
117
|
+
"delta": round(bs["mean_baseline"] - bs["mean_candidate"], 6), # a − b
|
|
118
|
+
"p_value": bs["p_value"],
|
|
119
|
+
"significant": bs["significant"],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def pairwise(self, baseline_name: str, candidate_name: str, **kw: Any):
|
|
123
|
+
"""A baseline-anchored :class:`~auditkit.comparison.RunComparison` between
|
|
124
|
+
two of the already-run models (e.g. base vs pruned)."""
|
|
125
|
+
from .comparison import RunComparison
|
|
126
|
+
|
|
127
|
+
base, cand = self.runs.get(baseline_name), self.runs.get(candidate_name)
|
|
128
|
+
if base is None or cand is None:
|
|
129
|
+
raise KeyError(
|
|
130
|
+
f"unknown model name(s) {baseline_name!r}/{candidate_name!r}; "
|
|
131
|
+
f"have {list(self.runs)}"
|
|
132
|
+
)
|
|
133
|
+
return RunComparison(base, cand, **kw)
|
|
134
|
+
|
|
135
|
+
# -- N-model coverage visibility -----------------------------------------
|
|
136
|
+
def coverage_warnings(self) -> list[str]:
|
|
137
|
+
"""Flags failed samples and per-metric sample-count mismatches across
|
|
138
|
+
ALL compared models at once -- the N-model analog of
|
|
139
|
+
:meth:`~auditkit.comparison.RunComparison.coverage_warnings`/
|
|
140
|
+
``has_failures``. ``compare_models()`` itself performs none of this
|
|
141
|
+
checking on its own; ``per_metric()``/``summary()`` read each run's
|
|
142
|
+
already-aggregated ``.headline`` and would silently show a clean
|
|
143
|
+
table even if one model failed on several samples or a metric never
|
|
144
|
+
scored successfully for one model at all.
|
|
145
|
+
"""
|
|
146
|
+
warnings: list[str] = []
|
|
147
|
+
for name, err in self.errors.items():
|
|
148
|
+
warnings.append(f"{name}: FAILED TO RUN ENTIRELY -- {err}")
|
|
149
|
+
for name, run in self.runs.items():
|
|
150
|
+
if run.failed_count:
|
|
151
|
+
warnings.append(f"{name}: {run.failed_count} failed sample(s) -- see run.errors")
|
|
152
|
+
|
|
153
|
+
all_metrics: set[str] = set()
|
|
154
|
+
for run in self.runs.values():
|
|
155
|
+
all_metrics |= set(run.stats.keys())
|
|
156
|
+
for metric in sorted(all_metrics):
|
|
157
|
+
counts = {name: run.stats[metric].count for name, run in self.runs.items() if metric in run.stats}
|
|
158
|
+
missing = [name for name in self.runs if metric not in self.runs[name].stats]
|
|
159
|
+
if missing:
|
|
160
|
+
warnings.append(f"{metric}: missing entirely from {missing} (never scored successfully there)")
|
|
161
|
+
elif len(set(counts.values())) > 1:
|
|
162
|
+
warnings.append(f"{metric}: sample counts differ across models -> {counts}")
|
|
163
|
+
|
|
164
|
+
# Per-task check: two tasks can individually mismatch in N while the
|
|
165
|
+
# blended totals above coincidentally agree.
|
|
166
|
+
for row in self.per_task_table():
|
|
167
|
+
counts = {name: row.get(f"{name}_n", 0) for name in self.runs}
|
|
168
|
+
if len(set(counts.values())) > 1:
|
|
169
|
+
warnings.append(f"{row['task']}/{row['metric']}: sample counts differ across models -> {counts}")
|
|
170
|
+
return warnings
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def has_failures(self) -> bool:
|
|
174
|
+
return bool(self.errors) or any(run.failed_count for run in self.runs.values())
|
|
175
|
+
|
|
176
|
+
def comparison_table(self) -> list[dict[str, Any]]:
|
|
177
|
+
"""One row per metric, with every model's score/n/std side by side and
|
|
178
|
+
the winner marked -- the structured form behind ``summary()``'s printed
|
|
179
|
+
table, for programmatic use (dashboards, CI gates, etc.)."""
|
|
180
|
+
all_metrics: set[str] = set()
|
|
181
|
+
for run in self.runs.values():
|
|
182
|
+
all_metrics |= set(run.stats.keys())
|
|
183
|
+
dirs = metric_directions(self.runs.values())
|
|
184
|
+
rows = []
|
|
185
|
+
for metric in sorted(all_metrics):
|
|
186
|
+
scores: dict[str, float] = {}
|
|
187
|
+
row: dict[str, Any] = {"metric": metric}
|
|
188
|
+
for name, run in self.runs.items():
|
|
189
|
+
stat = run.stats.get(metric)
|
|
190
|
+
row[f"{name}_score"] = stat.mean if stat else None
|
|
191
|
+
row[f"{name}_n"] = stat.count if stat else 0
|
|
192
|
+
row[f"{name}_std"] = stat.std if stat else None
|
|
193
|
+
if stat:
|
|
194
|
+
scores[name] = stat.mean
|
|
195
|
+
row["winner"] = best_model(scores, direction_for(metric, dirs))
|
|
196
|
+
rows.append(row)
|
|
197
|
+
return rows
|
|
198
|
+
|
|
199
|
+
def per_task_table(self) -> list[dict[str, Any]]:
|
|
200
|
+
"""One row per (task, metric), every model's score/n/std side by side --
|
|
201
|
+
the N-model analog of ``RunComparison.per_task_deltas()``.
|
|
202
|
+
|
|
203
|
+
``comparison_table()``/``summary()`` blend every task into one number
|
|
204
|
+
per metric -- two models that fail in completely different,
|
|
205
|
+
non-overlapping ways (one bombs task A, the other bombs task B) can
|
|
206
|
+
show the *identical* blended score and be indistinguishable there.
|
|
207
|
+
This breaks it out per task so that's visible.
|
|
208
|
+
"""
|
|
209
|
+
# (task, metric) -> {model_name: {"mean", "count", "std"}}
|
|
210
|
+
table: dict[tuple[str, str], dict[str, dict[str, float]]] = {}
|
|
211
|
+
for name, run in self.runs.items():
|
|
212
|
+
# Benchmark engine: stats keyed "task:metric" -- Stat already has count/std.
|
|
213
|
+
for key, stat in run.stats.items():
|
|
214
|
+
if ":" in key:
|
|
215
|
+
task, metric = key.split(":", 1)
|
|
216
|
+
table.setdefault((task, metric), {})[name] = {
|
|
217
|
+
"mean": stat.mean, "count": stat.count, "std": stat.std,
|
|
218
|
+
}
|
|
219
|
+
# Native engine: group predictions by task via their per-score dicts.
|
|
220
|
+
acc: dict[tuple[str, str], list[float]] = {}
|
|
221
|
+
for p in run.predictions:
|
|
222
|
+
for s in p.metadata.get("scores", []):
|
|
223
|
+
mname, val = s.get("name"), s.get("value")
|
|
224
|
+
if mname is not None and isinstance(val, (int, float)):
|
|
225
|
+
acc.setdefault((p.task or "", mname), []).append(float(val))
|
|
226
|
+
for (task, metric), vals in acc.items():
|
|
227
|
+
if vals and name not in table.get((task, metric), {}):
|
|
228
|
+
table.setdefault((task, metric), {})[name] = {
|
|
229
|
+
"mean": sum(vals) / len(vals),
|
|
230
|
+
"count": len(vals),
|
|
231
|
+
"std": statistics.pstdev(vals) if len(vals) > 1 else 0.0,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
dirs = metric_directions(self.runs.values())
|
|
235
|
+
rows = []
|
|
236
|
+
for task, metric in sorted(table):
|
|
237
|
+
per_model = table[(task, metric)]
|
|
238
|
+
row: dict[str, Any] = {"task": task, "metric": metric}
|
|
239
|
+
scores: dict[str, float] = {}
|
|
240
|
+
for name in self.runs:
|
|
241
|
+
entry = per_model.get(name)
|
|
242
|
+
row[f"{name}_score"] = entry["mean"] if entry else None
|
|
243
|
+
row[f"{name}_n"] = entry["count"] if entry else 0
|
|
244
|
+
row[f"{name}_std"] = entry["std"] if entry else None
|
|
245
|
+
if entry:
|
|
246
|
+
scores[name] = entry["mean"]
|
|
247
|
+
row["winner"] = best_model(scores, direction_for(metric, dirs))
|
|
248
|
+
rows.append(row)
|
|
249
|
+
return rows
|
|
250
|
+
|
|
251
|
+
def task_macro_average_table(self) -> list[dict[str, Any]]:
|
|
252
|
+
"""One row per metric: each model's macro-average across tasks --
|
|
253
|
+
the mean of that model's own per-task means, each task weighted
|
|
254
|
+
equally regardless of how many samples it has.
|
|
255
|
+
|
|
256
|
+
Different from ``comparison_table()`` (which blends every sample
|
|
257
|
+
together, so a task with more samples counts for more) whenever
|
|
258
|
+
tasks have unequal sample counts -- e.g. a model at 100% on a
|
|
259
|
+
1-sample task and 0% on a 3-sample task blends (micro-average) to
|
|
260
|
+
0.25, but macro-averages to 0.5 (each task counted once, equally).
|
|
261
|
+
Neither is "more correct" -- micro answers "how good overall,
|
|
262
|
+
weighted by real sample volume," macro answers "how good on the
|
|
263
|
+
average task, regardless of how many examples it had."
|
|
264
|
+
"""
|
|
265
|
+
task_rows = self.per_task_table()
|
|
266
|
+
acc: dict[str, dict[str, list[float]]] = {}
|
|
267
|
+
for row in task_rows:
|
|
268
|
+
metric = row["metric"]
|
|
269
|
+
for name in self.runs:
|
|
270
|
+
val = row.get(f"{name}_score")
|
|
271
|
+
if val is not None:
|
|
272
|
+
acc.setdefault(metric, {}).setdefault(name, []).append(val)
|
|
273
|
+
|
|
274
|
+
dirs = metric_directions(self.runs.values())
|
|
275
|
+
rows = []
|
|
276
|
+
for metric in sorted(acc):
|
|
277
|
+
per_model = acc[metric]
|
|
278
|
+
row: dict[str, Any] = {"metric": metric}
|
|
279
|
+
scores: dict[str, float] = {}
|
|
280
|
+
for name in self.runs:
|
|
281
|
+
vals = per_model.get(name, [])
|
|
282
|
+
macro_mean = sum(vals) / len(vals) if vals else None
|
|
283
|
+
row[f"{name}_macro_mean"] = macro_mean
|
|
284
|
+
row[f"{name}_num_tasks"] = len(vals)
|
|
285
|
+
if macro_mean is not None:
|
|
286
|
+
scores[name] = macro_mean
|
|
287
|
+
row["winner"] = best_model(scores, direction_for(metric, dirs))
|
|
288
|
+
rows.append(row)
|
|
289
|
+
return rows
|
|
290
|
+
|
|
291
|
+
def performance_table(self) -> list[dict[str, Any]]:
|
|
292
|
+
"""Each model's measured latency/throughput side by side -- the
|
|
293
|
+
N-model analog of ``RunComparison.performance()``. Straight from
|
|
294
|
+
each run's ``RunResult.perf`` (populated by ``Runner`` while it ran);
|
|
295
|
+
a model with no timed calls (e.g. a ``reads_actual_output`` run), or
|
|
296
|
+
one whose ``RunConfig.track_performance`` was never enabled at all,
|
|
297
|
+
reports ``None``s rather than being omitted or crashing, so the row
|
|
298
|
+
shape stays uniform across models."""
|
|
299
|
+
rows = []
|
|
300
|
+
for name, run in self.runs.items():
|
|
301
|
+
perf = run.perf
|
|
302
|
+
lat = (perf.get("latency_ms") or {}) if perf is not None else None
|
|
303
|
+
thr = (perf.get("throughput") or {}) if perf is not None else None
|
|
304
|
+
rows.append({
|
|
305
|
+
"model": name,
|
|
306
|
+
"latency_mean_ms": lat.get("mean") if lat is not None else None,
|
|
307
|
+
"throughput_rps": thr.get("rps") if thr is not None else None,
|
|
308
|
+
"output_tokens_per_sec": thr.get("output_tokens_per_sec") if thr is not None else None,
|
|
309
|
+
"total_tokens_per_sec": thr.get("total_tokens_per_sec") if thr is not None else None,
|
|
310
|
+
"calls": lat.get("count", 0) if lat is not None else None,
|
|
311
|
+
})
|
|
312
|
+
return rows
|
|
313
|
+
|
|
314
|
+
def cost_table(self, pricing: dict[str, dict[str, float]]) -> list[dict[str, Any]]:
|
|
315
|
+
"""Dollar cost per model, given *pricing* -- ``{model_name:
|
|
316
|
+
{"input_per_1m": ..., "output_per_1m": ...}}``. Token counts are the
|
|
317
|
+
real, provider-reported numbers already on each ``RunResult``
|
|
318
|
+
(``.token_usage``); there is no library default price table (see
|
|
319
|
+
``RunResult.cost()``), so a model with no entry in *pricing*, no
|
|
320
|
+
recorded token usage at all, or ``RunConfig.track_performance``
|
|
321
|
+
never enabled, reports ``cost=None`` and ``None`` token counts
|
|
322
|
+
rather than a guess or a crash."""
|
|
323
|
+
rows = []
|
|
324
|
+
for name, run in self.runs.items():
|
|
325
|
+
model_pricing = pricing.get(name)
|
|
326
|
+
cost = run.cost(model_pricing) if model_pricing else None
|
|
327
|
+
usage = run.token_usage
|
|
328
|
+
rows.append({
|
|
329
|
+
"model": name,
|
|
330
|
+
"prompt_tokens": usage.get("prompt_tokens", 0) if usage is not None else None,
|
|
331
|
+
"completion_tokens": usage.get("completion_tokens", 0) if usage is not None else None,
|
|
332
|
+
"cost": cost,
|
|
333
|
+
})
|
|
334
|
+
return rows
|
|
335
|
+
|
|
336
|
+
def size_table(self) -> list[dict[str, Any]]:
|
|
337
|
+
"""Each model's size, side by side -- never caller-supplied. Local
|
|
338
|
+
backends report introspected params/sparsity/MB; hosted-API backends
|
|
339
|
+
(or anything with no ``model_size`` recorded) report just an
|
|
340
|
+
identity so the row still names the model instead of a blank."""
|
|
341
|
+
rows = []
|
|
342
|
+
for name, run in self.runs.items():
|
|
343
|
+
ms = run.model_size or {}
|
|
344
|
+
rows.append({
|
|
345
|
+
"model": name,
|
|
346
|
+
"is_local": ms.get("is_local", False),
|
|
347
|
+
"model_name": ms.get("model_name"),
|
|
348
|
+
"total_params": ms.get("total_params"),
|
|
349
|
+
"sparsity": ms.get("sparsity"),
|
|
350
|
+
"size_mb": ms.get("size_mb"),
|
|
351
|
+
})
|
|
352
|
+
return rows
|
|
353
|
+
|
|
354
|
+
def summary(self) -> str:
|
|
355
|
+
lines = [f"Model Comparison — {self.dataset_size} samples, {len(self.scorers)} scorers", ""]
|
|
356
|
+
|
|
357
|
+
lines.append("Runs:")
|
|
358
|
+
for name, run in self.runs.items():
|
|
359
|
+
flag = f" [!! {run.failed_count} FAILED]" if run.failed_count else ""
|
|
360
|
+
lines.append(f" {name}: run_id={run.run_id}{flag}")
|
|
361
|
+
for name, err in self.errors.items():
|
|
362
|
+
lines.append(f" {name}: DID NOT RUN -- {err}")
|
|
363
|
+
lines.append("")
|
|
364
|
+
|
|
365
|
+
cw = self.coverage_warnings()
|
|
366
|
+
if cw:
|
|
367
|
+
lines.append("WARNING -- coverage issues across models:")
|
|
368
|
+
lines += [f" {w}" for w in cw]
|
|
369
|
+
lines.append("")
|
|
370
|
+
|
|
371
|
+
metrics = self.per_metric()
|
|
372
|
+
if not metrics:
|
|
373
|
+
lines.append("(no metrics)")
|
|
374
|
+
return "\n".join(lines)
|
|
375
|
+
|
|
376
|
+
model_names = list(self.runs.keys())
|
|
377
|
+
table = {row["metric"]: row for row in self.comparison_table()}
|
|
378
|
+
header = f"{'Metric':<25}" + "".join(f"{m:<26}" for m in model_names)
|
|
379
|
+
lines.append(header)
|
|
380
|
+
lines.append("-" * len(header))
|
|
381
|
+
for m in metrics:
|
|
382
|
+
row = f"{m.metric:<25}"
|
|
383
|
+
for name in model_names:
|
|
384
|
+
val = m.scores.get(name, 0)
|
|
385
|
+
n = table[m.metric].get(f"{name}_n", 0)
|
|
386
|
+
marker = " <-" if m.winner == name else ""
|
|
387
|
+
cell = f"{val:.4f} (n={n}){marker}"
|
|
388
|
+
row += f"{cell:<26}"
|
|
389
|
+
lines.append(row)
|
|
390
|
+
|
|
391
|
+
task_rows = self.per_task_table()
|
|
392
|
+
# Only show the per-task breakout if it says something the blended
|
|
393
|
+
# table above doesn't -- i.e. there's more than one distinct task.
|
|
394
|
+
if len({r["task"] for r in task_rows}) > 1:
|
|
395
|
+
lines.append("")
|
|
396
|
+
lines.append("Per-task:")
|
|
397
|
+
task_header = f" {'Task/Metric':<25}" + "".join(f"{m:<26}" for m in model_names)
|
|
398
|
+
lines.append(task_header)
|
|
399
|
+
lines.append(" " + "-" * (len(task_header) - 2))
|
|
400
|
+
for row in task_rows:
|
|
401
|
+
label = f"{row['task']}/{row['metric']}"
|
|
402
|
+
line = f" {label:<25}"
|
|
403
|
+
for name in model_names:
|
|
404
|
+
val = row.get(f"{name}_score")
|
|
405
|
+
n = row.get(f"{name}_n", 0)
|
|
406
|
+
marker = " <-" if row["winner"] == name else ""
|
|
407
|
+
cell = "n/a" if val is None else f"{val:.4f} (n={n}){marker}"
|
|
408
|
+
line += f"{cell:<26}"
|
|
409
|
+
lines.append(line)
|
|
410
|
+
|
|
411
|
+
lines.append("")
|
|
412
|
+
lines.append("Macro-average across tasks (each task weighted equally, not by sample count):")
|
|
413
|
+
macro_header = f" {'Metric':<25}" + "".join(f"{m:<26}" for m in model_names)
|
|
414
|
+
lines.append(macro_header)
|
|
415
|
+
lines.append(" " + "-" * (len(macro_header) - 2))
|
|
416
|
+
for row in self.task_macro_average_table():
|
|
417
|
+
line = f" {row['metric']:<25}"
|
|
418
|
+
for name in model_names:
|
|
419
|
+
val = row.get(f"{name}_macro_mean")
|
|
420
|
+
marker = " <-" if row["winner"] == name else ""
|
|
421
|
+
cell = "n/a" if val is None else f"{val:.4f}{marker}"
|
|
422
|
+
line += f"{cell:<26}"
|
|
423
|
+
lines.append(line)
|
|
424
|
+
|
|
425
|
+
size_rows = [r for r in self.size_table() if r["model_name"] is not None]
|
|
426
|
+
if size_rows:
|
|
427
|
+
lines.append("")
|
|
428
|
+
lines.append("Model size:")
|
|
429
|
+
for row in size_rows:
|
|
430
|
+
if row["is_local"] and row["size_mb"] is not None:
|
|
431
|
+
lines.append(
|
|
432
|
+
f" {row['model']} ({row['model_name']}): "
|
|
433
|
+
f"{row['total_params']:,} params "
|
|
434
|
+
f"({row['sparsity']:.1%} sparse), {row['size_mb']:.1f} MB"
|
|
435
|
+
)
|
|
436
|
+
elif row["is_local"]:
|
|
437
|
+
# is_local=True but no size_mb -- a local backend that
|
|
438
|
+
# doesn't actually introspect size yet (e.g. VLLMModel),
|
|
439
|
+
# not a hosted-API model. Different reason, same "nothing
|
|
440
|
+
# to report" outcome -- said explicitly either way.
|
|
441
|
+
lines.append(f" {row['model']} ({row['model_name']}): local model, size not introspected")
|
|
442
|
+
else:
|
|
443
|
+
lines.append(f" {row['model']} ({row['model_name']}): not a local model -- no measurable size")
|
|
444
|
+
|
|
445
|
+
perf_rows = [r for r in self.performance_table() if r["calls"]]
|
|
446
|
+
if perf_rows:
|
|
447
|
+
lines.append("")
|
|
448
|
+
lines.append("Performance (measured):")
|
|
449
|
+
perf_header = f" {'Model':<20}{'latency (ms)':<14}{'req/s':<10}{'out-tok/s':<12}"
|
|
450
|
+
lines.append(perf_header)
|
|
451
|
+
lines.append(" " + "-" * (len(perf_header) - 2))
|
|
452
|
+
for row in self.performance_table():
|
|
453
|
+
if not row["calls"]:
|
|
454
|
+
lines.append(f" {row['model']:<20}{'n/a':<14}{'n/a':<10}{'n/a':<12}")
|
|
455
|
+
continue
|
|
456
|
+
# output_tokens_per_sec is 0 for local/callable backends that
|
|
457
|
+
# never report token usage -- shown as "n/a", not a
|
|
458
|
+
# misleading measured zero.
|
|
459
|
+
tok_s = row.get("output_tokens_per_sec") or 0.0
|
|
460
|
+
tok_cell = f"{tok_s:.1f}" if tok_s else "n/a"
|
|
461
|
+
lines.append(
|
|
462
|
+
f" {row['model']:<20}{row['latency_mean_ms']:<14.0f}"
|
|
463
|
+
f"{row['throughput_rps']:<10.2f}{tok_cell:<12}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
lines.append("")
|
|
467
|
+
lines.append("Significance (pairwise bootstrap p < 0.05):")
|
|
468
|
+
for i, a in enumerate(model_names):
|
|
469
|
+
for b in model_names[i + 1:]:
|
|
470
|
+
sig = self.significance(a, b)
|
|
471
|
+
p = sig.get("p_value", 1)
|
|
472
|
+
label = "significant" if sig.get("significant") else "not significant"
|
|
473
|
+
lines.append(f" {a} vs {b}: p={p:.4f} ({label})")
|
|
474
|
+
|
|
475
|
+
return "\n".join(lines)
|
|
476
|
+
|
|
477
|
+
def push_to_hub(
|
|
478
|
+
self,
|
|
479
|
+
repo_id: str,
|
|
480
|
+
private: bool = False,
|
|
481
|
+
token: str | None = None,
|
|
482
|
+
) -> str:
|
|
483
|
+
"""Push per-model predictions to a Hub dataset and stamp the AuditKIT card."""
|
|
484
|
+
from .hf_publish import push_rows_to_hub
|
|
485
|
+
|
|
486
|
+
rows: list[dict[str, Any]] = []
|
|
487
|
+
for name, run in self.runs.items():
|
|
488
|
+
for p in run.predictions:
|
|
489
|
+
doc = p.to_doc()
|
|
490
|
+
doc["model"] = name
|
|
491
|
+
rows.append(doc)
|
|
492
|
+
return push_rows_to_hub(
|
|
493
|
+
rows,
|
|
494
|
+
repo_id,
|
|
495
|
+
private=private,
|
|
496
|
+
token=token,
|
|
497
|
+
kind="compare",
|
|
498
|
+
method="compare_models",
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def compare_models(
|
|
503
|
+
models: Sequence[Union[str, Model, Callable[[list[str]], list[str]]]],
|
|
504
|
+
dataset: Union[Scenario, Sequence[Sample], Callable[[], Iterable[Sample]], str],
|
|
505
|
+
scorers: Any = None,
|
|
506
|
+
*,
|
|
507
|
+
model_names: list[str] | None = None,
|
|
508
|
+
adapter: Union[Adapter, str, None] = None,
|
|
509
|
+
annotators: Any = None,
|
|
510
|
+
extract_with: str | None = None,
|
|
511
|
+
config: RunConfig | None = None,
|
|
512
|
+
configs: dict[str, RunConfig] | None = None,
|
|
513
|
+
model_opts: dict[str, dict[str, Any]] | None = None,
|
|
514
|
+
experiment_name: str | None = None,
|
|
515
|
+
tags: list[str] | None = None,
|
|
516
|
+
**opts: Any,
|
|
517
|
+
) -> CompareResult:
|
|
518
|
+
"""Evaluate the same dataset against multiple models and compare results.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
models: List of model specs (strings, Model instances, or callables).
|
|
522
|
+
dataset: Dataset to evaluate (same for all models).
|
|
523
|
+
scorers: Metrics to use (same for all models -- comparison across
|
|
524
|
+
models assumes a shared metric set, so this is not overridable
|
|
525
|
+
per-model via ``model_opts``).
|
|
526
|
+
model_names: Optional human-readable names for each model.
|
|
527
|
+
adapter: Adapter shared across all models unless overridden per-model
|
|
528
|
+
via ``model_opts``. Same default as ``evaluate()`` (``None`` ->
|
|
529
|
+
``GenerationAdapter``) when not given here.
|
|
530
|
+
annotators: Annotator(s) shared across all models unless overridden
|
|
531
|
+
per-model via ``model_opts``.
|
|
532
|
+
extract_with: Annotator name whose extraction feeds scoring, shared
|
|
533
|
+
across all models unless overridden per-model via ``model_opts``.
|
|
534
|
+
config: Run configuration shared across all evaluations.
|
|
535
|
+
configs: Optional per-model ``RunConfig`` overrides, keyed by model name
|
|
536
|
+
(falls back to ``config`` when a name is absent). Use this when a
|
|
537
|
+
quantized/pruned model legitimately needs different knobs than the base.
|
|
538
|
+
model_opts: Optional per-model overrides, keyed by model name. Each
|
|
539
|
+
entry is itself a dict that may contain any of ``adapter``/
|
|
540
|
+
``annotators``/``extract_with`` (overriding the shared value
|
|
541
|
+
above, for that model only) plus arbitrary backend construction
|
|
542
|
+
kwargs (e.g. ``dtype``/``device``, layered over the shared
|
|
543
|
+
``**opts``) -- anything not recognized as a pipeline override is
|
|
544
|
+
forwarded to model resolution unchanged.
|
|
545
|
+
experiment_name: If set, each model's run is persisted under
|
|
546
|
+
``f"{experiment_name}:{name}"`` (mirrors ``evaluate_many()``).
|
|
547
|
+
Explicit real params, not swept into ``**opts`` -- previously
|
|
548
|
+
passing ``experiment_name=``/``tags=`` here silently leaked them
|
|
549
|
+
into ``AutoModel.resolve(**opts)``, i.e. into each model's own
|
|
550
|
+
constructor kwargs, and for a real hosted API backend that meant
|
|
551
|
+
landing in the live HTTP request body, which the provider then
|
|
552
|
+
rejected outright (a real, confirmed Groq 400).
|
|
553
|
+
tags: Passed to each model's underlying ``evaluate()`` call.
|
|
554
|
+
**opts: Extra kwargs forwarded to model resolution for every model.
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
A CompareResult with per-model runs and comparison methods.
|
|
558
|
+
"""
|
|
559
|
+
names = model_names or [str(m) if isinstance(m, str) else getattr(m, "name", f"model_{i}") for i, m in enumerate(models)]
|
|
560
|
+
if len(names) != len(models):
|
|
561
|
+
raise ValueError("model_names must match number of models")
|
|
562
|
+
|
|
563
|
+
runs: dict[str, RunResult] = {}
|
|
564
|
+
errors: dict[str, str] = {}
|
|
565
|
+
for i, (name, model_spec) in enumerate(zip(names, models)):
|
|
566
|
+
run_config = (configs or {}).get(name, config)
|
|
567
|
+
this_opts = dict((model_opts or {}).get(name, {}))
|
|
568
|
+
run_adapter = this_opts.pop("adapter", adapter)
|
|
569
|
+
run_annotators = this_opts.pop("annotators", annotators)
|
|
570
|
+
run_extract_with = this_opts.pop("extract_with", extract_with)
|
|
571
|
+
run_opts = {**opts, **this_opts}
|
|
572
|
+
# Resolve the model HERE (rather than letting evaluate() do it) so we
|
|
573
|
+
# hold the instance and can free its memory before the next model
|
|
574
|
+
# loads -- keeping peak GPU use at one model instead of accumulating
|
|
575
|
+
# across the comparison. Only tear down a model we resolved ourselves;
|
|
576
|
+
# a Model instance the caller passed in is theirs to keep/reuse.
|
|
577
|
+
owns = not isinstance(model_spec, Model)
|
|
578
|
+
# vLLM sleep mode is one engine per process. Enable it only when a
|
|
579
|
+
# later vllm: model still has to load, so unload() can sleep(level=2)
|
|
580
|
+
# and free the T4. The last vllm: engine stays a normal LLM().
|
|
581
|
+
# Confirmed live: enabling it on every model raised
|
|
582
|
+
# "Sleep mode can only be used for one instance per process."
|
|
583
|
+
if (
|
|
584
|
+
owns
|
|
585
|
+
and isinstance(model_spec, str)
|
|
586
|
+
and model_spec.startswith("vllm:")
|
|
587
|
+
and any(
|
|
588
|
+
isinstance(m, str) and m.startswith("vllm:")
|
|
589
|
+
for m in models[i + 1:]
|
|
590
|
+
)
|
|
591
|
+
):
|
|
592
|
+
run_opts.setdefault("enable_sleep_mode", True)
|
|
593
|
+
resolved: Model | None = None
|
|
594
|
+
try:
|
|
595
|
+
resolved = AutoModel.resolve(model_spec, **run_opts)
|
|
596
|
+
# opts already applied at resolve(); don't re-forward them to
|
|
597
|
+
# evaluate (a resolved instance ignores them anyway).
|
|
598
|
+
result = evaluate(
|
|
599
|
+
dataset, model=resolved, scorers=scorers, config=run_config,
|
|
600
|
+
adapter=run_adapter, annotators=run_annotators, extract_with=run_extract_with,
|
|
601
|
+
experiment_name=f"{experiment_name}:{name}" if experiment_name else None,
|
|
602
|
+
tags=tags,
|
|
603
|
+
)
|
|
604
|
+
except Exception as e:
|
|
605
|
+
# One bad model (an invalid/decommissioned spec, a real API
|
|
606
|
+
# error that exhausted retries, ...) used to crash this whole
|
|
607
|
+
# function, discarding every other model's already-computed
|
|
608
|
+
# results too. Record it and keep going instead -- callers can
|
|
609
|
+
# see exactly which model(s) failed via CompareResult.errors/
|
|
610
|
+
# .coverage_warnings(), rather than losing everything.
|
|
611
|
+
detail = str(e)
|
|
612
|
+
if e.__cause__ is not None:
|
|
613
|
+
# Runner._retry_generate() re-raises as a generic ModelError
|
|
614
|
+
# ("generate failed after N retries"), chaining the real
|
|
615
|
+
# underlying exception via `raise ... from` -- surface that
|
|
616
|
+
# cause too, or the recorded message is nearly useless.
|
|
617
|
+
detail = f"{detail} (caused by {type(e.__cause__).__name__}: {e.__cause__})"
|
|
618
|
+
errors[name] = f"{type(e).__name__}: {detail}"
|
|
619
|
+
logger.error("compare_models(): model %r failed entirely: %s", name, errors[name])
|
|
620
|
+
continue
|
|
621
|
+
finally:
|
|
622
|
+
# Free this model's local weights/GPU memory before the next one
|
|
623
|
+
# loads (no-op for API/callable backends). Runs on both the success
|
|
624
|
+
# and error paths; only for a model we resolved ourselves. The
|
|
625
|
+
# RunResult stores a stringified model_spec, not the live model, so
|
|
626
|
+
# unloading after evaluate() returns never affects the result.
|
|
627
|
+
if owns and resolved is not None:
|
|
628
|
+
try:
|
|
629
|
+
resolved.unload()
|
|
630
|
+
except Exception: # noqa: BLE001 -- teardown is best-effort
|
|
631
|
+
logger.debug("compare_models(): unload() failed for %r", name, exc_info=True)
|
|
632
|
+
runs[name] = result
|
|
633
|
+
|
|
634
|
+
first = next(iter(models), None)
|
|
635
|
+
if first is None:
|
|
636
|
+
raise ValueError("At least one model is required")
|
|
637
|
+
if isinstance(dataset, str):
|
|
638
|
+
from .registry import SCENARIOS
|
|
639
|
+
scenario = SCENARIOS.get(dataset)()
|
|
640
|
+
samples = list(scenario.samples())
|
|
641
|
+
elif isinstance(dataset, Scenario):
|
|
642
|
+
samples = list(dataset.samples())
|
|
643
|
+
elif callable(dataset):
|
|
644
|
+
samples = list(dataset())
|
|
645
|
+
else:
|
|
646
|
+
samples = list(dataset)
|
|
647
|
+
|
|
648
|
+
scorer_names = list(next(iter(runs.values())).headline.keys()) if runs else []
|
|
649
|
+
|
|
650
|
+
return CompareResult(
|
|
651
|
+
runs=runs,
|
|
652
|
+
dataset_size=len(samples),
|
|
653
|
+
scorers=scorer_names,
|
|
654
|
+
errors=errors,
|
|
655
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Red Teaming
|
|
2
|
+
|
|
3
|
+
Probes and detectors for adversarial evaluation of LLM safety and robustness.
|
|
4
|
+
|
|
5
|
+
**Built-in probes** — `prompt_injection`, `jailbreak`, `encoding`, `refusal`
|
|
6
|
+
|
|
7
|
+
**Built-in detectors** — `keyword`, `refusal`, `injection_success`, `system_prompt_leak`
|
|
8
|
+
|
|
9
|
+
Run with `RedTeamRunner` which orchestrates probe → model → detector pipelines.
|