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.
Files changed (81) hide show
  1. auditkit/README.md +99 -0
  2. auditkit/__init__.py +177 -0
  3. auditkit/__main__.py +3 -0
  4. auditkit/_bootstrap.py +77 -0
  5. auditkit/_identity_guard.py +99 -0
  6. auditkit/adapter.py +264 -0
  7. auditkit/annotator.py +339 -0
  8. auditkit/api.py +502 -0
  9. auditkit/assets/auditkit_logo.png +0 -0
  10. auditkit/cache.py +47 -0
  11. auditkit/cli.py +417 -0
  12. auditkit/comparison.py +563 -0
  13. auditkit/diff.py +265 -0
  14. auditkit/errors.py +54 -0
  15. auditkit/evaluator.py +20 -0
  16. auditkit/experiment.py +145 -0
  17. auditkit/hf_publish.py +262 -0
  18. auditkit/lmeval_engine.py +550 -0
  19. auditkit/loaders.py +121 -0
  20. auditkit/logs.py +18 -0
  21. auditkit/metric.py +199 -0
  22. auditkit/metrics/README.md +15 -0
  23. auditkit/metrics/__init__.py +0 -0
  24. auditkit/metrics/code.py +222 -0
  25. auditkit/metrics/embedding.py +131 -0
  26. auditkit/metrics/encoder_judge.py +423 -0
  27. auditkit/metrics/generation.py +331 -0
  28. auditkit/metrics/guard.py +412 -0
  29. auditkit/metrics/hallucination.py +45 -0
  30. auditkit/metrics/judge.py +547 -0
  31. auditkit/metrics/pairwise.py +153 -0
  32. auditkit/metrics/perf.py +53 -0
  33. auditkit/metrics/rag.py +149 -0
  34. auditkit/metrics/security.py +64 -0
  35. auditkit/metrics/toxicity.py +238 -0
  36. auditkit/model/README.md +16 -0
  37. auditkit/model/__init__.py +485 -0
  38. auditkit/model/anthropic.py +94 -0
  39. auditkit/model/api_gen.py +133 -0
  40. auditkit/model/groq_gen.py +121 -0
  41. auditkit/model/hf_gen.py +385 -0
  42. auditkit/model/lexsi.py +155 -0
  43. auditkit/model/litellm_gen.py +65 -0
  44. auditkit/model/openai.py +90 -0
  45. auditkit/model/openrouter_gen.py +152 -0
  46. auditkit/model/vllm_gen.py +316 -0
  47. auditkit/model_compare.py +655 -0
  48. auditkit/redteam/README.md +9 -0
  49. auditkit/redteam/__init__.py +26 -0
  50. auditkit/redteam/detector.py +37 -0
  51. auditkit/redteam/detectors/README.md +5 -0
  52. auditkit/redteam/detectors/builtin.py +126 -0
  53. auditkit/redteam/probe.py +39 -0
  54. auditkit/redteam/probes/README.md +5 -0
  55. auditkit/redteam/probes/builtin.py +85 -0
  56. auditkit/redteam/runner.py +206 -0
  57. auditkit/registry.py +65 -0
  58. auditkit/report.py +278 -0
  59. auditkit/report_format.py +52 -0
  60. auditkit/router.py +54 -0
  61. auditkit/runner.py +575 -0
  62. auditkit/runspec.py +159 -0
  63. auditkit/sample.py +40 -0
  64. auditkit/scenario.py +88 -0
  65. auditkit/scenarios/README.md +10 -0
  66. auditkit/scenarios/__init__.py +4 -0
  67. auditkit/scenarios/arc.py +33 -0
  68. auditkit/scenarios/gsm8k.py +32 -0
  69. auditkit/scenarios/hellaswag.py +33 -0
  70. auditkit/scenarios/humaneval.py +32 -0
  71. auditkit/scenarios/mmlu.py +34 -0
  72. auditkit/scenarios/truthfulqa.py +33 -0
  73. auditkit/score.py +165 -0
  74. auditkit/scorers.py +117 -0
  75. auditkit/scoring.py +79 -0
  76. auditkit/types.py +69 -0
  77. auditkit-1.0.0.dist-info/METADATA +396 -0
  78. auditkit-1.0.0.dist-info/RECORD +81 -0
  79. auditkit-1.0.0.dist-info/WHEEL +4 -0
  80. auditkit-1.0.0.dist-info/entry_points.txt +2 -0
  81. auditkit-1.0.0.dist-info/licenses/LICENSE.md +92 -0
auditkit/runspec.py ADDED
@@ -0,0 +1,159 @@
1
+ """The reproducible description of a run: :class:`RunConfig` and :class:`RunSpec`.
2
+
3
+ A ``RunSpec`` binds together everything a run needs — the scenario, the model,
4
+ the adapter, the metrics, the config — and can distil itself to a stable
5
+ :meth:`RunSpec.fingerprint`. Two runs with the same fingerprint are the same
6
+ experiment; changing the model, the seed, the few-shot count, or the judge
7
+ changes the fingerprint, which is what makes results comparable and cacheable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, TYPE_CHECKING, Union
16
+
17
+ if TYPE_CHECKING: # avoid import cycles; these are only type hints here
18
+ from .adapter import Adapter
19
+ from .metric import Metric
20
+ from .model import Model
21
+ from .scenario import Scenario
22
+
23
+
24
+ @dataclass
25
+ class RunConfig:
26
+ """The knobs that shape a run (all optional, all with sane defaults)."""
27
+
28
+ num_fewshot: Union[int, None] = None
29
+ limit: Union[int, None] = None
30
+ # None (not 0): every other generation-affecting field here defaults to
31
+ # None so _gen_params() correctly skips it when unset. A concrete 0
32
+ # defeats that -- seed=0 was being force-included in every generation
33
+ # request whether the caller asked for it or not, and some providers
34
+ # (confirmed: at least one Groq model) reject the seed param outright,
35
+ # turning every request into a 400. Explicitly pass seed=0 if you want
36
+ # a real, reproducible seed sent to the backend.
37
+ seed: Union[int, None] = None
38
+ trials: int = 1
39
+ batch_size: Union[str, int] = "auto"
40
+ # Default 1 (not higher) because Runner._chunk() always splits requests
41
+ # into exactly `concurrency` chunks regardless of request count -- with
42
+ # fewer requests than concurrency, that produces empty chunks and wastes
43
+ # real model/API calls on them.
44
+ # concurrency<=1 takes Runner.execute()'s no-chunking path entirely, so
45
+ # this is the only value that's safe unconditionally.
46
+ concurrency: int = 1
47
+ split: SplitConfig | None = None
48
+ temperature: float = 0.0
49
+ top_p: float | None = None
50
+ top_k: int | None = None
51
+ max_tokens: int | None = None
52
+ stop_sequences: list[str] | None = None
53
+ presence_penalty: float | None = None
54
+ frequency_penalty: float | None = None
55
+ num_completions: int | None = None
56
+ best_of: int | None = None
57
+ timeout: float | None = None
58
+ max_retries: int = 3
59
+ retry_delay: float = 1.0
60
+ judge_model: Union[str, None] = None
61
+ judge_prompt_version: Union[str, None] = None
62
+ # On by default: RunResult.perf/model_size/token_usage are populated
63
+ # unless explicitly disabled. Set False to skip surfacing them (they are
64
+ # still measured for free during the run either way -- this only gates
65
+ # whether they're attached to the result; see Runner.run()) -- useful if
66
+ # local-backend model_size introspection's real, if best-effort, cost is
67
+ # unwanted. Part of the fingerprint (RunConfig.to_dict() is hashed
68
+ # whole), so toggling this forces a fresh run rather than replaying a
69
+ # cached perf-less result.
70
+ track_performance: bool = True
71
+ extra: dict[str, Any] = field(default_factory=dict)
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ return {f.name: getattr(self, f.name) for f in self.__dataclass_fields__.values()}
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: dict[str, Any]) -> RunConfig:
78
+ field_names = {f.name for f in cls.__dataclass_fields__.values()}
79
+ valid = {k: v for k, v in data.items() if k in field_names}
80
+ return cls(**valid)
81
+
82
+
83
+ @dataclass
84
+ class SplitConfig:
85
+ strategy: str = "sequential"
86
+ train_ratio: float = 0.0
87
+ val_ratio: float = 0.0
88
+ test_ratio: float = 1.0
89
+ fold: int = 0
90
+ # Fixed, not None: Runner._split_samples()'s "random"/"stratified" paths
91
+ # seed `random.Random(seed)` fresh on every call. `None` draws OS entropy
92
+ # each time, so reusing the identical SplitConfig (or RunConfig) across a
93
+ # baseline and a candidate evaluate() call -- the normal "same eval
94
+ # config" comparison pattern -- would silently reshuffle differently each
95
+ # time, breaking RunComparison's sample_id-based alignment (newly_wrong,
96
+ # significance) even though nothing about the config looked different.
97
+ # A caller who genuinely wants a different shuffle per run still can, by
98
+ # passing a different seed= explicitly.
99
+ seed: int | None = 0
100
+
101
+
102
+ @dataclass
103
+ class RunSpec:
104
+ """Everything a run needs, plus a stable fingerprint over what matters."""
105
+
106
+ scenario: "Scenario"
107
+ model: "Model"
108
+ adapter: "Adapter"
109
+ metrics: list["Metric"]
110
+ annotators: list[Any] = field(default_factory=list)
111
+ # Names an annotator (by its .name) whose context["extracted"] value
112
+ # should be scored instead of the raw output -- e.g. a RegexAnnotator
113
+ # pulling "42" out of "...FINAL ANSWER: 42". None (default): score the
114
+ # raw output, today's behavior, unchanged.
115
+ extracted_by: Union[str, None] = None
116
+ config: RunConfig = field(default_factory=RunConfig)
117
+ run_name: str = ""
118
+
119
+ def fingerprint(self) -> str:
120
+ """A stable sha256[:16] over the identity-defining parts of the run.
121
+
122
+ Hashes the *entire* config (via ``RunConfig.to_dict()``) rather than a
123
+ hand-picked subset of fields, so a run-affecting setting (``limit``,
124
+ ``batch_size``, a new field added later, ...) can never be silently
125
+ left out of the fingerprint the way ``limit`` previously was.
126
+ """
127
+ def _identity(obj: Any, default_attr: str) -> Any:
128
+ fn = getattr(obj, "identity", None)
129
+ if callable(fn):
130
+ return fn()
131
+ return getattr(obj, default_attr, type(obj).__name__)
132
+
133
+ key = {
134
+ "engine": "native",
135
+ "model": _identity(self.model, "name"),
136
+ "scenario": getattr(self.scenario, "name", type(self.scenario).__name__),
137
+ # identity() captures the *prompt* (adapter template/system prompt,
138
+ # judge prompt/choices), not just the type — so changing a prompt
139
+ # changes the fingerprint and can't return a stale cached result.
140
+ "adapter": _identity(self.adapter, "method"),
141
+ "metrics": sorted(
142
+ json.dumps(_identity(m, "name"), sort_keys=True, default=str)
143
+ for m in self.metrics
144
+ ),
145
+ # Annotators have no scoring effect unless extracted_by names
146
+ # one -- but hashing both unconditionally means changing a
147
+ # regex pattern (or which annotator feeds scoring) always
148
+ # invalidates the cache, never silently reuses a stale result
149
+ # scored under a different pattern.
150
+ "annotators": sorted(
151
+ json.dumps(_identity(a, "name"), sort_keys=True, default=str)
152
+ for a in self.annotators
153
+ ),
154
+ "extracted_by": self.extracted_by,
155
+ "run_name": self.run_name,
156
+ "config": self.config.to_dict(),
157
+ }
158
+ blob = json.dumps(key, sort_keys=True, default=str).encode("utf-8")
159
+ return hashlib.sha256(blob).hexdigest()[:16]
auditkit/sample.py ADDED
@@ -0,0 +1,40 @@
1
+ """The unit of evaluation: a :class:`Sample`.
2
+
3
+ A sample is one thing to ask a model and (optionally) what a correct answer
4
+ looks like. Most fields are optional and task-specific — an MCQ sample carries
5
+ ``choices``, a RAG sample carries ``retrieval_context`` — so one dataclass
6
+ covers every task the spine handles.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Optional, Union
13
+
14
+ from .types import TaskKind
15
+
16
+
17
+ @dataclass
18
+ class Sample:
19
+ """One evaluation item: what to ask, and (optionally) the reference answer."""
20
+
21
+ input: str
22
+ target: Optional[Union[str, int]] = None
23
+ id: Optional[str] = None
24
+ task: str = ""
25
+ kind: TaskKind = TaskKind.GENERATIVE
26
+ choices: Optional[list[str]] = None
27
+ retrieval_context: Optional[list[str]] = None
28
+ metadata: dict[str, Any] = field(default_factory=dict)
29
+ tags: list[str] = field(default_factory=list)
30
+ actual_output: Optional[str] = None
31
+
32
+ @property
33
+ def is_golden(self) -> bool:
34
+ """True when the sample carries a reference answer to score against."""
35
+ return self.target is not None
36
+
37
+ @property
38
+ def input_text(self) -> str:
39
+ """The input as plain text — what a text-only backend is prompted with."""
40
+ return self.input
auditkit/scenario.py ADDED
@@ -0,0 +1,88 @@
1
+ """Scenarios: a task and its data, yielding :class:`Sample`.
2
+
3
+ Users pass a task name, a list of samples, or a callable to ``evaluate()``; the
4
+ façade wraps whatever they pass into a :class:`Scenario` so the runner sees one
5
+ shape. :class:`ListScenario` is the trivial wrapper around an in-memory list;
6
+ :class:`CallableScenario` defers to a function that yields samples (a custom
7
+ loader, a generator, a streaming source).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ from abc import ABC, abstractmethod
15
+ from typing import Any, Callable, Iterable
16
+
17
+ from .sample import Sample
18
+
19
+
20
+ class Scenario(ABC):
21
+ """A task plus its data, as an iterable of samples."""
22
+
23
+ name: str = "scenario"
24
+
25
+ def __init__(self, name: str = "scenario", metadata: dict[str, Any] | None = None) -> None:
26
+ self.name = name
27
+ self.metadata: dict[str, Any] = metadata or {}
28
+
29
+ @abstractmethod
30
+ def samples(self) -> Iterable[Sample]:
31
+ ...
32
+
33
+
34
+ class ListScenario(Scenario):
35
+ """A scenario backed by an in-memory list of samples."""
36
+
37
+ def __init__(self, samples: Iterable[Sample], name: str | None = None,
38
+ metadata: dict[str, Any] | None = None) -> None:
39
+ samples = list(samples)
40
+ if name is None:
41
+ # Deliberately excludes s.id: Runner mutates a sample's id in
42
+ # place the first time it's scored (`if sample.id is None:
43
+ # sample.id = str(index)`), so including id here meant re-running
44
+ # the exact same Sample objects a second time produced a
45
+ # different auto-generated name -- and therefore a different
46
+ # RunSpec.fingerprint() -- for identical content, defeating the
47
+ # cache purely from that side effect. Identity for caching
48
+ # purposes should track the data, not a bookkeeping field that
49
+ # changes as a side effect of a prior run.
50
+ # Covers every field a built-in Adapter actually reads (input,
51
+ # target for all; choices for MCQAdapter; retrieval_context for
52
+ # RAGAdapter) -- input/target alone let two samples that differ
53
+ # only in retrieval_context or choices collide onto the same
54
+ # name/fingerprint and silently share a DiskCache entry.
55
+ #
56
+ # actual_output is included for the same reason: it's what
57
+ # PrecomputedModel (reads_actual_output=True) actually scores in
58
+ # the documented "generate once, score many times" flow
59
+ # (ak.generate() -> model="precomputed"). Without it, two
60
+ # datasets sharing the same input/target but carrying DIFFERENT
61
+ # generated text (e.g. two different models' real outputs, or a
62
+ # re-generation after a prompt change) hash to the identical
63
+ # name/fingerprint -- confirmed live: scoring "positive" (correct,
64
+ # 1.0) then "negative" (wrong, should be 0.0) against the same
65
+ # input/target silently returned the FIRST call's cached 1.0 for
66
+ # the second, wrong-answer sample.
67
+ h = hashlib.sha256(json.dumps(
68
+ [(s.input, s.target, s.choices, s.retrieval_context, s.actual_output) for s in samples],
69
+ default=str, sort_keys=True,
70
+ ).encode()).hexdigest()[:8]
71
+ name = f"inline_{len(samples)}_{h}"
72
+ super().__init__(name=name, metadata=metadata)
73
+ self._samples = samples
74
+
75
+ def samples(self) -> list[Sample]:
76
+ return list(self._samples)
77
+
78
+
79
+ class CallableScenario(Scenario):
80
+ """A scenario that calls a function to produce samples each time it's read."""
81
+
82
+ def __init__(self, fn: Callable[[], Iterable[Sample]], name: str = "callable",
83
+ metadata: dict[str, Any] | None = None) -> None:
84
+ super().__init__(name=name, metadata=metadata)
85
+ self._fn = fn
86
+
87
+ def samples(self) -> list[Sample]:
88
+ return list(self._fn())
@@ -0,0 +1,10 @@
1
+ # Scenarios
2
+
3
+ Built-in benchmark scenarios for standardized evaluation:
4
+
5
+ - **MMLU** — Massive Multitask Language Understanding
6
+ - **GSM8K** — Grade School Math
7
+ - **ARC** — AI2 Reasoning Challenge
8
+ - **HellaSwag** — Commonsense NLI
9
+ - **HumanEval** — Code generation
10
+ - **TruthfulQA** — Factuality and truthfulness
@@ -0,0 +1,4 @@
1
+ """Pre-built benchmark scenarios (optional extra: auditkit[interop])."""
2
+ from __future__ import annotations
3
+
4
+ from . import arc, gsm8k, hellaswag, humaneval, mmlu, truthfulqa # noqa: F401 -- triggers @SCENARIOS.register decorators
@@ -0,0 +1,33 @@
1
+ """ARC-Challenge benchmark scenario (lazy datasets import)."""
2
+ from __future__ import annotations
3
+
4
+ from auditkit.scenario import Scenario
5
+ from auditkit.sample import Sample
6
+ from auditkit.types import TaskKind
7
+ from auditkit.errors import ExtraNotInstalled
8
+ from auditkit.registry import SCENARIOS
9
+
10
+
11
+ @SCENARIOS.register("arc")
12
+ class ARCScenario(Scenario):
13
+ name = "arc"
14
+
15
+ def __init__(self, split: str = "test"):
16
+ self.split = split
17
+ super().__init__(name=f"arc_{split}")
18
+
19
+ def samples(self):
20
+ try:
21
+ import datasets
22
+ except ImportError:
23
+ raise ExtraNotInstalled("interop", "pip install auditkit[interop]")
24
+ data = datasets.load_dataset("arc", "ARC-Challenge", split=self.split)
25
+ return [
26
+ Sample(
27
+ input=row["question"],
28
+ choices=list(row["choices"]["text"]),
29
+ target=row["answerKey"],
30
+ kind=TaskKind.MCQ,
31
+ )
32
+ for row in data
33
+ ]
@@ -0,0 +1,32 @@
1
+ """GSM8K benchmark scenario (lazy datasets import)."""
2
+ from __future__ import annotations
3
+
4
+ from auditkit.scenario import Scenario
5
+ from auditkit.sample import Sample
6
+ from auditkit.types import TaskKind
7
+ from auditkit.errors import ExtraNotInstalled
8
+ from auditkit.registry import SCENARIOS
9
+
10
+
11
+ @SCENARIOS.register("gsm8k")
12
+ class GSM8KScenario(Scenario):
13
+ name = "gsm8k"
14
+
15
+ def __init__(self, split: str = "test"):
16
+ self.split = split
17
+ super().__init__(name=f"gsm8k_{split}")
18
+
19
+ def samples(self):
20
+ try:
21
+ import datasets
22
+ except ImportError:
23
+ raise ExtraNotInstalled("interop", "pip install auditkit[interop]")
24
+ data = datasets.load_dataset("gsm8k", "main", split=self.split)
25
+ return [
26
+ Sample(
27
+ input=row["question"],
28
+ target=row["answer"],
29
+ kind=TaskKind.GENERATIVE,
30
+ )
31
+ for row in data
32
+ ]
@@ -0,0 +1,33 @@
1
+ """HellaSwag benchmark scenario (lazy datasets import)."""
2
+ from __future__ import annotations
3
+
4
+ from auditkit.scenario import Scenario
5
+ from auditkit.sample import Sample
6
+ from auditkit.types import TaskKind
7
+ from auditkit.errors import ExtraNotInstalled
8
+ from auditkit.registry import SCENARIOS
9
+
10
+
11
+ @SCENARIOS.register("hellaswag")
12
+ class HellaSwagScenario(Scenario):
13
+ name = "hellaswag"
14
+
15
+ def __init__(self, split: str = "validation"):
16
+ self.split = split
17
+ super().__init__(name=f"hellaswag_{split}")
18
+
19
+ def samples(self):
20
+ try:
21
+ import datasets
22
+ except ImportError:
23
+ raise ExtraNotInstalled("interop", "pip install auditkit[interop]")
24
+ data = datasets.load_dataset("hellaswag", split=self.split)
25
+ return [
26
+ Sample(
27
+ input=row["ctx"],
28
+ choices=list(row["endings"]),
29
+ target=str(row["label"]),
30
+ kind=TaskKind.MCQ,
31
+ )
32
+ for row in data
33
+ ]
@@ -0,0 +1,32 @@
1
+ """HumanEval benchmark scenario (lazy datasets import)."""
2
+ from __future__ import annotations
3
+
4
+ from auditkit.scenario import Scenario
5
+ from auditkit.sample import Sample
6
+ from auditkit.types import TaskKind
7
+ from auditkit.errors import ExtraNotInstalled
8
+ from auditkit.registry import SCENARIOS
9
+
10
+
11
+ @SCENARIOS.register("humaneval")
12
+ class HumanEvalScenario(Scenario):
13
+ name = "humaneval"
14
+
15
+ def __init__(self, split: str = "test"):
16
+ self.split = split
17
+ super().__init__(name=f"humaneval_{split}")
18
+
19
+ def samples(self):
20
+ try:
21
+ import datasets
22
+ except ImportError:
23
+ raise ExtraNotInstalled("interop", "pip install auditkit[interop]")
24
+ data = datasets.load_dataset("openai_humaneval", split=self.split)
25
+ return [
26
+ Sample(
27
+ input=row["prompt"],
28
+ target=row["canonical_solution"],
29
+ kind=TaskKind.CODE,
30
+ )
31
+ for row in data
32
+ ]
@@ -0,0 +1,34 @@
1
+ """MMLU benchmark scenario (lazy datasets import)."""
2
+ from __future__ import annotations
3
+
4
+ from auditkit.scenario import Scenario
5
+ from auditkit.sample import Sample
6
+ from auditkit.types import TaskKind
7
+ from auditkit.errors import ExtraNotInstalled
8
+ from auditkit.registry import SCENARIOS
9
+
10
+
11
+ @SCENARIOS.register("mmlu")
12
+ class MMLUScenario(Scenario):
13
+ name = "mmlu"
14
+
15
+ def __init__(self, subject: str = "all", split: str = "test"):
16
+ self.subject = subject
17
+ self.split = split
18
+ super().__init__(name=f"mmlu_{subject}_{split}")
19
+
20
+ def samples(self):
21
+ try:
22
+ import datasets
23
+ except ImportError:
24
+ raise ExtraNotInstalled("interop", "pip install auditkit[interop]")
25
+ data = datasets.load_dataset("mmlu", self.subject, split=self.split)
26
+ return [
27
+ Sample(
28
+ input=row["question"],
29
+ choices=list(row["choices"]),
30
+ target=row["answer"],
31
+ kind=TaskKind.MCQ,
32
+ )
33
+ for row in data
34
+ ]
@@ -0,0 +1,33 @@
1
+ """TruthfulQA benchmark scenario (lazy datasets import)."""
2
+ from __future__ import annotations
3
+
4
+ from auditkit.scenario import Scenario
5
+ from auditkit.sample import Sample
6
+ from auditkit.types import TaskKind
7
+ from auditkit.errors import ExtraNotInstalled
8
+ from auditkit.registry import SCENARIOS
9
+
10
+
11
+ @SCENARIOS.register("truthfulqa")
12
+ class TruthfulQAScenario(Scenario):
13
+ name = "truthfulqa"
14
+
15
+ def __init__(self, split: str = "validation"):
16
+ self.split = split
17
+ super().__init__(name=f"truthfulqa_{split}")
18
+
19
+ def samples(self):
20
+ try:
21
+ import datasets
22
+ except ImportError:
23
+ raise ExtraNotInstalled("interop", "pip install auditkit[interop]")
24
+ data = datasets.load_dataset("truthfulqa", "multiple_choice", split=self.split)
25
+ return [
26
+ Sample(
27
+ input=row["question"],
28
+ choices=row["mc1_targets"]["choices"],
29
+ target=row["mc1_targets"]["labels"].index(1) if 1 in row["mc1_targets"]["labels"] else "0",
30
+ kind=TaskKind.MCQ,
31
+ )
32
+ for row in data
33
+ ]
auditkit/score.py ADDED
@@ -0,0 +1,165 @@
1
+ """Scores, running statistics, and the pass@k / pass^k estimators.
2
+
3
+ A :class:`Score` is one measurement of one sample (a value plus what it means).
4
+ A :class:`Stat` accumulates many values into a mean/stderr/percentile summary
5
+ without holding every value in memory longer than it needs to. :func:`pass_at_k`
6
+ and :func:`pass_hat_k` turn "``c`` of ``n`` trials were correct" into the two
7
+ numbers people actually want: capability (can it ever?) and reliability (does it
8
+ every time?).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import threading
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Optional
17
+
18
+ from .types import DataType, Direction, ScoreKind, Source
19
+
20
+
21
+ @dataclass
22
+ class Score:
23
+ """One measurement of one sample.
24
+
25
+ ``value`` is the number; the rest says how to read it. When ``threshold`` is
26
+ set, :attr:`passed` turns the number into a gate honoring ``direction`` —
27
+ higher-is-better for ``MAXIMIZE``, lower-is-better for ``MINIMIZE``.
28
+ """
29
+
30
+ name: str
31
+ value: float
32
+ kind: ScoreKind = ScoreKind.BENCHMARK
33
+ data_type: DataType = DataType.NUMERIC
34
+ direction: Direction = Direction.MAXIMIZE
35
+ label: Optional[str] = None
36
+ reason: Optional[str] = None
37
+ threshold: Optional[float] = None
38
+ weight: float = 1.0
39
+ source: Source = Source.SDK
40
+ metadata: dict[str, Any] = field(default_factory=dict)
41
+
42
+ @property
43
+ def passed(self) -> Optional[bool]:
44
+ """``True``/``False`` against ``threshold``, or ``None`` if ungated."""
45
+ if self.threshold is None:
46
+ return None
47
+ if self.direction == Direction.MINIMIZE:
48
+ return self.value <= self.threshold
49
+ return self.value >= self.threshold
50
+
51
+
52
+ class Stat:
53
+ """A running summary of many values (mean, stderr, spread, percentiles).
54
+
55
+ Values are kept so percentiles can be computed exactly; everything else is
56
+ derived on read. Empty is safe: every reduction returns ``0.0``.
57
+ """
58
+
59
+ def __init__(self, name: str) -> None:
60
+ self.name = name
61
+ self._values: list[float] = []
62
+ self._lock = threading.Lock()
63
+
64
+ def add(self, value: float) -> "Stat":
65
+ """Record one value; returns self so calls can be chained."""
66
+ with self._lock:
67
+ self._values.append(float(value))
68
+ return self
69
+
70
+ @property
71
+ def count(self) -> int:
72
+ return len(self._values)
73
+
74
+ @property
75
+ def mean(self) -> float:
76
+ return sum(self._values) / len(self._values) if self._values else 0.0
77
+
78
+ @property
79
+ def std(self) -> float:
80
+ """Population standard deviation (0.0 for fewer than two values)."""
81
+ n = len(self._values)
82
+ if n < 2:
83
+ return 0.0
84
+ mu = self.mean
85
+ return math.sqrt(sum((v - mu) ** 2 for v in self._values) / n)
86
+
87
+ @property
88
+ def stderr(self) -> float:
89
+ n = len(self._values)
90
+ if n < 2:
91
+ return 0.0
92
+ return self.std / math.sqrt(n)
93
+
94
+ @property
95
+ def min(self) -> float:
96
+ return min(self._values) if self._values else 0.0
97
+
98
+ @property
99
+ def max(self) -> float:
100
+ return max(self._values) if self._values else 0.0
101
+
102
+ def percentile(self, p: float) -> float:
103
+ """The ``p``-th percentile (0..100) by linear interpolation."""
104
+ if not self._values:
105
+ return 0.0
106
+ ordered = sorted(self._values)
107
+ if len(ordered) == 1:
108
+ return ordered[0]
109
+ rank = (p / 100.0) * (len(ordered) - 1)
110
+ lo = math.floor(rank)
111
+ hi = math.ceil(rank)
112
+ if lo == hi:
113
+ return ordered[lo]
114
+ frac = rank - lo
115
+ return ordered[lo] + (ordered[hi] - ordered[lo]) * frac
116
+
117
+ def to_dict(self) -> dict[str, Any]:
118
+ return {
119
+ "name": self.name,
120
+ "count": self.count,
121
+ "mean": self.mean,
122
+ "stderr": self.stderr,
123
+ "std": self.std,
124
+ "min": self.min,
125
+ "max": self.max,
126
+ "values": list(self._values),
127
+ }
128
+
129
+ @classmethod
130
+ def from_dict(cls, data: dict[str, Any]) -> Stat:
131
+ s = cls(data.get("name", ""))
132
+ for v in data.get("values", []):
133
+ s.add(v)
134
+ return s
135
+
136
+
137
+ def _check_draw(n: int, c: int, k: int) -> None:
138
+ if k > n:
139
+ raise ValueError(f"cannot draw k={k} from n={n} trials")
140
+ if c > n or c < 0:
141
+ raise ValueError(f"correct count c={c} out of range for n={n}")
142
+
143
+
144
+ def pass_at_k(n: int, c: int, k: int) -> float:
145
+ """Capability: probability that a draw of ``k`` of ``n`` trials has >=1 correct.
146
+
147
+ ``1 - C(n-c, k) / C(n, k)`` — the unbiased HumanEval estimator.
148
+ """
149
+ _check_draw(n, c, k)
150
+ if c == 0:
151
+ return 0.0
152
+ if n - c < k:
153
+ return 1.0
154
+ return 1.0 - math.comb(n - c, k) / math.comb(n, k)
155
+
156
+
157
+ def pass_hat_k(n: int, c: int, k: int) -> float:
158
+ """Reliability: probability that all ``k`` of a draw of ``k`` are correct.
159
+
160
+ ``C(c, k) / C(n, k)``.
161
+ """
162
+ _check_draw(n, c, k)
163
+ if c < k:
164
+ return 0.0
165
+ return math.comb(c, k) / math.comb(n, k)