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,133 @@
|
|
|
1
|
+
"""Generic OpenAI-compatible HTTP API backend (optional extra: auditkit[requests])."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from . import (
|
|
8
|
+
Model, Request, Result_, Generated, resolve_params, resolve_messages,
|
|
9
|
+
DEFAULT_TEMPERATURE, reject_generation_kwargs, split_session_kwargs,
|
|
10
|
+
)
|
|
11
|
+
from ..errors import ExtraNotInstalled
|
|
12
|
+
|
|
13
|
+
# OpenAI-compatible endpoints (the whole point of this backend) use
|
|
14
|
+
# OpenAI's own kwarg names.
|
|
15
|
+
_KEY_MAP = {
|
|
16
|
+
"temperature": "temperature",
|
|
17
|
+
"top_p": "top_p",
|
|
18
|
+
"max_tokens": "max_tokens",
|
|
19
|
+
"stop_sequences": "stop",
|
|
20
|
+
"presence_penalty": "presence_penalty",
|
|
21
|
+
"frequency_penalty": "frequency_penalty",
|
|
22
|
+
"num_completions": "n",
|
|
23
|
+
"seed": "seed",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class APIModel(Model):
|
|
28
|
+
"""Call any OpenAI-compatible HTTP API.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
model
|
|
33
|
+
Model name sent in the API request body.
|
|
34
|
+
api_base
|
|
35
|
+
Base URL of the API endpoint (default: ``https://api.openai.com/v1``).
|
|
36
|
+
api_key
|
|
37
|
+
API key. Falls back to ``API_KEY`` env var, then ``OPENAI_API_KEY``.
|
|
38
|
+
chat_template
|
|
39
|
+
If True (default), uses ``/chat/completions`` with messages format.
|
|
40
|
+
If False, uses ``/completions`` with raw prompt.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name = "api"
|
|
44
|
+
threadsafe = False
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
model: str = "gpt-4o-mini",
|
|
49
|
+
api_base: str = "https://api.openai.com/v1",
|
|
50
|
+
api_key: str | None = None,
|
|
51
|
+
chat_template: bool = True,
|
|
52
|
+
name: str = "api",
|
|
53
|
+
**kwargs: Any,
|
|
54
|
+
) -> None:
|
|
55
|
+
reject_generation_kwargs(kwargs, "APIModel")
|
|
56
|
+
self._model_name = model
|
|
57
|
+
self._api_base = api_base.rstrip("/")
|
|
58
|
+
self._api_key = api_key or os.environ.get("API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
59
|
+
self._chat_template = chat_template
|
|
60
|
+
# verify=/proxies=/cert=/timeout= are real requests.Session concerns,
|
|
61
|
+
# not API request-body fields -- split them out so e.g.
|
|
62
|
+
# APIModel(verify=False) actually disables TLS verification (useful
|
|
63
|
+
# for a self-signed internal endpoint) instead of silently becoming
|
|
64
|
+
# an extra, ignored JSON field in the chat-completions payload.
|
|
65
|
+
self._session_kwargs, self._extra_kwargs = split_session_kwargs(kwargs)
|
|
66
|
+
self._session = None
|
|
67
|
+
self.name = name
|
|
68
|
+
|
|
69
|
+
def _ensure_session(self) -> None:
|
|
70
|
+
if self._session is not None:
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
import requests
|
|
74
|
+
except ImportError:
|
|
75
|
+
raise ExtraNotInstalled("requests", "pip install auditkit[requests]")
|
|
76
|
+
self._session = requests.Session()
|
|
77
|
+
self._session.headers.update({
|
|
78
|
+
"Authorization": f"Bearer {self._api_key}" if self._api_key else "",
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
})
|
|
81
|
+
for attr in ("verify", "proxies", "cert"):
|
|
82
|
+
if attr in self._session_kwargs:
|
|
83
|
+
setattr(self._session, attr, self._session_kwargs[attr])
|
|
84
|
+
|
|
85
|
+
def generate(self, requests: list[Request]) -> list[Result_]:
|
|
86
|
+
self._ensure_session()
|
|
87
|
+
results = []
|
|
88
|
+
for r in requests:
|
|
89
|
+
prompt = r.prompt if isinstance(r.prompt, str) else str(r.prompt)
|
|
90
|
+
defaults = {"temperature": DEFAULT_TEMPERATURE, "max_tokens": 1024}
|
|
91
|
+
gen_kwargs = resolve_params(r, defaults, _KEY_MAP)
|
|
92
|
+
if self._chat_template:
|
|
93
|
+
endpoint = f"{self._api_base}/chat/completions"
|
|
94
|
+
body = {
|
|
95
|
+
"model": self._model_name,
|
|
96
|
+
"messages": resolve_messages(r),
|
|
97
|
+
**gen_kwargs,
|
|
98
|
+
}
|
|
99
|
+
else:
|
|
100
|
+
endpoint = f"{self._api_base}/completions"
|
|
101
|
+
body = {
|
|
102
|
+
"model": self._model_name,
|
|
103
|
+
"prompt": prompt,
|
|
104
|
+
**gen_kwargs,
|
|
105
|
+
}
|
|
106
|
+
body.update(self._extra_kwargs)
|
|
107
|
+
timeout = self._session_kwargs.get("timeout", 120)
|
|
108
|
+
import time
|
|
109
|
+
t0 = time.monotonic()
|
|
110
|
+
resp = self._session.post(endpoint, json=body, timeout=timeout)
|
|
111
|
+
try:
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
except Exception as e:
|
|
114
|
+
# requests' default HTTPError message drops the response
|
|
115
|
+
# body, which is where the real API actually explains what's
|
|
116
|
+
# wrong. Surface it instead of a bare "400 Client Error".
|
|
117
|
+
raise type(e)(f"{e} -- response body: {resp.text}") from None
|
|
118
|
+
latency_ms = (time.monotonic() - t0) * 1000
|
|
119
|
+
data = resp.json()
|
|
120
|
+
if self._chat_template:
|
|
121
|
+
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
122
|
+
else:
|
|
123
|
+
text = data.get("choices", [{}])[0].get("text", "")
|
|
124
|
+
raw_usage = data.get("usage") or {}
|
|
125
|
+
usage = {}
|
|
126
|
+
if raw_usage:
|
|
127
|
+
usage = {
|
|
128
|
+
"prompt_tokens": raw_usage.get("prompt_tokens", 0),
|
|
129
|
+
"completion_tokens": raw_usage.get("completion_tokens", 0),
|
|
130
|
+
"total_tokens": raw_usage.get("total_tokens", 0),
|
|
131
|
+
}
|
|
132
|
+
results.append(Result_(completions=[Generated(text=text)], usage=usage, latency_ms=latency_ms))
|
|
133
|
+
return results
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Groq model backend (optional extra: auditkit[requests]).
|
|
2
|
+
|
|
3
|
+
Groq's API is a documented OpenAI-compatible endpoint
|
|
4
|
+
(https://api.groq.com/openai/v1, standard Bearer auth, /chat/completions
|
|
5
|
+
request/response shape) -- this backend is a thin, pre-configured wrapper so
|
|
6
|
+
you don't have to pass api_base=/api_key= through the generic api: backend
|
|
7
|
+
by hand every time.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from . import (
|
|
16
|
+
Model, Request, Result_, Generated, resolve_params, resolve_messages,
|
|
17
|
+
DEFAULT_TEMPERATURE, reject_generation_kwargs, split_session_kwargs,
|
|
18
|
+
)
|
|
19
|
+
from ..errors import ExtraNotInstalled
|
|
20
|
+
|
|
21
|
+
_KEY_MAP = {
|
|
22
|
+
"temperature": "temperature",
|
|
23
|
+
"top_p": "top_p",
|
|
24
|
+
"max_tokens": "max_tokens",
|
|
25
|
+
"stop_sequences": "stop",
|
|
26
|
+
"presence_penalty": "presence_penalty",
|
|
27
|
+
"frequency_penalty": "frequency_penalty",
|
|
28
|
+
"num_completions": "n",
|
|
29
|
+
"seed": "seed",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GroqModel(Model):
|
|
34
|
+
"""Call the Groq chat completions API.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
model
|
|
39
|
+
Model name on Groq (e.g. ``"llama-3.3-70b-versatile"``).
|
|
40
|
+
api_key
|
|
41
|
+
Groq API key. Falls back to the ``GROQ_API_KEY`` env var.
|
|
42
|
+
api_base
|
|
43
|
+
Base URL (default: ``https://api.groq.com/openai/v1``).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
name = "groq"
|
|
47
|
+
threadsafe = False
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
model: str = "llama-3.3-70b-versatile",
|
|
52
|
+
api_key: str | None = None,
|
|
53
|
+
api_base: str = "https://api.groq.com/openai/v1",
|
|
54
|
+
name: str = "groq",
|
|
55
|
+
**kwargs: Any,
|
|
56
|
+
) -> None:
|
|
57
|
+
reject_generation_kwargs(kwargs, "GroqModel")
|
|
58
|
+
self._model_name = model
|
|
59
|
+
self._api_base = api_base.rstrip("/")
|
|
60
|
+
self._api_key = api_key or os.environ.get("GROQ_API_KEY")
|
|
61
|
+
# verify=/proxies=/cert=/timeout= are real requests.Session concerns,
|
|
62
|
+
# not API request-body fields -- split them out so e.g.
|
|
63
|
+
# GroqModel(verify=False) actually disables TLS verification instead
|
|
64
|
+
# of silently becoming an extra, ignored JSON field in the payload.
|
|
65
|
+
self._session_kwargs, self._extra_kwargs = split_session_kwargs(kwargs)
|
|
66
|
+
self._session = None
|
|
67
|
+
self.name = name
|
|
68
|
+
|
|
69
|
+
def _ensure_session(self) -> None:
|
|
70
|
+
if self._session is not None:
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
import requests
|
|
74
|
+
except ImportError:
|
|
75
|
+
raise ExtraNotInstalled("requests", "pip install auditkit[requests]")
|
|
76
|
+
self._session = requests.Session()
|
|
77
|
+
self._session.headers.update({
|
|
78
|
+
"Authorization": f"Bearer {self._api_key}" if self._api_key else "",
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
})
|
|
81
|
+
for attr in ("verify", "proxies", "cert"):
|
|
82
|
+
if attr in self._session_kwargs:
|
|
83
|
+
setattr(self._session, attr, self._session_kwargs[attr])
|
|
84
|
+
|
|
85
|
+
def generate(self, requests: list[Request]) -> list[Result_]:
|
|
86
|
+
self._ensure_session()
|
|
87
|
+
results = []
|
|
88
|
+
for r in requests:
|
|
89
|
+
defaults = {"temperature": DEFAULT_TEMPERATURE, "max_tokens": 1024}
|
|
90
|
+
gen_kwargs = resolve_params(r, defaults, _KEY_MAP)
|
|
91
|
+
body = {
|
|
92
|
+
"model": self._model_name,
|
|
93
|
+
"messages": resolve_messages(r),
|
|
94
|
+
**gen_kwargs,
|
|
95
|
+
**self._extra_kwargs,
|
|
96
|
+
}
|
|
97
|
+
timeout = self._session_kwargs.get("timeout", 120)
|
|
98
|
+
t0 = time.monotonic()
|
|
99
|
+
resp = self._session.post(f"{self._api_base}/chat/completions", json=body, timeout=timeout)
|
|
100
|
+
try:
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
except Exception as e:
|
|
103
|
+
# requests' default HTTPError message drops the response
|
|
104
|
+
# body, which is where Groq actually explains what's wrong
|
|
105
|
+
# (an invalid model slug, an unsupported param, rate
|
|
106
|
+
# limiting, ...) -- surface it instead of a bare "400
|
|
107
|
+
# Client Error: Bad Request".
|
|
108
|
+
raise type(e)(f"{e} -- response body: {resp.text}") from None
|
|
109
|
+
latency_ms = (time.monotonic() - t0) * 1000
|
|
110
|
+
data = resp.json()
|
|
111
|
+
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
112
|
+
raw_usage = data.get("usage") or {}
|
|
113
|
+
usage = {}
|
|
114
|
+
if raw_usage:
|
|
115
|
+
usage = {
|
|
116
|
+
"prompt_tokens": raw_usage.get("prompt_tokens", 0),
|
|
117
|
+
"completion_tokens": raw_usage.get("completion_tokens", 0),
|
|
118
|
+
"total_tokens": raw_usage.get("total_tokens", 0),
|
|
119
|
+
}
|
|
120
|
+
results.append(Result_(completions=[Generated(text=text)], usage=usage, latency_ms=latency_ms))
|
|
121
|
+
return results
|
auditkit/model/hf_gen.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""HuggingFace generation backend (optional extra: auditkit[transformers])."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from . import (
|
|
7
|
+
Model, Request, Result_, Generated, LogLikelihood, resolve_params,
|
|
8
|
+
DEFAULT_TEMPERATURE, reject_generation_kwargs, _free_torch_memory,
|
|
9
|
+
)
|
|
10
|
+
from ..errors import ExtraNotInstalled
|
|
11
|
+
from ..types import Capability
|
|
12
|
+
|
|
13
|
+
# stop_sequences maps to generate()'s real stop_strings kwarg (needs the
|
|
14
|
+
# tokenizer passed alongside it -- handled in generate() below, not here).
|
|
15
|
+
# seed has no generate()-kwarg equivalent at all; transformers' own
|
|
16
|
+
# set_seed() (a global RNG reset) is the only per-call reproducibility knob,
|
|
17
|
+
# so it's applied separately in generate() rather than through this map.
|
|
18
|
+
_KEY_MAP = {
|
|
19
|
+
"temperature": "temperature",
|
|
20
|
+
"top_p": "top_p",
|
|
21
|
+
"top_k": "top_k",
|
|
22
|
+
"max_tokens": "max_new_tokens",
|
|
23
|
+
"stop_sequences": "stop_strings",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Model-LOADING kwargs `transformers.pipeline()` accepts at construction
|
|
27
|
+
# time (forwarded internally to from_pretrained) -- these must reach
|
|
28
|
+
# pipeline() itself in _ensure_pipeline(), not the per-call generate()
|
|
29
|
+
# invocation. The pipeline's __call__ doesn't accept any of these at all;
|
|
30
|
+
# passing e.g. torch_dtype= here previously raised TypeError at generate()
|
|
31
|
+
# time since it was blindly spread into **self._extra_kwargs on the call,
|
|
32
|
+
# never on construction. Anything in **kwargs NOT in this set is assumed
|
|
33
|
+
# to be a real generation-time kwarg (repetition_penalty, num_beams,
|
|
34
|
+
# no_repeat_ngram_size, ...) and keeps going to the pipeline call, as
|
|
35
|
+
# before -- this fix only redirects the loading-specific ones.
|
|
36
|
+
_LOAD_TIME_KWARGS = frozenset({
|
|
37
|
+
"torch_dtype", "dtype", "device_map", "trust_remote_code", "revision",
|
|
38
|
+
"low_cpu_mem_usage", "quantization_config", "model_kwargs", "use_fast",
|
|
39
|
+
"attn_implementation", "load_in_8bit", "load_in_4bit",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HFGenModel(Model):
|
|
44
|
+
"""Generate text using a HuggingFace transformers model."""
|
|
45
|
+
|
|
46
|
+
name = "hf"
|
|
47
|
+
is_local = True
|
|
48
|
+
|
|
49
|
+
def __init__(self, model: str = "gpt2", device: str | None = "auto",
|
|
50
|
+
name: str = "hf", hf_token: str | None = None,
|
|
51
|
+
token: str | None = None, **kwargs: Any) -> None:
|
|
52
|
+
reject_generation_kwargs(kwargs, "HFGenModel")
|
|
53
|
+
self._model_name = model
|
|
54
|
+
self._device = device
|
|
55
|
+
# Gated models (Llama, Gemma, ...) need a HuggingFace access token at
|
|
56
|
+
# load time; accept `hf_token` or `token` and pass it to from_pretrained.
|
|
57
|
+
self._token = hf_token or token
|
|
58
|
+
self._extra_kwargs = kwargs
|
|
59
|
+
self._pipeline = None
|
|
60
|
+
self.name = name
|
|
61
|
+
|
|
62
|
+
def capabilities(self) -> set[Capability]:
|
|
63
|
+
return {Capability.GENERATE, Capability.LOGLIKELIHOOD}
|
|
64
|
+
|
|
65
|
+
def model_info(self) -> dict[str, Any]:
|
|
66
|
+
"""Real parameter count/sparsity/on-disk-equivalent size, introspected
|
|
67
|
+
from the loaded checkpoint -- never a caller-supplied guess.
|
|
68
|
+
|
|
69
|
+
Loads the pipeline first if it isn't already (so calling this before
|
|
70
|
+
any ``generate()``/``loglikelihood()`` call still works, e.g. from a
|
|
71
|
+
comparison that wants size info without necessarily having run yet).
|
|
72
|
+
``size_mb`` is each parameter's actual in-memory dtype size summed up
|
|
73
|
+
(``numel() * element_size()``) -- the same bytes an on-disk safetensors
|
|
74
|
+
checkpoint stores, not a fixed fp32/fp16 assumption.
|
|
75
|
+
"""
|
|
76
|
+
self._ensure_pipeline()
|
|
77
|
+
model = self._pipeline.model
|
|
78
|
+
params = list(model.parameters())
|
|
79
|
+
total_params = sum(p.numel() for p in params)
|
|
80
|
+
# nonzero count is what actually shows pruning -- a structurally
|
|
81
|
+
# pruned model still allocates the same tensor shapes, just with
|
|
82
|
+
# zeroed-out weights, so total_params alone can't tell two
|
|
83
|
+
# differently-pruned checkpoints apart.
|
|
84
|
+
nonzero_params = sum(int((p != 0).sum().item()) for p in params)
|
|
85
|
+
size_bytes = sum(p.numel() * p.element_size() for p in params)
|
|
86
|
+
return {
|
|
87
|
+
"is_local": True,
|
|
88
|
+
"model_name": self._model_name,
|
|
89
|
+
"total_params": total_params,
|
|
90
|
+
"nonzero_params": nonzero_params,
|
|
91
|
+
"sparsity": 1.0 - (nonzero_params / total_params) if total_params else 0.0,
|
|
92
|
+
"size_mb": size_bytes / (1024 * 1024),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def unload(self) -> None:
|
|
96
|
+
"""Drop the loaded transformers pipeline (model + tokenizer) and free
|
|
97
|
+
the GPU cache -- so ``compare_models()`` can reclaim this checkpoint's
|
|
98
|
+
memory before loading the next. Lazily reloads on the next call."""
|
|
99
|
+
self._pipeline = None
|
|
100
|
+
_free_torch_memory()
|
|
101
|
+
|
|
102
|
+
def _resolve_device(self) -> str:
|
|
103
|
+
"""Best available device: CUDA GPU > Apple Silicon MPS > CPU.
|
|
104
|
+
|
|
105
|
+
transformers.pipeline(device=...) forwards straight to
|
|
106
|
+
torch.device(...), which has no concept of "auto" -- it only
|
|
107
|
+
understands concrete device-type strings ("cpu", "cuda", "mps",
|
|
108
|
+
...). "auto" is a real HF concept, but it belongs to a different
|
|
109
|
+
parameter (device_map="auto" on from_pretrained's multi-GPU
|
|
110
|
+
placement), not this one. Previously "auto" was forwarded verbatim
|
|
111
|
+
and crashed immediately on every zero-config construction. An
|
|
112
|
+
explicit device (e.g. "cpu") always wins; detection only runs when
|
|
113
|
+
the caller left it at the default or passed None.
|
|
114
|
+
"""
|
|
115
|
+
if self._device not in (None, "auto"):
|
|
116
|
+
return self._device
|
|
117
|
+
import torch
|
|
118
|
+
if torch.cuda.is_available():
|
|
119
|
+
return "cuda"
|
|
120
|
+
if torch.backends.mps.is_available():
|
|
121
|
+
return "mps"
|
|
122
|
+
return "cpu"
|
|
123
|
+
|
|
124
|
+
def _logits_processor(self):
|
|
125
|
+
"""A processor that sanitizes non-finite logits before sampling,
|
|
126
|
+
WITHOUT touching genuinely finite ones.
|
|
127
|
+
|
|
128
|
+
Some checkpoints (confirmed: gpt2-medium, on this environment) emit
|
|
129
|
+
nan/inf logits straight out of the forward pass for certain prompts --
|
|
130
|
+
a numerical-precision issue in the model computation itself, not
|
|
131
|
+
something caused by our decoding settings. torch.multinomial (real
|
|
132
|
+
sampling) rejects a distribution containing any nan/inf/negative
|
|
133
|
+
value outright, crashing the whole run.
|
|
134
|
+
|
|
135
|
+
A previous version of this fix blanket-clamped every logit
|
|
136
|
+
(finite or not) to a fixed [-50, 50] range. That's a real,
|
|
137
|
+
confirmed bug for checkpoints whose legitimate logit scale exceeds
|
|
138
|
+
that range: bigscience/bloom-560m's real, non-NaN logits for a
|
|
139
|
+
typical prompt reach ~430 for the correct top token, and clamping
|
|
140
|
+
collapses every one of its top ~30 tokens down to the SAME ceiling
|
|
141
|
+
value of 50.0 -- creating an artificial tie that argmax/greedy then
|
|
142
|
+
breaks by lowest token ID, which favors a low-ID special token
|
|
143
|
+
(confirmed: the real EOS token, id 2, won the tie over the actual
|
|
144
|
+
best token " Paris"), producing empty generated text instead of a
|
|
145
|
+
real answer. Only the positions that are ACTUALLY non-finite are
|
|
146
|
+
replaced now; every genuinely finite logit -- regardless of
|
|
147
|
+
magnitude -- passes through completely untouched, so a model's
|
|
148
|
+
real, correctly-ranked distribution is never altered unless it
|
|
149
|
+
would otherwise crash generation outright.
|
|
150
|
+
"""
|
|
151
|
+
import torch
|
|
152
|
+
from transformers import LogitsProcessor
|
|
153
|
+
|
|
154
|
+
class _SanitizeLogits(LogitsProcessor):
|
|
155
|
+
def __call__(self, input_ids, scores):
|
|
156
|
+
non_finite = ~torch.isfinite(scores)
|
|
157
|
+
if not non_finite.any():
|
|
158
|
+
return scores
|
|
159
|
+
scores = scores.clone()
|
|
160
|
+
# A fixed, safe replacement for whatever was actually
|
|
161
|
+
# broken -- not a clamp applied to the whole tensor.
|
|
162
|
+
scores[non_finite & (scores == float("inf"))] = 1e4
|
|
163
|
+
scores[non_finite & (scores == float("-inf"))] = -1e4
|
|
164
|
+
scores[non_finite & torch.isnan(scores)] = -1e4
|
|
165
|
+
return scores
|
|
166
|
+
|
|
167
|
+
return _SanitizeLogits()
|
|
168
|
+
|
|
169
|
+
def _ensure_pipeline(self) -> None:
|
|
170
|
+
if self._pipeline is not None:
|
|
171
|
+
return
|
|
172
|
+
try:
|
|
173
|
+
import torch # noqa: F401 -- forces a clear ExtraNotInstalled if torch is missing
|
|
174
|
+
from transformers import pipeline
|
|
175
|
+
except ImportError:
|
|
176
|
+
raise ExtraNotInstalled("transformers", "pip install auditkit[transformers]")
|
|
177
|
+
pipe_kwargs: dict[str, Any] = {}
|
|
178
|
+
if self._token:
|
|
179
|
+
pipe_kwargs["token"] = self._token
|
|
180
|
+
for k in _LOAD_TIME_KWARGS:
|
|
181
|
+
if k in self._extra_kwargs:
|
|
182
|
+
pipe_kwargs[k] = self._extra_kwargs[k]
|
|
183
|
+
self._pipeline = pipeline(
|
|
184
|
+
"text-generation",
|
|
185
|
+
model=self._model_name,
|
|
186
|
+
device=self._resolve_device(),
|
|
187
|
+
**pipe_kwargs,
|
|
188
|
+
)
|
|
189
|
+
# generate() always drives length via max_new_tokens (see _KEY_MAP) --
|
|
190
|
+
# never max_length. Most checkpoints still ship a generation_config.json
|
|
191
|
+
# with a default max_length (commonly 20), which transformers otherwise
|
|
192
|
+
# warns about on every single call ("Both max_new_tokens and max_length
|
|
193
|
+
# seem to have been set ... max_new_tokens will take precedence").
|
|
194
|
+
# Harmless in practice (the warning itself confirms max_new_tokens wins),
|
|
195
|
+
# but noisy on every generate() call across an entire run.
|
|
196
|
+
#
|
|
197
|
+
# Pipeline.__init__ deep-copies model.generation_config into its OWN
|
|
198
|
+
# self.generation_config attribute at construction time (see
|
|
199
|
+
# transformers/pipelines/base.py), and TextGenerationPipeline reads
|
|
200
|
+
# that copy, not the model's live one -- so clearing only
|
|
201
|
+
# self._pipeline.model.generation_config.max_length here has no
|
|
202
|
+
# effect on the warning; the pipeline's own already-copied
|
|
203
|
+
# generation_config has to be cleared too.
|
|
204
|
+
#
|
|
205
|
+
# Guarded with getattr: not every transformers version/model exposes a
|
|
206
|
+
# `generation_config` on the pipeline (older TextGenerationPipeline
|
|
207
|
+
# lacks the attribute) or a non-None one on the model. Clearing it is
|
|
208
|
+
# only warning-suppression, so a missing attribute must never crash
|
|
209
|
+
# pipeline construction (it previously raised AttributeError on, e.g.,
|
|
210
|
+
# sshleifer/tiny-gpt2, taking down generate()/loglikelihood() entirely).
|
|
211
|
+
for _holder in (self._pipeline.model, self._pipeline):
|
|
212
|
+
_gen_cfg = getattr(_holder, "generation_config", None)
|
|
213
|
+
if _gen_cfg is not None:
|
|
214
|
+
_gen_cfg.max_length = None
|
|
215
|
+
|
|
216
|
+
def _render_prompt(self, request: Request) -> str:
|
|
217
|
+
"""Render *request* to the literal text sent to the model.
|
|
218
|
+
|
|
219
|
+
The backend owns the model-specific wire format, so *any* adapter's
|
|
220
|
+
output is formatted correctly for the model at hand — routing decides
|
|
221
|
+
the technique (MCQ/RAG/generation), never the chat-vs-base formatting:
|
|
222
|
+
|
|
223
|
+
* **Instruct/chat model** (tokenizer has a ``chat_template``): render
|
|
224
|
+
through the model's *own* template with its real special tokens
|
|
225
|
+
(``<|im_start|>``, ``[INST]``, ...). Structured turns
|
|
226
|
+
(``request.params["messages"]``, e.g. from ``ChatAdapter``) render
|
|
227
|
+
as-is; a flat prompt from any other adapter is wrapped as a single
|
|
228
|
+
``user`` turn so it still gets the model's real formatting instead
|
|
229
|
+
of raw text it was never trained on.
|
|
230
|
+
* **Base model** (no ``chat_template``): the plain prompt text.
|
|
231
|
+
"""
|
|
232
|
+
text = request.prompt if isinstance(request.prompt, str) else str(request.prompt)
|
|
233
|
+
# Explicit opt-out: send the prompt verbatim, skipping the chat template.
|
|
234
|
+
# Classifier-style models (e.g. some safety guards like WildGuard /
|
|
235
|
+
# HarmBench) are trained on their own fixed prompt string; wrapping that
|
|
236
|
+
# in the tokenizer's chat template would corrupt it.
|
|
237
|
+
if request.params.get("apply_chat_template") is False:
|
|
238
|
+
return text
|
|
239
|
+
tokenizer = getattr(self._pipeline, "tokenizer", None)
|
|
240
|
+
chat_template = getattr(tokenizer, "chat_template", None) if tokenizer is not None else None
|
|
241
|
+
if not chat_template:
|
|
242
|
+
return text
|
|
243
|
+
messages = request.params.get("messages") or [{"role": "user", "content": text}]
|
|
244
|
+
# Extra kwargs some chat templates need (e.g. a safety guard's per-call
|
|
245
|
+
# policy: ShieldGemma's `guideline=`, Granite Guardian's `guardian_config=`).
|
|
246
|
+
extra = request.params.get("chat_template_kwargs") or {}
|
|
247
|
+
return tokenizer.apply_chat_template(
|
|
248
|
+
messages, tokenize=False, add_generation_prompt=True, **extra)
|
|
249
|
+
|
|
250
|
+
def generate(self, requests: list[Request]) -> list[Result_]:
|
|
251
|
+
self._ensure_pipeline()
|
|
252
|
+
prompts = [self._render_prompt(r) for r in requests]
|
|
253
|
+
# The pipeline runs all prompts in one batched call sharing one
|
|
254
|
+
# sampling config, so per-request param variation isn't meaningful
|
|
255
|
+
# here -- the first request's params represent the whole batch
|
|
256
|
+
# (in practice uniform, since one evaluate() run shares one RunConfig).
|
|
257
|
+
defaults = {"temperature": DEFAULT_TEMPERATURE, "max_new_tokens": 128}
|
|
258
|
+
gen_kwargs = resolve_params(requests[0], defaults, _KEY_MAP) if requests else defaults
|
|
259
|
+
# Never hand the pipeline an ambiguous "sample, but with no temperature"
|
|
260
|
+
# combination -- some checkpoints' own generation_config defaults to
|
|
261
|
+
# do_sample=True, and sampling with temperature=None can produce an
|
|
262
|
+
# invalid (nan/inf) probability tensor for some models (confirmed:
|
|
263
|
+
# gpt2-medium crashes this way, plain gpt2 doesn't). Explicitly choose
|
|
264
|
+
# greedy vs. sampling instead of leaving it to be inferred.
|
|
265
|
+
temp = gen_kwargs.pop("temperature")
|
|
266
|
+
if temp is not None and temp > 0:
|
|
267
|
+
gen_kwargs["temperature"] = temp
|
|
268
|
+
gen_kwargs["do_sample"] = True
|
|
269
|
+
else:
|
|
270
|
+
gen_kwargs["do_sample"] = False
|
|
271
|
+
# generate()'s stop_strings criterion needs the tokenizer passed
|
|
272
|
+
# alongside it to check decoded output against -- the pipeline
|
|
273
|
+
# doesn't supply this on its own.
|
|
274
|
+
if "stop_strings" in gen_kwargs:
|
|
275
|
+
gen_kwargs["tokenizer"] = self._pipeline.tokenizer
|
|
276
|
+
# No per-call seed kwarg exists for generate()/pipeline() -- transformers'
|
|
277
|
+
# own set_seed() resets the global torch/numpy/python RNGs, the only
|
|
278
|
+
# reproducibility knob available. A real, if blunt, effect: same seed
|
|
279
|
+
# + same prompt + do_sample=True reliably reproduces the same output.
|
|
280
|
+
seed = requests[0].params.get("seed") if requests else None
|
|
281
|
+
if seed is not None:
|
|
282
|
+
from transformers import set_seed
|
|
283
|
+
set_seed(seed)
|
|
284
|
+
call_time_extra_kwargs = {
|
|
285
|
+
k: v for k, v in self._extra_kwargs.items() if k not in _LOAD_TIME_KWARGS
|
|
286
|
+
}
|
|
287
|
+
outputs = self._pipeline(
|
|
288
|
+
prompts,
|
|
289
|
+
# transformers' text-generation pipeline defaults to echoing the
|
|
290
|
+
# full prompt back as part of "generated_text" -- every other
|
|
291
|
+
# backend (openai.py, anthropic.py, vllm_gen.py, ...) returns only
|
|
292
|
+
# the new completion, never the input. That inconsistency broke
|
|
293
|
+
# LLMJudge: its instructions necessarily list every valid verdict
|
|
294
|
+
# label, so with the prompt echoed back, the parser reliably
|
|
295
|
+
# mistook its own instructions for the model's real answer.
|
|
296
|
+
return_full_text=False,
|
|
297
|
+
logits_processor=[self._logits_processor()],
|
|
298
|
+
**gen_kwargs,
|
|
299
|
+
**call_time_extra_kwargs,
|
|
300
|
+
)
|
|
301
|
+
results = []
|
|
302
|
+
for out in outputs:
|
|
303
|
+
if isinstance(out, list):
|
|
304
|
+
text = out[0].get("generated_text", "") if isinstance(out[0], dict) else str(out[0])
|
|
305
|
+
elif isinstance(out, dict):
|
|
306
|
+
text = out.get("generated_text", "")
|
|
307
|
+
else:
|
|
308
|
+
text = str(out)
|
|
309
|
+
results.append(Result_(completions=[Generated(text=text)]))
|
|
310
|
+
return results
|
|
311
|
+
|
|
312
|
+
def loglikelihood(self, requests: list[Request]) -> list[LogLikelihood]:
|
|
313
|
+
"""Real per-candidate log-probability scoring via teacher forcing.
|
|
314
|
+
|
|
315
|
+
For each request, feeds ``prompt + target`` through the model with
|
|
316
|
+
the target's own tokens forced as the continuation (never sampled
|
|
317
|
+
or generated), and sums the log-probability the model assigns to
|
|
318
|
+
each of the target's actual tokens, conditioned on everything
|
|
319
|
+
before it. This is the standard MCQ-by-loglikelihood technique
|
|
320
|
+
(the same approach lm-eval-harness uses for ``hf:`` models) -- it
|
|
321
|
+
measures how likely the model considers a *specific* candidate
|
|
322
|
+
answer, without requiring it to generate that exact text itself.
|
|
323
|
+
|
|
324
|
+
Reuses the same lazily-loaded pipeline's model/tokenizer as
|
|
325
|
+
``generate()`` (``transformers.pipeline`` exposes both as plain
|
|
326
|
+
attributes) -- no separate model load. One forward pass per
|
|
327
|
+
request, not yet batched across requests (a real cost -- see the
|
|
328
|
+
design note this followed up on -- but batching ragged
|
|
329
|
+
prompt/continuation lengths correctly, with proper padding and
|
|
330
|
+
attention masks, is real additional surface area for a subtle
|
|
331
|
+
numerical bug; left as a follow-up once this is verified correct).
|
|
332
|
+
"""
|
|
333
|
+
self._ensure_pipeline()
|
|
334
|
+
import torch
|
|
335
|
+
|
|
336
|
+
model = self._pipeline.model
|
|
337
|
+
tokenizer = self._pipeline.tokenizer
|
|
338
|
+
device = self._pipeline.device
|
|
339
|
+
|
|
340
|
+
results: list[LogLikelihood] = []
|
|
341
|
+
with torch.no_grad():
|
|
342
|
+
for r in requests:
|
|
343
|
+
prompt = r.prompt if isinstance(r.prompt, str) else str(r.prompt)
|
|
344
|
+
target = r.params.get("target", "")
|
|
345
|
+
|
|
346
|
+
# Tokenize context alone and context+target together (not
|
|
347
|
+
# context and target separately) so continuation_ids reflects
|
|
348
|
+
# how the tokenizer actually splits the continuation as part
|
|
349
|
+
# of one contiguous string -- BPE merges can span the
|
|
350
|
+
# context/continuation boundary, so naive separate
|
|
351
|
+
# tokenization can silently produce different token ids
|
|
352
|
+
# than what the model will actually see.
|
|
353
|
+
context_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
|
|
354
|
+
full_ids = tokenizer(prompt + target, add_special_tokens=False)["input_ids"]
|
|
355
|
+
continuation_ids = full_ids[len(context_ids):]
|
|
356
|
+
|
|
357
|
+
if not continuation_ids:
|
|
358
|
+
# Empty target (or one that tokenizes to nothing new) --
|
|
359
|
+
# nothing to score.
|
|
360
|
+
results.append(LogLikelihood(logprob=0.0, is_greedy=True))
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
input_ids = torch.tensor([full_ids], device=device)
|
|
364
|
+
logits = model(input_ids=input_ids).logits[0] # [seq_len, vocab]
|
|
365
|
+
# log_probs[j] is the model's distribution for the token at
|
|
366
|
+
# position j+1, predicted from positions [0..j].
|
|
367
|
+
log_probs = torch.log_softmax(logits[:-1], dim=-1)
|
|
368
|
+
target_ids = torch.tensor(full_ids[1:], device=device)
|
|
369
|
+
token_log_probs = log_probs.gather(1, target_ids.unsqueeze(-1)).squeeze(-1)
|
|
370
|
+
|
|
371
|
+
# The first continuation token is at full_ids[len(context_ids)],
|
|
372
|
+
# predicted by log_probs[len(context_ids) - 1]. (Empty context
|
|
373
|
+
# has no preceding token to condition the first continuation
|
|
374
|
+
# token on -- clamped to 0 as a best-effort approximation,
|
|
375
|
+
# not mathematically exact for that edge case.)
|
|
376
|
+
start = max(len(context_ids) - 1, 0)
|
|
377
|
+
continuation_log_probs = token_log_probs[start:]
|
|
378
|
+
greedy_ids = log_probs[start:].argmax(dim=-1)
|
|
379
|
+
is_greedy = bool(torch.equal(greedy_ids, target_ids[start:]))
|
|
380
|
+
|
|
381
|
+
results.append(LogLikelihood(
|
|
382
|
+
logprob=float(continuation_log_probs.sum().item()),
|
|
383
|
+
is_greedy=is_greedy,
|
|
384
|
+
))
|
|
385
|
+
return results
|