hallustream 0.2.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.
- hallustream/__init__.py +18 -0
- hallustream/cli.py +220 -0
- hallustream/detector.py +315 -0
- hallustream/entities.py +75 -0
- hallustream/integrations/__init__.py +0 -0
- hallustream/integrations/shars.py +107 -0
- hallustream/probe.py +93 -0
- hallustream/py.typed +0 -0
- hallustream/text.py +96 -0
- hallustream/weights/qwen3-4b/detector.json +1 -0
- hallustream/weights/qwen3-4b/model.json +8 -0
- hallustream/weights/qwen3-4b/probe_seed0.safetensors +0 -0
- hallustream/weights/qwen3-4b/probe_seed1.safetensors +0 -0
- hallustream/weights/qwen3-4b/probe_seed2.safetensors +0 -0
- hallustream/weights/qwen3-4b/probe_seed3.safetensors +0 -0
- hallustream/weights/qwen3-4b/probe_seed4.safetensors +0 -0
- hallustream/weights/qwen3-4b/thresholds.json +35 -0
- hallustream-0.2.0.dist-info/METADATA +131 -0
- hallustream-0.2.0.dist-info/RECORD +22 -0
- hallustream-0.2.0.dist-info/WHEEL +4 -0
- hallustream-0.2.0.dist-info/entry_points.txt +3 -0
- hallustream-0.2.0.dist-info/licenses/LICENSE +201 -0
hallustream/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""HalluStream: flag the facts an open LLM is probably making up, from its hidden states."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from hallustream.detector import Detector, Entity, Event, Report
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
__all__ = ["Detector", "Entity", "Event", "Report", "__version__"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(name: str):
|
|
13
|
+
# Loaded on first use, so the research code can import hallustream.* without the library
|
|
14
|
+
if name in ("Detector", "Entity", "Event", "Report"):
|
|
15
|
+
from hallustream import detector
|
|
16
|
+
|
|
17
|
+
return getattr(detector, name)
|
|
18
|
+
raise AttributeError(name)
|
hallustream/cli.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""The hallustream command: answers with the details the model is probably making up highlighted.
|
|
2
|
+
|
|
3
|
+
hallustream "Tell me about Lake Tahoe." ask one question (the default)
|
|
4
|
+
hallustream chat have a conversation
|
|
5
|
+
hallustream score "Tell me about Lake Tahoe." a.txt check an answer it already wrote
|
|
6
|
+
hallustream batch prompts.jsonl -o results.jsonl check many prompts or answers
|
|
7
|
+
|
|
8
|
+
Answers appear sentence by sentence, once each sentence has been checked.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from hallustream import __version__
|
|
19
|
+
from hallustream.detector import BUNDLED, Detector, Entity, Report, _weights_dir, highlight
|
|
20
|
+
|
|
21
|
+
COMMANDS = ("ask", "chat", "score", "batch")
|
|
22
|
+
DIM, RESET = "\033[2m", "\033[0m"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parser() -> argparse.ArgumentParser:
|
|
26
|
+
shared = argparse.ArgumentParser(add_help=False)
|
|
27
|
+
shared.add_argument("--threshold", default="balanced",
|
|
28
|
+
help="balanced (default), strict, or a risk between 0 and 1") # fmt: skip
|
|
29
|
+
shared.add_argument("--weights", default="qwen3-4b", help="bundled name or weights folder")
|
|
30
|
+
shared.add_argument("--device", choices=["cuda", "mps", "cpu"],
|
|
31
|
+
help="default: the fastest available") # fmt: skip
|
|
32
|
+
p = argparse.ArgumentParser(
|
|
33
|
+
prog="hallustream",
|
|
34
|
+
description="Answers from Qwen3-4B with the details it is probably making up highlighted."
|
|
35
|
+
" Without a command, the arguments go to 'ask'.",
|
|
36
|
+
epilog='Example: hallustream "Tell me about Lake Tahoe." (see "hallustream ask -h" for'
|
|
37
|
+
" --threshold, --device, --json and the other options)",
|
|
38
|
+
)
|
|
39
|
+
p.add_argument("--version", action="version", version=f"hallustream {__version__}")
|
|
40
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
41
|
+
ask = sub.add_parser("ask", parents=[shared], help="answer one question (the default)")
|
|
42
|
+
ask.add_argument("prompt")
|
|
43
|
+
chat = sub.add_parser("chat", parents=[shared], help="have a conversation")
|
|
44
|
+
score = sub.add_parser("score", parents=[shared], help="check an answer it already wrote")
|
|
45
|
+
score.add_argument("prompt", help="the question the answer was written for")
|
|
46
|
+
score.add_argument("file", nargs="?", default="-", help="the answer (default: stdin)")
|
|
47
|
+
batch = sub.add_parser("batch", parents=[shared], help="check a file of prompts or answers")
|
|
48
|
+
batch.add_argument("input", help='JSONL with "prompt" (and "answer" to check one already '
|
|
49
|
+
'written), or one prompt per line; - for stdin') # fmt: skip
|
|
50
|
+
batch.add_argument("-o", "--output", default="-", help="JSONL results (default: stdout)")
|
|
51
|
+
for s in (ask, chat, batch):
|
|
52
|
+
s.add_argument("--max-new-tokens", type=int, default=512)
|
|
53
|
+
for s in (ask, chat, score):
|
|
54
|
+
s.add_argument("--no-color", action="store_true")
|
|
55
|
+
for s in (ask, score):
|
|
56
|
+
s.add_argument("--json", action="store_true", help="print one JSON object instead")
|
|
57
|
+
return p
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load(weights: str, device: str | None) -> Detector:
|
|
61
|
+
import logging as pylogging
|
|
62
|
+
import warnings
|
|
63
|
+
|
|
64
|
+
import torch
|
|
65
|
+
from huggingface_hub import try_to_load_from_cache
|
|
66
|
+
from transformers.utils import logging
|
|
67
|
+
|
|
68
|
+
if device == "cuda" and not torch.cuda.is_available():
|
|
69
|
+
sys.exit("hallustream: --device cuda, but no CUDA GPU is available")
|
|
70
|
+
if device == "mps" and not torch.backends.mps.is_available():
|
|
71
|
+
sys.exit("hallustream: --device mps, but this machine has no Apple GPU")
|
|
72
|
+
# This is a command for reading answers: library warnings (GLiNER, transformers, torch's
|
|
73
|
+
# macOS note about redirects) are turned off for the whole process.
|
|
74
|
+
warnings.simplefilter("ignore")
|
|
75
|
+
pylogging.getLogger("torch.distributed.elastic.multiprocessing.redirects").setLevel("ERROR")
|
|
76
|
+
|
|
77
|
+
hf_id = json.loads((_weights_dir(weights) / "model.json").read_text())["hf_id"]
|
|
78
|
+
if isinstance(try_to_load_from_cache(hf_id, "model.safetensors.index.json"), str):
|
|
79
|
+
logging.disable_progress_bar() # already downloaded: keep the terminal for the answer
|
|
80
|
+
else:
|
|
81
|
+
print(f"Downloading {hf_id} (about 8 GB, once)...", file=sys.stderr)
|
|
82
|
+
logging.set_verbosity_error()
|
|
83
|
+
return Detector.from_pretrained(weights, device=device)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _segment(text: str, lo: int, hi: int, flagged: list[Entity], color: bool) -> str:
|
|
87
|
+
inside = [Entity(e.text, e.start - lo, e.end - lo, e.risk, True)
|
|
88
|
+
for e in flagged if lo <= e.start and e.end <= hi] # fmt: skip
|
|
89
|
+
return highlight(text[lo:hi], inside, "ansi" if color else "plain")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _stream(det: Detector, messages: list[dict], max_new_tokens: int, threshold, color: bool,
|
|
93
|
+
) -> Report: # fmt: skip
|
|
94
|
+
"""Print each sentence once it has been checked; return the whole answer's report."""
|
|
95
|
+
text, printed, found, n = "", 0, [], 0
|
|
96
|
+
for ev in det.stream(messages=messages, max_new_tokens=max_new_tokens, threshold=threshold):
|
|
97
|
+
text += ev.text
|
|
98
|
+
found += ev.entities
|
|
99
|
+
n = len(ev.ids)
|
|
100
|
+
if ev.checked > printed:
|
|
101
|
+
print(_segment(text, printed, ev.checked, [e for e in found if e.flagged], color),
|
|
102
|
+
end="", flush=True) # fmt: skip
|
|
103
|
+
printed = ev.checked
|
|
104
|
+
cut = f"\n(cut off at {max_new_tokens} tokens; raise --max-new-tokens for more)"
|
|
105
|
+
print(_dim(cut, color) if n >= max_new_tokens else "")
|
|
106
|
+
return Report(text, found, det._threshold(threshold))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _dim(text: str, color: bool) -> str:
|
|
110
|
+
return f"{DIM}{text}{RESET}" if color else text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _legend(threshold: float, color: bool) -> str:
|
|
114
|
+
return _dim(f"Risk is the estimated chance a detail is made up; flagged from {threshold:.0%}.",
|
|
115
|
+
color) # fmt: skip
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _ask(det: Detector, a, threshold, color: bool) -> int:
|
|
119
|
+
if a.json:
|
|
120
|
+
report = det.generate(a.prompt, max_new_tokens=a.max_new_tokens, threshold=threshold)
|
|
121
|
+
print(json.dumps({"prompt": a.prompt, **report.to_dict()}, ensure_ascii=False))
|
|
122
|
+
return 0
|
|
123
|
+
msgs = [{"role": "user", "content": a.prompt}]
|
|
124
|
+
report = _stream(det, msgs, a.max_new_tokens, threshold, color)
|
|
125
|
+
print(f"\n{report.summary()}\n{_legend(report.threshold, color)}")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _chat(det: Detector, a, threshold, color: bool) -> int:
|
|
130
|
+
legend = _legend(det._threshold(threshold), color)
|
|
131
|
+
print(f"Chat with the model; likely made-up details are highlighted.\n{legend}\n"
|
|
132
|
+
"Type exit or press Ctrl-D to quit.") # fmt: skip
|
|
133
|
+
messages: list[dict] = []
|
|
134
|
+
while True:
|
|
135
|
+
try:
|
|
136
|
+
line = input("\n> ").strip()
|
|
137
|
+
except (EOFError, KeyboardInterrupt):
|
|
138
|
+
print()
|
|
139
|
+
return 0
|
|
140
|
+
if line.lower() in ("exit", "quit"):
|
|
141
|
+
return 0
|
|
142
|
+
if not line:
|
|
143
|
+
continue
|
|
144
|
+
messages.append({"role": "user", "content": line})
|
|
145
|
+
report = _stream(det, messages, a.max_new_tokens, threshold, color)
|
|
146
|
+
messages.append({"role": "assistant", "content": report.text})
|
|
147
|
+
print(f"\n{report.summary()}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _score(det: Detector, a, threshold, color: bool) -> int:
|
|
151
|
+
text = (sys.stdin.read() if a.file == "-" else Path(a.file).read_text()).rstrip()
|
|
152
|
+
report = det.score(a.prompt, text, threshold=threshold)
|
|
153
|
+
if a.json:
|
|
154
|
+
print(json.dumps({"prompt": a.prompt, **report.to_dict()}, ensure_ascii=False))
|
|
155
|
+
return 0
|
|
156
|
+
print(report.highlight("ansi" if color else "plain"))
|
|
157
|
+
print(f"\n{report.summary()}\n{_legend(report.threshold, color)}")
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _batch(det: Detector, a, threshold, color: bool) -> int:
|
|
162
|
+
raw = sys.stdin.read() if a.input == "-" else Path(a.input).read_text()
|
|
163
|
+
lines = [x for x in raw.splitlines() if x.strip()]
|
|
164
|
+
out = sys.stdout if a.output == "-" else open(a.output, "w")
|
|
165
|
+
for i, line in enumerate(lines, 1):
|
|
166
|
+
try:
|
|
167
|
+
item: dict = (json.loads(line) if line.lstrip().startswith("{")
|
|
168
|
+
else {"prompt": line.strip()}) # fmt: skip
|
|
169
|
+
except json.JSONDecodeError as e:
|
|
170
|
+
sys.exit(f"{a.input} line {i}: not valid JSON ({e.msg})")
|
|
171
|
+
prompt, msgs = item.get("prompt"), item.get("messages")
|
|
172
|
+
if prompt is None and msgs is None:
|
|
173
|
+
sys.exit(f'{a.input} line {i}: needs a "prompt" or "messages"')
|
|
174
|
+
if "answer" in item:
|
|
175
|
+
report = det.score(prompt, item["answer"], messages=msgs, threshold=threshold)
|
|
176
|
+
else:
|
|
177
|
+
report = det.generate(prompt, messages=msgs, max_new_tokens=a.max_new_tokens,
|
|
178
|
+
threshold=threshold) # fmt: skip
|
|
179
|
+
out.write(json.dumps({**item, **report.to_dict()}, ensure_ascii=False) + "\n")
|
|
180
|
+
out.flush()
|
|
181
|
+
print(f"{i}/{len(lines)}: {len(report.flagged)} flagged", file=sys.stderr)
|
|
182
|
+
if out is not sys.stdout:
|
|
183
|
+
out.close()
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main(argv: list[str] | None = None, detector: Detector | None = None) -> int:
|
|
188
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
189
|
+
if argv and argv[0] not in (*COMMANDS, "-h", "--help", "--version"):
|
|
190
|
+
argv.insert(0, "ask")
|
|
191
|
+
p = _parser()
|
|
192
|
+
a = p.parse_args(argv)
|
|
193
|
+
if detector is None and not (_weights_dir(a.weights) / "model.json").exists():
|
|
194
|
+
p.error(f"--weights {a.weights}: not a bundled name {list(BUNDLED)} or a weights folder")
|
|
195
|
+
try:
|
|
196
|
+
threshold: str | float = float(a.threshold)
|
|
197
|
+
if not 0 <= threshold <= 1:
|
|
198
|
+
p.error(f"--threshold {a.threshold}: a risk must be between 0 and 1")
|
|
199
|
+
except ValueError:
|
|
200
|
+
threshold = a.threshold
|
|
201
|
+
file = _weights_dir(a.weights) / "thresholds.json"
|
|
202
|
+
presets = detector.thresholds if detector else json.loads(file.read_text())
|
|
203
|
+
if not isinstance(presets.get(threshold), dict):
|
|
204
|
+
names = [k for k, v in presets.items() if isinstance(v, dict)]
|
|
205
|
+
p.error(f"unknown threshold {threshold!r}; use one of {names} or a number")
|
|
206
|
+
path = getattr(a, "file", None) or getattr(a, "input", None)
|
|
207
|
+
if path and path != "-" and not Path(path).is_file():
|
|
208
|
+
p.error(f"{path}: no such file")
|
|
209
|
+
color = not getattr(a, "no_color", True) and sys.stdout.isatty()
|
|
210
|
+
run = {"ask": _ask, "chat": _chat, "score": _score, "batch": _batch}[a.command]
|
|
211
|
+
try:
|
|
212
|
+
det = detector or _load(a.weights, a.device)
|
|
213
|
+
return run(det, a, threshold, color)
|
|
214
|
+
except KeyboardInterrupt:
|
|
215
|
+
print(file=sys.stderr)
|
|
216
|
+
return 130
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
sys.exit(main())
|
hallustream/detector.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Flag the facts a model is probably making up, after it writes or while it writes.
|
|
2
|
+
|
|
3
|
+
det = Detector.from_pretrained("qwen3-4b")
|
|
4
|
+
report = det.generate("Tell me about Lake Tahoe.") # write an answer and check it
|
|
5
|
+
report = det.score("Tell me about Lake Tahoe.", answer) # or check one it already wrote
|
|
6
|
+
print(report.highlight("ansi"), report.summary(), sep="\n\n")
|
|
7
|
+
for event in det.stream("Tell me about Lake Tahoe."): # flags as each sentence ends
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
The probe only knows what its own model knows: score text written by the model it was
|
|
11
|
+
trained for (Qwen3-4B-Instruct-2507 for the bundled weights), not other models' text.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import html
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
from collections.abc import Callable, Iterator
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
from functools import partial
|
|
22
|
+
from importlib.resources import files
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import torch
|
|
27
|
+
|
|
28
|
+
from hallustream import entities as _entities
|
|
29
|
+
from hallustream.probe import ADAPTER, Forward, Probe, char_to_tokens, forward
|
|
30
|
+
from hallustream.text import PRONOUNS, is_refusal, sentences
|
|
31
|
+
|
|
32
|
+
BUNDLED = ("qwen3-4b",)
|
|
33
|
+
SENTENCE_END = ".!?"
|
|
34
|
+
EntityFinder = Callable[[str, list[tuple[int, int]]], list[tuple[int, int]]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Entity:
|
|
39
|
+
text: str
|
|
40
|
+
start: int # character offsets in the answer
|
|
41
|
+
end: int
|
|
42
|
+
risk: float # probability that the entity is made up (sigmoid of the adapted probe's score)
|
|
43
|
+
flagged: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Report:
|
|
48
|
+
text: str
|
|
49
|
+
entities: list[Entity]
|
|
50
|
+
threshold: float
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def flagged(self) -> list[Entity]:
|
|
54
|
+
return [e for e in self.entities if e.flagged]
|
|
55
|
+
|
|
56
|
+
def highlight(self, style: str = "plain") -> str:
|
|
57
|
+
"""The text with flagged entities marked: "plain" [[...]], "ansi" colors, or "html"."""
|
|
58
|
+
return highlight(self.text, self.flagged, style)
|
|
59
|
+
|
|
60
|
+
def summary(self) -> str:
|
|
61
|
+
"""The flagged details, riskiest first, each listed once."""
|
|
62
|
+
checked = {e.text for e in self.entities}
|
|
63
|
+
risky: dict[str, float] = {}
|
|
64
|
+
for e in self.flagged:
|
|
65
|
+
risky[e.text] = max(risky.get(e.text, 0.0), e.risk)
|
|
66
|
+
if not checked:
|
|
67
|
+
return "No specific details to check."
|
|
68
|
+
if not risky:
|
|
69
|
+
return f"No details flagged ({len(checked)} checked)."
|
|
70
|
+
lines = [f"{len(risky)} of {len(checked)} details are likely made up:"]
|
|
71
|
+
lines += [f" {r:4.0%} {t}" for t, r in sorted(risky.items(), key=lambda x: -x[1])]
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict:
|
|
75
|
+
return {
|
|
76
|
+
"text": self.text,
|
|
77
|
+
"threshold": self.threshold,
|
|
78
|
+
"n_flagged": len(self.flagged),
|
|
79
|
+
"entities": [{**asdict(e), "risk": round(e.risk, 4)} for e in self.entities],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def __str__(self) -> str:
|
|
83
|
+
return self.highlight()
|
|
84
|
+
|
|
85
|
+
def _repr_html_(self) -> str: # notebooks show the highlighted text and the summary
|
|
86
|
+
return (
|
|
87
|
+
f"<div style='white-space:pre-wrap;line-height:1.6'>{self.highlight('html')}"
|
|
88
|
+
f"</div><pre>{html.escape(self.summary())}</pre>"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Event:
|
|
94
|
+
"""One step of stream(): new text, and the entities of sentences that just finished."""
|
|
95
|
+
|
|
96
|
+
text: str
|
|
97
|
+
entities: list[Entity] = field(default_factory=list)
|
|
98
|
+
done: bool = False
|
|
99
|
+
checked: int = 0 # the text up to this character has been checked
|
|
100
|
+
ids: list[int] = field(default_factory=list) # on the last event: every generated token
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def highlight(text: str, flagged: list[Entity], style: str = "plain") -> str:
|
|
104
|
+
spans: list[list] = []
|
|
105
|
+
for e in sorted(flagged, key=lambda e: e.start):
|
|
106
|
+
if spans and e.start < spans[-1][1]:
|
|
107
|
+
spans[-1][1], spans[-1][2] = max(spans[-1][1], e.end), max(spans[-1][2], e.risk)
|
|
108
|
+
else:
|
|
109
|
+
spans.append([e.start, e.end, e.risk])
|
|
110
|
+
esc = html.escape if style == "html" else (lambda s: s)
|
|
111
|
+
out, pos = [], 0
|
|
112
|
+
for lo, hi, risk in spans:
|
|
113
|
+
out.append(esc(text[pos:lo]))
|
|
114
|
+
inner = esc(text[lo:hi])
|
|
115
|
+
if style == "html":
|
|
116
|
+
out.append(
|
|
117
|
+
f'<mark class="hs-risk" style="background:#ffcdd2;color:#000" '
|
|
118
|
+
f'title="risk {risk:.0%}">{inner}</mark>'
|
|
119
|
+
)
|
|
120
|
+
elif style == "ansi":
|
|
121
|
+
out.append(f"\033[1;97;41m{inner}\033[0m")
|
|
122
|
+
else:
|
|
123
|
+
out.append(f"[[{inner}]]")
|
|
124
|
+
pos = hi
|
|
125
|
+
out.append(esc(text[pos:]))
|
|
126
|
+
return "".join(out)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _default_device() -> str:
|
|
130
|
+
if torch.cuda.is_available():
|
|
131
|
+
return "cuda"
|
|
132
|
+
if torch.backends.mps.is_available():
|
|
133
|
+
return "mps"
|
|
134
|
+
return "cpu"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _weights_dir(weights: str | Path) -> Path:
|
|
138
|
+
if str(weights) in BUNDLED:
|
|
139
|
+
return Path(str(files("hallustream") / "weights" / str(weights)))
|
|
140
|
+
return Path(weights)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _check_weights(d: Path, weights: str | Path) -> None:
|
|
144
|
+
if not (d / "model.json").exists():
|
|
145
|
+
raise FileNotFoundError(
|
|
146
|
+
f"{weights!r} is not a bundled probe {list(BUNDLED)} or a folder with model.json"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Detector:
|
|
151
|
+
"""A model, its probe, and the thresholds for flagging an entity."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, model, tokenizer, probe: Probe, threshold: str | float = 0.5,
|
|
154
|
+
chat_template_kwargs: dict | None = None, thresholds: dict | None = None,
|
|
155
|
+
entity_finder: EntityFinder | None = None): # fmt: skip
|
|
156
|
+
self.model, self.tokenizer, self.probe = model, tokenizer, probe
|
|
157
|
+
self.chat = chat_template_kwargs or {}
|
|
158
|
+
self.thresholds = thresholds or {}
|
|
159
|
+
self.threshold = self._threshold(threshold)
|
|
160
|
+
# GLiNER runs on the model's GPU when there is one (its MPS support is uneven)
|
|
161
|
+
gliner_device = "cuda" if str(getattr(model, "device", "cpu")).startswith("cuda") else "cpu"
|
|
162
|
+
self.find_entities = entity_finder or partial(_entities.find, device=gliner_device)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_pretrained(cls, weights: str | Path = "qwen3-4b", model=None, tokenizer=None,
|
|
166
|
+
device: str | None = None, dtype: torch.dtype = torch.bfloat16,
|
|
167
|
+
threshold: str | float = "balanced",
|
|
168
|
+
entity_finder: EntityFinder | None = None) -> Detector: # fmt: skip
|
|
169
|
+
"""Load bundled weights by name ("qwen3-4b") or a weights folder, and the model they
|
|
170
|
+
were trained for (unless model and tokenizer are given)."""
|
|
171
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
172
|
+
|
|
173
|
+
d = _weights_dir(weights)
|
|
174
|
+
_check_weights(d, weights)
|
|
175
|
+
meta = json.loads((d / "model.json").read_text())
|
|
176
|
+
if model is None:
|
|
177
|
+
model = AutoModelForCausalLM.from_pretrained(meta["hf_id"], dtype=dtype)
|
|
178
|
+
model.to(device or _default_device()) # pyright: ignore[reportArgumentType]
|
|
179
|
+
model.eval()
|
|
180
|
+
tokenizer = tokenizer or AutoTokenizer.from_pretrained(meta["hf_id"])
|
|
181
|
+
seeds = sorted(d.glob("probe_seed*.safetensors"))
|
|
182
|
+
if not seeds:
|
|
183
|
+
raise FileNotFoundError(f"no probe weights (probe_seed*.safetensors) in {d}")
|
|
184
|
+
probe = Probe(seeds, d / ADAPTER, device=str(model.device))
|
|
185
|
+
if probe.weight.shape[0] != model.config.hidden_size:
|
|
186
|
+
raise ValueError(f"probe width {probe.weight.shape[0]} does not match the model")
|
|
187
|
+
th = d / "thresholds.json"
|
|
188
|
+
return cls(model, tokenizer, probe, threshold, meta.get("chat_template_kwargs"),
|
|
189
|
+
json.loads(th.read_text()) if th.exists() else {}, entity_finder) # fmt: skip
|
|
190
|
+
|
|
191
|
+
def _threshold(self, threshold: str | float | None) -> float:
|
|
192
|
+
if threshold is None:
|
|
193
|
+
return self.threshold
|
|
194
|
+
if isinstance(threshold, str):
|
|
195
|
+
presets = [k for k, v in self.thresholds.items() if isinstance(v, dict)]
|
|
196
|
+
if threshold not in presets:
|
|
197
|
+
hint = f"use one of {presets}" if presets else "no presets loaded; give a number"
|
|
198
|
+
raise ValueError(f"unknown threshold {threshold!r}: {hint}")
|
|
199
|
+
return float(self.thresholds[threshold]["threshold"])
|
|
200
|
+
if not 0 <= float(threshold) <= 1:
|
|
201
|
+
raise ValueError(f"threshold {threshold} is not a risk between 0 and 1")
|
|
202
|
+
return float(threshold)
|
|
203
|
+
|
|
204
|
+
def _messages(self, prompt: str | None, messages: list[dict] | None) -> list[dict]:
|
|
205
|
+
if messages is None:
|
|
206
|
+
if prompt is None:
|
|
207
|
+
raise ValueError("give a prompt or messages")
|
|
208
|
+
messages = [{"role": "user", "content": prompt}]
|
|
209
|
+
return messages
|
|
210
|
+
|
|
211
|
+
def _find(self, text: str, sents: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
212
|
+
"""Entities to score: none in sentences where the model says it does not know, and
|
|
213
|
+
never a bare pronoun."""
|
|
214
|
+
sents = [(lo, hi) for lo, hi in sents if not is_refusal(text[lo:hi])]
|
|
215
|
+
spans = self.find_entities(text, sents) if sents else []
|
|
216
|
+
return [(lo, hi) for lo, hi in spans if text[lo:hi].strip().lower() not in PRONOUNS]
|
|
217
|
+
|
|
218
|
+
def _entities(self, text: str, fw: Forward, spans, threshold: float) -> list[Entity]:
|
|
219
|
+
keep, tok_spans = [], []
|
|
220
|
+
for lo, hi in sorted(set(spans)):
|
|
221
|
+
if (t := char_to_tokens(fw, lo, hi)) is not None:
|
|
222
|
+
keep.append((lo, hi))
|
|
223
|
+
tok_spans.append(t)
|
|
224
|
+
_, adapted = self.probe.span_scores(fw, tok_spans)
|
|
225
|
+
risk = [1 / (1 + math.exp(-float(z))) for z in adapted]
|
|
226
|
+
return [Entity(text[lo:hi], lo, hi, r, r >= threshold)
|
|
227
|
+
for (lo, hi), r in zip(keep, risk, strict=True)] # fmt: skip
|
|
228
|
+
|
|
229
|
+
def score(self, prompt: str | None = None, text: str = "", *, messages: list[dict] | None
|
|
230
|
+
= None, spans: list[tuple[int, int]] | None = None,
|
|
231
|
+
threshold: str | float | None = None) -> Report: # fmt: skip
|
|
232
|
+
"""Risk of every entity in `text`, an answer the model wrote to `prompt` (or after
|
|
233
|
+
`messages`). Entities are found automatically unless `spans` are given."""
|
|
234
|
+
th = self._threshold(threshold)
|
|
235
|
+
fw = forward(self.model, self.tokenizer, self.probe, self._messages(prompt, messages),
|
|
236
|
+
text, **self.chat) # fmt: skip
|
|
237
|
+
if spans is None:
|
|
238
|
+
spans = self._find(text, sentences(text))
|
|
239
|
+
return Report(text, self._entities(text, fw, spans, th), th)
|
|
240
|
+
|
|
241
|
+
def generate(self, prompt: str | None = None, *, messages: list[dict] | None = None,
|
|
242
|
+
max_new_tokens: int = 512, threshold: str | float | None = None,
|
|
243
|
+
temperature: float = 0.0) -> Report: # fmt: skip
|
|
244
|
+
"""Write an answer and check it: stream() collected into one Report."""
|
|
245
|
+
text, found = "", []
|
|
246
|
+
for ev in self.stream(prompt, messages=messages, max_new_tokens=max_new_tokens,
|
|
247
|
+
threshold=threshold, temperature=temperature): # fmt: skip
|
|
248
|
+
text += ev.text
|
|
249
|
+
found += ev.entities
|
|
250
|
+
return Report(text, sorted(found, key=lambda e: e.start), self._threshold(threshold))
|
|
251
|
+
|
|
252
|
+
@torch.no_grad()
|
|
253
|
+
def stream(self, prompt: str | None = None, *, messages: list[dict] | None = None,
|
|
254
|
+
max_new_tokens: int = 512, threshold: str | float | None = None,
|
|
255
|
+
temperature: float = 0.0) -> Iterator[Event]: # fmt: skip
|
|
256
|
+
"""Generate an answer token by token; when a sentence ends, its entities are scored
|
|
257
|
+
from the hidden states already computed for generation (no second pass)."""
|
|
258
|
+
from transformers import DynamicCache
|
|
259
|
+
|
|
260
|
+
th = self._threshold(threshold)
|
|
261
|
+
tok, lm = self.tokenizer, self.model
|
|
262
|
+
msgs = self._messages(prompt, messages)
|
|
263
|
+
prefix = tok.apply_chat_template(
|
|
264
|
+
msgs, add_generation_prompt=True, tokenize=False, **self.chat
|
|
265
|
+
)
|
|
266
|
+
ids = tok(prefix, add_special_tokens=False, return_tensors="pt").input_ids.to(lm.device)
|
|
267
|
+
eos = lm.generation_config.eos_token_id
|
|
268
|
+
stop = set(eos if isinstance(eos, list) else [eos]) | {tok.eos_token_id}
|
|
269
|
+
cache = DynamicCache()
|
|
270
|
+
out = lm(ids, past_key_values=cache, use_cache=True)
|
|
271
|
+
gen: list[int] = []
|
|
272
|
+
dec = [0] # dec[i]: length of the text decoded from the first i tokens
|
|
273
|
+
hidden: list[np.ndarray] = []
|
|
274
|
+
probs: list[np.ndarray] = []
|
|
275
|
+
text, scored, seen = "", set(), set() # sentences and entity spans already scored
|
|
276
|
+
for _ in range(max_new_tokens):
|
|
277
|
+
logits = out.logits[0, -1].float()
|
|
278
|
+
if temperature > 0:
|
|
279
|
+
nxt = int(torch.multinomial(torch.softmax(logits / temperature, -1), 1))
|
|
280
|
+
else:
|
|
281
|
+
nxt = int(logits.argmax())
|
|
282
|
+
if nxt in stop:
|
|
283
|
+
break
|
|
284
|
+
gen.append(nxt)
|
|
285
|
+
out = lm(torch.tensor([[nxt]], device=lm.device), past_key_values=cache,
|
|
286
|
+
use_cache=True, output_hidden_states=True) # fmt: skip
|
|
287
|
+
h = out.hidden_states[self.probe.layer + 1][0, -1]
|
|
288
|
+
hidden.append(h.half().cpu().numpy())
|
|
289
|
+
probs.append(self.probe.token_probs(h[None])[0])
|
|
290
|
+
ended = text.rstrip()[-1:] in SENTENCE_END or "\n" in text[-1:]
|
|
291
|
+
new = tok.decode(gen, skip_special_tokens=True)
|
|
292
|
+
dec.append(len(new))
|
|
293
|
+
delta = new[len(text) :] if new.startswith(text) else new
|
|
294
|
+
text = new
|
|
295
|
+
found = []
|
|
296
|
+
if ended:
|
|
297
|
+
found = self._finished(text, gen, dec, hidden, probs, scored, seen, th, False)
|
|
298
|
+
yield Event(delta, found, checked=max((hi for _, hi in scored), default=0))
|
|
299
|
+
last = self._finished(text, gen, dec, hidden, probs, scored, seen, th, True)
|
|
300
|
+
yield Event("", last, True, len(text), gen)
|
|
301
|
+
|
|
302
|
+
def _finished(self, text, gen, dec, hidden, probs, scored, seen, th, final) -> list[Entity]:
|
|
303
|
+
"""Entities of sentences that are complete and not scored yet (each span once, even if
|
|
304
|
+
sentence boundaries shift as more text arrives)."""
|
|
305
|
+
sents = sentences(text)
|
|
306
|
+
todo = [s for s in (sents if final else sents[:-1]) if s not in scored]
|
|
307
|
+
if not todo or not gen:
|
|
308
|
+
return []
|
|
309
|
+
scored.update(todo)
|
|
310
|
+
n = len(gen)
|
|
311
|
+
fw = Forward(gen, list(zip(dec[:-1], dec[1:], strict=True)), 0, np.stack(probs),
|
|
312
|
+
np.stack(hidden), np.full(n, np.nan), np.full(n, np.nan)) # fmt: skip
|
|
313
|
+
spans = [s for s in self._find(text, todo) if s not in seen]
|
|
314
|
+
seen.update(spans)
|
|
315
|
+
return self._entities(text, fw, spans, th)
|
hallustream/entities.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Find the entities to score: names, places, dates, numbers, titles. Uses GLiNER, chosen on dev
|
|
2
|
+
biographies for finding the most false entities; install with
|
|
3
|
+
`pip install "hallustream[entities]"`, or pass your own spans."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
|
|
9
|
+
# Entity types the judge labels (EVAL_PROTOCOL.md): people, organizations, places, dates,
|
|
10
|
+
# quantities, titles of works, awards; plus the descriptive roles it often includes.
|
|
11
|
+
GLINER_LABELS = [
|
|
12
|
+
"person", "organization", "location", "date", "number", "nationality",
|
|
13
|
+
"occupation", "title of work", "award", "event",
|
|
14
|
+
] # fmt: skip
|
|
15
|
+
GLINER_MODEL = "urchade/gliner_medium-v2.1" # Apache-2.0
|
|
16
|
+
# Generic lowercase words ("region", "book", "team") are skipped: the fact checker almost never
|
|
17
|
+
# labels them, and on the library-check texts they drew 16-19% of all flags while dropping them
|
|
18
|
+
# kept 96-99% of the false entities found (research/entity_rule.py). Jobs and roles ("actress")
|
|
19
|
+
# stay: in biographies they are claims, and the probe ranks them well (0.92 AUROC).
|
|
20
|
+
ROLES = {"occupation"}
|
|
21
|
+
TITLES = {"mr", "mrs", "ms", "miss", "dr", "prof", "sir", "dame", "st"} # alone, not a detail
|
|
22
|
+
# Words that are capitalized only because they start a sentence ("The death of ...")
|
|
23
|
+
STARTERS = {
|
|
24
|
+
"the", "a", "an", "his", "her", "their", "its", "this", "that", "these", "those",
|
|
25
|
+
"in", "on", "at", "after", "before", "during", "by", "for", "with", "from", "as",
|
|
26
|
+
} # fmt: skip
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def available() -> bool:
|
|
30
|
+
try:
|
|
31
|
+
import gliner # noqa: F401 # pyright: ignore[reportMissingImports]
|
|
32
|
+
except ImportError:
|
|
33
|
+
return False
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_specific(text: str, label: str, sentence_start: bool = False) -> bool:
|
|
38
|
+
"""A name, place, title, date or number (it has a capital letter or a digit), or a role.
|
|
39
|
+
|
|
40
|
+
At the start of a sentence, a capitalized function word ("The death of ...") doesn't
|
|
41
|
+
count, and a lone title ("Mr.") is skipped (EVAL_PROTOCOL.md, follow-ups C)."""
|
|
42
|
+
words = text.split()
|
|
43
|
+
if not words or text.strip().rstrip(".").lower() in TITLES:
|
|
44
|
+
return False
|
|
45
|
+
if label in ROLES:
|
|
46
|
+
return True
|
|
47
|
+
if sentence_start and words[0].lower() in STARTERS:
|
|
48
|
+
text = " ".join(words[1:])
|
|
49
|
+
return any(c.isupper() or c.isdigit() for c in text)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@lru_cache(maxsize=1)
|
|
53
|
+
def _gliner(device: str = "cpu"):
|
|
54
|
+
from gliner import GLiNER # pyright: ignore[reportMissingImports]
|
|
55
|
+
|
|
56
|
+
return GLiNER.from_pretrained(GLINER_MODEL).to(device)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find(text: str, sents: list[tuple[int, int]], threshold: float = 0.4,
|
|
60
|
+
device: str = "cpu") -> list[tuple]: # fmt: skip
|
|
61
|
+
"""(start, end) character spans of specific entities, found sentence by sentence."""
|
|
62
|
+
if not available():
|
|
63
|
+
raise ImportError(
|
|
64
|
+
"finding entities needs GLiNER: pip install 'hallustream[entities]',"
|
|
65
|
+
" or pass spans=[(start, end), ...] yourself"
|
|
66
|
+
)
|
|
67
|
+
model = _gliner(device)
|
|
68
|
+
out = []
|
|
69
|
+
for lo, hi in sents:
|
|
70
|
+
for e in model.predict_entities(text[lo:hi], GLINER_LABELS, threshold=threshold):
|
|
71
|
+
a, b = lo + e["start"], lo + e["end"]
|
|
72
|
+
start = not text[lo:a].strip() # nothing but spaces before it in the sentence
|
|
73
|
+
if is_specific(text[a:b], e["label"], start):
|
|
74
|
+
out.append((a, b))
|
|
75
|
+
return out
|
|
File without changes
|