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,331 @@
|
|
|
1
|
+
"""Text generation quality metrics (BLEU, ROUGE, chrF, WER, Perplexity, BERTScore)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from auditkit.metric import Metric
|
|
8
|
+
from auditkit.registry import METRICS
|
|
9
|
+
from auditkit.sample import Sample
|
|
10
|
+
from auditkit.score import Score
|
|
11
|
+
from auditkit.types import Direction, ScoreKind
|
|
12
|
+
from auditkit.errors import ExtraNotInstalled
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@METRICS.register("bleu")
|
|
16
|
+
class Bleu(Metric):
|
|
17
|
+
name = "bleu"
|
|
18
|
+
kind = ScoreKind.BENCHMARK
|
|
19
|
+
direction = Direction.MAXIMIZE
|
|
20
|
+
is_deterministic = True
|
|
21
|
+
required_fields = frozenset({"target"})
|
|
22
|
+
|
|
23
|
+
def __init__(self, max_n: int = 4, smooth: bool = True) -> None:
|
|
24
|
+
self._max_n = max_n
|
|
25
|
+
self._smooth = smooth
|
|
26
|
+
|
|
27
|
+
def identity(self) -> dict:
|
|
28
|
+
return {"name": self.name, "max_n": self._max_n, "smooth": self._smooth}
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _ngrams(tokens: list[str], n: int) -> list[tuple[str, ...]]:
|
|
32
|
+
return [tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)]
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _brevity_penalty(ref_len: int, hyp_len: int) -> float:
|
|
36
|
+
if hyp_len == 0:
|
|
37
|
+
return 0.0
|
|
38
|
+
if hyp_len >= ref_len:
|
|
39
|
+
return 1.0
|
|
40
|
+
return math.exp(1.0 - ref_len / hyp_len)
|
|
41
|
+
|
|
42
|
+
def score(self, sample: Sample, output: str, context: Any = None) -> Score:
|
|
43
|
+
ref = (sample.target or "").split()
|
|
44
|
+
hyp = output.split()
|
|
45
|
+
if not ref or not hyp:
|
|
46
|
+
return Score(name=self.name, value=0.0, kind=self.kind)
|
|
47
|
+
|
|
48
|
+
max_n = min(self._max_n, max(len(ref), len(hyp)))
|
|
49
|
+
precisions = []
|
|
50
|
+
for n in range(1, max_n + 1):
|
|
51
|
+
hyp_ngrams = self._ngrams(hyp, n)
|
|
52
|
+
ref_ngrams = self._ngrams(ref, n)
|
|
53
|
+
if not hyp_ngrams:
|
|
54
|
+
precisions.append(1e-12 if self._smooth else 0.0)
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
ref_counts = {}
|
|
58
|
+
for ng in ref_ngrams:
|
|
59
|
+
ref_counts[ng] = ref_counts.get(ng, 0) + 1
|
|
60
|
+
|
|
61
|
+
count = 0
|
|
62
|
+
clipped = 0
|
|
63
|
+
for ng in hyp_ngrams:
|
|
64
|
+
count += 1
|
|
65
|
+
if ref_counts.get(ng, 0) > 0:
|
|
66
|
+
clipped += 1
|
|
67
|
+
ref_counts[ng] -= 1
|
|
68
|
+
|
|
69
|
+
p = clipped / count if count > 0 else 0.0
|
|
70
|
+
if self._smooth and n > 1 and p == 0.0:
|
|
71
|
+
p = 1e-12
|
|
72
|
+
precisions.append(p)
|
|
73
|
+
|
|
74
|
+
if not precisions:
|
|
75
|
+
return Score(name=self.name, value=0.0, kind=self.kind)
|
|
76
|
+
if any(p <= 0.0 for p in precisions):
|
|
77
|
+
if self._smooth:
|
|
78
|
+
precisions = [max(p, 1e-12) for p in precisions]
|
|
79
|
+
else:
|
|
80
|
+
return Score(name=self.name, value=0.0, kind=self.kind)
|
|
81
|
+
log_sum = sum(math.log(p) for p in precisions)
|
|
82
|
+
geo_mean = math.exp(log_sum / len(precisions))
|
|
83
|
+
bp = self._brevity_penalty(len(ref), len(hyp))
|
|
84
|
+
return Score(name=self.name, value=bp * geo_mean, kind=self.kind)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@METRICS.register("rouge_l")
|
|
88
|
+
class RogueL(Metric):
|
|
89
|
+
name = "rouge_l"
|
|
90
|
+
kind = ScoreKind.BENCHMARK
|
|
91
|
+
direction = Direction.MAXIMIZE
|
|
92
|
+
is_deterministic = True
|
|
93
|
+
required_fields = frozenset({"target"})
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _lcs_length(a: list[str], b: list[str]) -> int:
|
|
97
|
+
m, n = len(a), len(b)
|
|
98
|
+
dp = [[0] * (n + 1) for _ in range(m + 1)]
|
|
99
|
+
for i in range(1, m + 1):
|
|
100
|
+
for j in range(1, n + 1):
|
|
101
|
+
if a[i - 1] == b[j - 1]:
|
|
102
|
+
dp[i][j] = dp[i - 1][j - 1] + 1
|
|
103
|
+
else:
|
|
104
|
+
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
|
|
105
|
+
return dp[m][n]
|
|
106
|
+
|
|
107
|
+
def score(self, sample: Sample, output: str, context: Any = None) -> Score:
|
|
108
|
+
ref = (sample.target or "").split()
|
|
109
|
+
hyp = output.split()
|
|
110
|
+
if not ref and not hyp:
|
|
111
|
+
return Score(name=self.name, value=1.0, kind=self.kind)
|
|
112
|
+
if not ref or not hyp:
|
|
113
|
+
return Score(name=self.name, value=0.0, kind=self.kind)
|
|
114
|
+
|
|
115
|
+
lcs_len = self._lcs_length(hyp, ref)
|
|
116
|
+
precision = lcs_len / len(hyp)
|
|
117
|
+
recall = lcs_len / len(ref)
|
|
118
|
+
if precision == 0.0 and recall == 0.0:
|
|
119
|
+
f1 = 0.0
|
|
120
|
+
else:
|
|
121
|
+
f1 = 2.0 * precision * recall / (precision + recall)
|
|
122
|
+
return Score(name=self.name, value=f1, kind=self.kind)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@METRICS.register("chrf")
|
|
126
|
+
class ChrF(Metric):
|
|
127
|
+
name = "chrf"
|
|
128
|
+
kind = ScoreKind.BENCHMARK
|
|
129
|
+
direction = Direction.MAXIMIZE
|
|
130
|
+
is_deterministic = True
|
|
131
|
+
required_fields = frozenset({"target"})
|
|
132
|
+
|
|
133
|
+
def __init__(self, n: int = 6, beta: float = 1.0) -> None:
|
|
134
|
+
self._n = n
|
|
135
|
+
self._beta = beta
|
|
136
|
+
|
|
137
|
+
def identity(self) -> dict:
|
|
138
|
+
return {"name": self.name, "n": self._n, "beta": self._beta}
|
|
139
|
+
|
|
140
|
+
def score(self, sample: Sample, output: str, context: Any = None) -> Score:
|
|
141
|
+
ref = sample.target or ""
|
|
142
|
+
hyp = output
|
|
143
|
+
if not ref or not hyp:
|
|
144
|
+
return Score(name=self.name, value=0.0, kind=self.kind)
|
|
145
|
+
|
|
146
|
+
# Cap by the shorter string's length, same as Bleu does for its word
|
|
147
|
+
# n-grams -- without this, a short string (e.g. "hi"/"hi", 2 chars)
|
|
148
|
+
# still gets n=3..6 character n-gram orders folded into the average,
|
|
149
|
+
# each contributing a spurious 0 precision/recall since no n-gram of
|
|
150
|
+
# that length can even exist, understating an otherwise-perfect match.
|
|
151
|
+
max_n = min(self._n, max(len(ref), len(hyp)))
|
|
152
|
+
total_p = 0.0
|
|
153
|
+
total_r = 0.0
|
|
154
|
+
for n in range(1, max_n + 1):
|
|
155
|
+
ref_ngrams: dict[str, int] = {}
|
|
156
|
+
for i in range(len(ref) - n + 1):
|
|
157
|
+
ng = ref[i:i + n]
|
|
158
|
+
ref_ngrams[ng] = ref_ngrams.get(ng, 0) + 1
|
|
159
|
+
|
|
160
|
+
hyp_ngrams: dict[str, int] = {}
|
|
161
|
+
for i in range(len(hyp) - n + 1):
|
|
162
|
+
ng = hyp[i:i + n]
|
|
163
|
+
hyp_ngrams[ng] = hyp_ngrams.get(ng, 0) + 1
|
|
164
|
+
|
|
165
|
+
match_count = 0
|
|
166
|
+
for ng, count in hyp_ngrams.items():
|
|
167
|
+
match_count += min(count, ref_ngrams.get(ng, 0))
|
|
168
|
+
|
|
169
|
+
hyp_count = sum(hyp_ngrams.values())
|
|
170
|
+
ref_count = sum(ref_ngrams.values())
|
|
171
|
+
|
|
172
|
+
p = match_count / hyp_count if hyp_count > 0 else 0.0
|
|
173
|
+
r = match_count / ref_count if ref_count > 0 else 0.0
|
|
174
|
+
total_p += p
|
|
175
|
+
total_r += r
|
|
176
|
+
|
|
177
|
+
avg_p = total_p / max_n
|
|
178
|
+
avg_r = total_r / max_n
|
|
179
|
+
beta2 = self._beta ** 2
|
|
180
|
+
if avg_p == 0.0 and avg_r == 0.0:
|
|
181
|
+
f_beta = 0.0
|
|
182
|
+
else:
|
|
183
|
+
f_beta = (1.0 + beta2) * avg_p * avg_r / (beta2 * avg_p + avg_r)
|
|
184
|
+
return Score(name=self.name, value=f_beta, kind=self.kind)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@METRICS.register("word_error_rate")
|
|
188
|
+
class WordErrorRate(Metric):
|
|
189
|
+
name = "wer"
|
|
190
|
+
kind = ScoreKind.BENCHMARK
|
|
191
|
+
# MAXIMIZE, not MINIMIZE despite the class name: .score() below already
|
|
192
|
+
# inverts to `max(0.0, 1.0 - wer)`, a 0-1 "higher is better" value, not
|
|
193
|
+
# raw word-error-rate. Direction describes the returned VALUE's
|
|
194
|
+
# convention, not the underlying named phenomenon.
|
|
195
|
+
direction = Direction.MAXIMIZE
|
|
196
|
+
is_deterministic = True
|
|
197
|
+
required_fields = frozenset({"target"})
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _word_edit_distance(ref_words: list[str], hyp_words: list[str]) -> int:
|
|
201
|
+
m, n = len(ref_words), len(hyp_words)
|
|
202
|
+
prev = list(range(n + 1))
|
|
203
|
+
for i in range(1, m + 1):
|
|
204
|
+
curr = [i] * (n + 1)
|
|
205
|
+
for j in range(1, n + 1):
|
|
206
|
+
cost = 0 if ref_words[i - 1] == hyp_words[j - 1] else 1
|
|
207
|
+
curr[j] = min(
|
|
208
|
+
prev[j] + 1,
|
|
209
|
+
curr[j - 1] + 1,
|
|
210
|
+
prev[j - 1] + cost,
|
|
211
|
+
)
|
|
212
|
+
prev = curr
|
|
213
|
+
return prev[n]
|
|
214
|
+
|
|
215
|
+
def score(self, sample: Sample, output: str, context: Any = None) -> Score:
|
|
216
|
+
ref = (sample.target or "").split()
|
|
217
|
+
hyp = output.split()
|
|
218
|
+
if not ref and not hyp:
|
|
219
|
+
return Score(name=self.name, value=1.0, kind=self.kind)
|
|
220
|
+
if not ref:
|
|
221
|
+
return Score(name=self.name, value=0.0, kind=self.kind)
|
|
222
|
+
|
|
223
|
+
distance = self._word_edit_distance(ref, hyp)
|
|
224
|
+
wer = distance / len(ref)
|
|
225
|
+
# Word error rate itself is unbounded above (a much-longer/garbled
|
|
226
|
+
# hyp can need more edits than ref has words), so 1.0 - wer can go
|
|
227
|
+
# arbitrarily negative -- clamp to the [0, 1] range every other
|
|
228
|
+
# normalized metric in this module promises, rather than leaking an
|
|
229
|
+
# unbounded value into aggregation/thresholding code that assumes it.
|
|
230
|
+
return Score(name=self.name, value=max(0.0, 1.0 - wer), kind=self.kind)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@METRICS.register("perplexity")
|
|
234
|
+
class Perplexity(Metric):
|
|
235
|
+
name = "perplexity"
|
|
236
|
+
kind = ScoreKind.BENCHMARK
|
|
237
|
+
# Genuinely MINIMIZE: .score() returns raw exp(loss), unbounded and
|
|
238
|
+
# not inverted -- lower perplexity is a more confident/correct model.
|
|
239
|
+
direction = Direction.MINIMIZE
|
|
240
|
+
is_deterministic = False
|
|
241
|
+
required_fields = frozenset({"target"})
|
|
242
|
+
|
|
243
|
+
def __init__(self, model_name: str = "gpt2") -> None:
|
|
244
|
+
self._model_name = model_name
|
|
245
|
+
self._model = None
|
|
246
|
+
self._tokenizer = None
|
|
247
|
+
|
|
248
|
+
def identity(self) -> dict:
|
|
249
|
+
return {"name": self.name, "model_name": self._model_name}
|
|
250
|
+
|
|
251
|
+
def score(self, sample: Sample, output: str, context: Any = None) -> Score:
|
|
252
|
+
if self._model is None:
|
|
253
|
+
try:
|
|
254
|
+
import torch # noqa: F401
|
|
255
|
+
import transformers # noqa: F401
|
|
256
|
+
except ImportError:
|
|
257
|
+
raise ExtraNotInstalled("transformers", "pip install auditkit[transformers]") from None
|
|
258
|
+
self._tokenizer = transformers.AutoTokenizer.from_pretrained(self._model_name)
|
|
259
|
+
self._model = transformers.AutoModelForCausalLM.from_pretrained(self._model_name)
|
|
260
|
+
|
|
261
|
+
import torch
|
|
262
|
+
encodings = self._tokenizer(output, return_tensors="pt")
|
|
263
|
+
input_ids = encodings.input_ids
|
|
264
|
+
# A causal-LM loss needs at least 2 tokens (one to predict from, one
|
|
265
|
+
# to predict) -- a single-token `output` (a bare word/number/label,
|
|
266
|
+
# exactly what an annotator's extraction commonly produces) has
|
|
267
|
+
# nothing to shift against and silently returns nan. Prepend a
|
|
268
|
+
# BOS-equivalent token so there's always a predecessor; GPT-2-family
|
|
269
|
+
# tokenizers have no distinct bos_token, so eos_token_id doubles as
|
|
270
|
+
# the conventional sequence start there.
|
|
271
|
+
bos_id = self._tokenizer.bos_token_id
|
|
272
|
+
if bos_id is None:
|
|
273
|
+
bos_id = self._tokenizer.eos_token_id
|
|
274
|
+
if bos_id is not None:
|
|
275
|
+
bos = torch.tensor([[bos_id]], dtype=input_ids.dtype)
|
|
276
|
+
input_ids = torch.cat([bos, input_ids], dim=1)
|
|
277
|
+
with torch.no_grad():
|
|
278
|
+
outputs = self._model(input_ids, labels=input_ids)
|
|
279
|
+
loss = outputs.loss.item()
|
|
280
|
+
ppl = math.exp(loss)
|
|
281
|
+
return Score(name=self.name, value=ppl, kind=self.kind)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@METRICS.register("bert_score")
|
|
285
|
+
class BertScore(Metric):
|
|
286
|
+
name = "bert_score"
|
|
287
|
+
kind = ScoreKind.BENCHMARK
|
|
288
|
+
direction = Direction.MAXIMIZE
|
|
289
|
+
is_deterministic = False
|
|
290
|
+
required_fields = frozenset({"target"})
|
|
291
|
+
|
|
292
|
+
def __init__(self, model_name: str = "microsoft/deberta-xlarge-mnli") -> None:
|
|
293
|
+
self._model_name = model_name
|
|
294
|
+
|
|
295
|
+
def identity(self) -> dict:
|
|
296
|
+
return {"name": self.name, "model_name": self._model_name}
|
|
297
|
+
|
|
298
|
+
def score(self, sample: Sample, output: str, context: Any = None) -> Score:
|
|
299
|
+
try:
|
|
300
|
+
import sys
|
|
301
|
+
import bert_score.score # noqa: F401 -- ensures sys.modules entry exists
|
|
302
|
+
from bert_score import score as bert_scorer
|
|
303
|
+
except ImportError:
|
|
304
|
+
raise ExtraNotInstalled("bert-score", "pip install bert-score") from None
|
|
305
|
+
ref = [sample.target or ""]
|
|
306
|
+
hyp = [output]
|
|
307
|
+
# bert_score's own package __init__ does `from .score import score`,
|
|
308
|
+
# which shadows the `bert_score.score` attribute with that function --
|
|
309
|
+
# so the submodule must be reached via sys.modules, not attribute access.
|
|
310
|
+
_bs_score_mod = sys.modules["bert_score.score"]
|
|
311
|
+
original_get_tokenizer = _bs_score_mod.get_tokenizer
|
|
312
|
+
|
|
313
|
+
def _patched_get_tokenizer(model_type, use_fast_tokenizer=False):
|
|
314
|
+
# Some tokenizers (e.g. microsoft/deberta-xlarge-mnli, this
|
|
315
|
+
# metric's own default) report model_max_length as HuggingFace's
|
|
316
|
+
# "no limit configured" sentinel (~1e30). transformers>=5's
|
|
317
|
+
# Rust-backed truncation setup tries to convert that into a
|
|
318
|
+
# bounded int and raises OverflowError. bert_score itself never
|
|
319
|
+
# clamps this, so we do it here before it's used for truncation.
|
|
320
|
+
tok = original_get_tokenizer(model_type, use_fast_tokenizer)
|
|
321
|
+
if getattr(tok, "model_max_length", 0) > 100_000:
|
|
322
|
+
tok.model_max_length = 512
|
|
323
|
+
return tok
|
|
324
|
+
|
|
325
|
+
_bs_score_mod.get_tokenizer = _patched_get_tokenizer
|
|
326
|
+
try:
|
|
327
|
+
P, R, F1 = bert_scorer(hyp, ref, model_type=self._model_name, verbose=False)
|
|
328
|
+
finally:
|
|
329
|
+
_bs_score_mod.get_tokenizer = original_get_tokenizer
|
|
330
|
+
f1 = F1.item()
|
|
331
|
+
return Score(name=self.name, value=f1, kind=self.kind)
|