contextattr 0.1.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.
- contextattr/__init__.py +22 -0
- contextattr/__main__.py +7 -0
- contextattr/cache.py +64 -0
- contextattr/cli.py +181 -0
- contextattr/evaluate.py +137 -0
- contextattr/methods/__init__.py +27 -0
- contextattr/methods/base.py +86 -0
- contextattr/methods/indices.py +59 -0
- contextattr/methods/interactions.py +215 -0
- contextattr/methods/kernelshap.py +114 -0
- contextattr/methods/linear.py +104 -0
- contextattr/methods/loo.py +31 -0
- contextattr/prompt/__init__.py +9 -0
- contextattr/prompt/io.py +222 -0
- contextattr/prompt/render.py +70 -0
- contextattr/prompt/split.py +153 -0
- contextattr/prompt/units.py +271 -0
- contextattr/report.py +120 -0
- contextattr/responses/__init__.py +6 -0
- contextattr/responses/checks.py +156 -0
- contextattr/responses/sample.py +36 -0
- contextattr/result.py +120 -0
- contextattr/run.py +306 -0
- contextattr/targets/__init__.py +7 -0
- contextattr/targets/sampled.py +58 -0
- contextattr/targets/scored.py +177 -0
- contextattr/value.py +201 -0
- contextattr/wrapper/__init__.py +9 -0
- contextattr/wrapper/check.py +163 -0
- contextattr/wrapper/contract.py +166 -0
- contextattr/wrapper/template.py +56 -0
- contextattr/wrapper/toy.py +129 -0
- contextattr-0.1.0.dist-info/METADATA +129 -0
- contextattr-0.1.0.dist-info/RECORD +38 -0
- contextattr-0.1.0.dist-info/WHEEL +5 -0
- contextattr-0.1.0.dist-info/entry_points.txt +2 -0
- contextattr-0.1.0.dist-info/licenses/LICENSE +21 -0
- contextattr-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""The input format of docs/interface.md §2: Prompt, Message, Unit, Group and Response, with their checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import cached_property
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
LABELS = ("positive", "negative")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _need_str(value, what: str, *, empty: bool = True) -> None:
|
|
16
|
+
if not isinstance(value, str):
|
|
17
|
+
raise TypeError(f"{what} must be a string, not {type(value).__name__}")
|
|
18
|
+
if not empty and not value:
|
|
19
|
+
raise ValueError(f"{what} is empty")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _need_opt_str(value, what: str) -> None:
|
|
23
|
+
if value is not None:
|
|
24
|
+
_need_str(value, what)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _need_sequence(value, what: str) -> None:
|
|
28
|
+
if isinstance(value, (str, bytes, Mapping)) or not isinstance(value, Iterable):
|
|
29
|
+
raise TypeError(f"{what} must be a list, not {type(value).__name__}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Unit:
|
|
34
|
+
"""A removable piece of a prompt: `text`, then `sep`, which goes with it when it is deleted and stays when it is
|
|
35
|
+
replaced. `replace` is this unit's replacement text, overriding the run's removal setting."""
|
|
36
|
+
|
|
37
|
+
id: str
|
|
38
|
+
text: str
|
|
39
|
+
sep: str = ""
|
|
40
|
+
tag: str | None = None
|
|
41
|
+
replace: str | None = None
|
|
42
|
+
group: str | None = None # the id of the Group holding it, set by the Group
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
_need_str(self.id, "unit id", empty=False)
|
|
46
|
+
_need_str(self.text, f"unit {self.id!r}: text", empty=False)
|
|
47
|
+
_need_str(self.sep, f"unit {self.id!r}: sep")
|
|
48
|
+
_need_opt_str(self.tag, f"unit {self.id!r}: tag")
|
|
49
|
+
_need_opt_str(self.replace, f"unit {self.id!r}: replace")
|
|
50
|
+
_need_opt_str(self.group, f"unit {self.id!r}: group")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Group:
|
|
55
|
+
"""Units that belong together, such as a document cut into sentences. `lead` comes before them and `sep` after;
|
|
56
|
+
both stay while any of the units shows (kept, or replaced) and go with the last one deleted. `tag` and `replace`
|
|
57
|
+
are given to each unit that has none of its own."""
|
|
58
|
+
|
|
59
|
+
id: str
|
|
60
|
+
units: tuple[Unit, ...]
|
|
61
|
+
lead: str = ""
|
|
62
|
+
sep: str = ""
|
|
63
|
+
tag: str | None = None
|
|
64
|
+
replace: str | None = None
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
_need_str(self.id, "group id", empty=False)
|
|
68
|
+
_need_str(self.lead, f"group {self.id!r}: lead")
|
|
69
|
+
_need_str(self.sep, f"group {self.id!r}: sep")
|
|
70
|
+
_need_opt_str(self.tag, f"group {self.id!r}: tag")
|
|
71
|
+
_need_opt_str(self.replace, f"group {self.id!r}: replace")
|
|
72
|
+
_need_sequence(self.units, f"group {self.id!r}: units")
|
|
73
|
+
units = []
|
|
74
|
+
for u in self.units:
|
|
75
|
+
if not isinstance(u, Unit):
|
|
76
|
+
raise TypeError(f"group {self.id!r}: units must be Unit objects, not {type(u).__name__}")
|
|
77
|
+
if u.group not in (None, self.id):
|
|
78
|
+
raise ValueError(f"group {self.id!r}: unit {u.id!r} already belongs to group {u.group!r}")
|
|
79
|
+
units.append(dataclasses.replace(
|
|
80
|
+
u, group=self.id,
|
|
81
|
+
tag=self.tag if u.tag is None else u.tag,
|
|
82
|
+
replace=self.replace if u.replace is None else u.replace))
|
|
83
|
+
if not units:
|
|
84
|
+
raise ValueError(f"group {self.id!r} has no units")
|
|
85
|
+
object.__setattr__(self, "units", tuple(units))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
Piece = Union[str, Unit, Group]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _pieces(content, what: str) -> tuple[Piece, ...]:
|
|
92
|
+
"""A message's content or a text prompt: a string (all fixed), or a list of strings, units and groups."""
|
|
93
|
+
if isinstance(content, str):
|
|
94
|
+
return (content,)
|
|
95
|
+
_need_sequence(content, what)
|
|
96
|
+
out = []
|
|
97
|
+
for p in content:
|
|
98
|
+
if isinstance(p, Unit) and p.group is not None:
|
|
99
|
+
raise ValueError(f"{what}: unit {p.id!r} names group {p.group!r} but is not inside it")
|
|
100
|
+
if not isinstance(p, (str, Unit, Group)):
|
|
101
|
+
raise TypeError(f"{what}: a piece is a string, a Unit or a Group, not {type(p).__name__}")
|
|
102
|
+
out.append(p)
|
|
103
|
+
return tuple(out)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Message:
|
|
108
|
+
"""One chat message: its role and its content, a sequence of fixed strings, units and groups."""
|
|
109
|
+
|
|
110
|
+
role: str
|
|
111
|
+
content: tuple[Piece, ...]
|
|
112
|
+
|
|
113
|
+
def __post_init__(self) -> None:
|
|
114
|
+
_need_str(self.role, "message role", empty=False)
|
|
115
|
+
object.__setattr__(self, "content", _pieces(self.content, f"{self.role} message"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class Response:
|
|
120
|
+
"""A reply of the model: `label` is positive, negative or None; `span` [start, end) marks the characters that
|
|
121
|
+
matter."""
|
|
122
|
+
|
|
123
|
+
id: str
|
|
124
|
+
text: str
|
|
125
|
+
label: str | None = None
|
|
126
|
+
span: tuple[int, int] | None = None
|
|
127
|
+
|
|
128
|
+
def __post_init__(self) -> None:
|
|
129
|
+
_need_str(self.id, "response id", empty=False)
|
|
130
|
+
_need_str(self.text, f"response {self.id!r}: text", empty=False)
|
|
131
|
+
if self.label is not None and self.label not in LABELS:
|
|
132
|
+
raise ValueError(f"response {self.id!r}: label must be positive, negative or None, not {self.label!r}")
|
|
133
|
+
if self.span is not None:
|
|
134
|
+
_need_sequence(self.span, f"response {self.id!r}: span")
|
|
135
|
+
span = tuple(self.span)
|
|
136
|
+
if len(span) != 2 or not all(isinstance(x, int) and not isinstance(x, bool) for x in span):
|
|
137
|
+
raise TypeError(f"response {self.id!r}: span must be two integers [start, end)")
|
|
138
|
+
start, end = span
|
|
139
|
+
if not 0 <= start < end <= len(self.text):
|
|
140
|
+
raise ValueError(f"response {self.id!r}: span {list(span)} must satisfy 0 <= start < end <= "
|
|
141
|
+
f"{len(self.text)}, the length of its text")
|
|
142
|
+
object.__setattr__(self, "span", span)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class Prompt:
|
|
147
|
+
"""A prompt with its removable units: chat `messages` or a plain `text`, never both. Messages may be given as
|
|
148
|
+
dicts {"role", "content"} and responses as dicts; everything not marked as a unit is fixed."""
|
|
149
|
+
|
|
150
|
+
id: str
|
|
151
|
+
messages: tuple[Message, ...] | None = None
|
|
152
|
+
text: tuple[Piece, ...] | None = None
|
|
153
|
+
reference: str | None = None
|
|
154
|
+
responses: tuple[Response, ...] = ()
|
|
155
|
+
|
|
156
|
+
def __post_init__(self) -> None:
|
|
157
|
+
_need_str(self.id, "prompt id", empty=False)
|
|
158
|
+
where = f"prompt {self.id!r}"
|
|
159
|
+
if (self.messages is None) == (self.text is None):
|
|
160
|
+
raise ValueError(f"{where}: give either messages or text")
|
|
161
|
+
if self.messages is not None:
|
|
162
|
+
_need_sequence(self.messages, f"{where}: messages")
|
|
163
|
+
messages = []
|
|
164
|
+
for m in self.messages:
|
|
165
|
+
if isinstance(m, Mapping):
|
|
166
|
+
if set(m) != {"role", "content"}:
|
|
167
|
+
raise ValueError(f"{where}: a message has the fields role and content, not {sorted(m)}")
|
|
168
|
+
m = Message(m["role"], m["content"])
|
|
169
|
+
elif not isinstance(m, Message):
|
|
170
|
+
raise TypeError(f"{where}: a message is a Message or a dict, not {type(m).__name__}")
|
|
171
|
+
messages.append(m)
|
|
172
|
+
if not messages:
|
|
173
|
+
raise ValueError(f"{where}: messages is empty")
|
|
174
|
+
object.__setattr__(self, "messages", tuple(messages))
|
|
175
|
+
else:
|
|
176
|
+
object.__setattr__(self, "text", _pieces(self.text, f"{where}: text"))
|
|
177
|
+
_need_opt_str(self.reference, f"{where}: reference")
|
|
178
|
+
_need_sequence(self.responses, f"{where}: responses")
|
|
179
|
+
responses = tuple(r if isinstance(r, Response) else Response(**r) if isinstance(r, Mapping) else
|
|
180
|
+
_not_a_response(r, where) for r in self.responses)
|
|
181
|
+
ids = [r.id for r in responses]
|
|
182
|
+
if len(set(ids)) != len(ids):
|
|
183
|
+
raise ValueError(f"{where}: response ids must be unique: {ids}")
|
|
184
|
+
object.__setattr__(self, "responses", responses)
|
|
185
|
+
|
|
186
|
+
seen: set[str] = set()
|
|
187
|
+
for p in self._top_pieces():
|
|
188
|
+
for name in ([p.id, *(u.id for u in p.units)] if isinstance(p, Group) else
|
|
189
|
+
[p.id] if isinstance(p, Unit) else []):
|
|
190
|
+
if name in seen:
|
|
191
|
+
raise ValueError(f"{where}: the id {name!r} is used twice (unit and group ids must be unique)")
|
|
192
|
+
seen.add(name)
|
|
193
|
+
if not self.units:
|
|
194
|
+
raise ValueError(f"{where} has no units: mark at least one piece as a unit")
|
|
195
|
+
|
|
196
|
+
def _top_pieces(self):
|
|
197
|
+
if self.messages is not None:
|
|
198
|
+
for m in self.messages:
|
|
199
|
+
yield from m.content
|
|
200
|
+
else:
|
|
201
|
+
yield from self.text
|
|
202
|
+
|
|
203
|
+
@cached_property
|
|
204
|
+
def units(self) -> tuple[Unit, ...]:
|
|
205
|
+
"""Every unit in order: message by message, piece by piece, a group's units in place."""
|
|
206
|
+
out: list[Unit] = []
|
|
207
|
+
for p in self._top_pieces():
|
|
208
|
+
if isinstance(p, Unit):
|
|
209
|
+
out.append(p)
|
|
210
|
+
elif isinstance(p, Group):
|
|
211
|
+
out.extend(p.units)
|
|
212
|
+
return tuple(out)
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def n(self) -> int:
|
|
216
|
+
"""The number of units: the length of a mask."""
|
|
217
|
+
return len(self.units)
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def is_chat(self) -> bool:
|
|
221
|
+
return self.messages is not None
|
|
222
|
+
|
|
223
|
+
@cached_property
|
|
224
|
+
def unit_ids(self) -> tuple[str, ...]:
|
|
225
|
+
return tuple(u.id for u in self.units)
|
|
226
|
+
|
|
227
|
+
@cached_property
|
|
228
|
+
def _index(self) -> Mapping[str, int]:
|
|
229
|
+
return MappingProxyType({u.id: i for i, u in enumerate(self.units)})
|
|
230
|
+
|
|
231
|
+
@cached_property
|
|
232
|
+
def groups(self) -> Mapping[str, tuple[int, ...]]:
|
|
233
|
+
"""Each group's id → the positions of its units in `units`."""
|
|
234
|
+
out: dict[str, list[int]] = {}
|
|
235
|
+
for i, u in enumerate(self.units):
|
|
236
|
+
if u.group is not None:
|
|
237
|
+
out.setdefault(u.group, []).append(i)
|
|
238
|
+
return MappingProxyType({g: tuple(ix) for g, ix in out.items()})
|
|
239
|
+
|
|
240
|
+
def index(self, unit_id: str) -> int:
|
|
241
|
+
"""The position of a unit in `units`, and so in a mask."""
|
|
242
|
+
try:
|
|
243
|
+
return self._index[unit_id]
|
|
244
|
+
except KeyError:
|
|
245
|
+
raise KeyError(f"prompt {self.id!r} has no unit {unit_id!r}") from None
|
|
246
|
+
|
|
247
|
+
def mask(self, *, keep: Iterable[str] | str | None = None,
|
|
248
|
+
drop: Iterable[str] | str | None = None) -> tuple[bool, ...]:
|
|
249
|
+
"""A mask from ids of units or groups: keep only `keep`, or keep every unit except `drop`."""
|
|
250
|
+
if (keep is None) == (drop is None):
|
|
251
|
+
raise ValueError("give either keep or drop")
|
|
252
|
+
names = keep if keep is not None else drop
|
|
253
|
+
chosen: set[int] = set()
|
|
254
|
+
for name in [names] if isinstance(names, str) else names:
|
|
255
|
+
if name in self.groups:
|
|
256
|
+
chosen.update(self.groups[name])
|
|
257
|
+
elif name in self._index:
|
|
258
|
+
chosen.add(self._index[name])
|
|
259
|
+
else:
|
|
260
|
+
raise KeyError(f"prompt {self.id!r} has no unit or group {name!r}")
|
|
261
|
+
return tuple((i in chosen) == (keep is not None) for i in range(self.n))
|
|
262
|
+
|
|
263
|
+
def render(self, mask=None, replace: str | None = None):
|
|
264
|
+
"""The prompt as the model sees it with only the units `mask` keeps (render.py)."""
|
|
265
|
+
from .render import render
|
|
266
|
+
|
|
267
|
+
return render(self, mask, replace)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _not_a_response(value, where: str):
|
|
271
|
+
raise TypeError(f"{where}: a response is a Response or a dict, not {type(value).__name__}")
|
contextattr/report.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Reports (docs/interface.md §8): one self-contained HTML page (no script, no file beside it) showing each prompt with
|
|
2
|
+
its units shaded by score where they stand, then the scores, the top interactions, the removal check and what the
|
|
3
|
+
explanation cost."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import html
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
from .prompt import Group, Unit
|
|
11
|
+
|
|
12
|
+
CSS = """
|
|
13
|
+
:root { --bg: #ffffff; --fg: #1f2328; --muted: #57606a; --line: #d0d7de; --card: #f6f8fa;
|
|
14
|
+
--up: 214, 69, 65; --down: 47, 109, 214; }
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root { --bg: #0d1117; --fg: #e6edf3; --muted: #9198a1; --line: #30363d; --card: #161b22;
|
|
17
|
+
--up: 248, 81, 73; --down: 88, 166, 255; } }
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
body { margin: 0; padding: 24px 16px; background: var(--bg); color: var(--fg);
|
|
20
|
+
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
21
|
+
main { max-width: 960px; margin: 0 auto; }
|
|
22
|
+
h1 { font-size: 22px; margin: 0 0 4px; } h2 { font-size: 18px; margin: 32px 0 6px; }
|
|
23
|
+
.meta, .legend { color: var(--muted); font-size: 13px; }
|
|
24
|
+
.prompt { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px;
|
|
25
|
+
white-space: pre-wrap; overflow-wrap: anywhere; margin: 10px 0; }
|
|
26
|
+
.role { display: block; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
|
|
27
|
+
margin-top: 6px; }
|
|
28
|
+
.u { border-radius: 3px; padding: 0 1px; }
|
|
29
|
+
table { border-collapse: collapse; width: 100%; font-size: 14px; margin: 8px 0; }
|
|
30
|
+
th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
31
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
32
|
+
.wrap { overflow-x: auto; }
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def page(explanations, title: str = "ContextAttr report") -> str:
|
|
37
|
+
"""The HTML page of a list of explanations."""
|
|
38
|
+
parts = [f"<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' "
|
|
39
|
+
f"content='width=device-width, initial-scale=1'><title>{_e(title)}</title><style>{CSS}</style></head>"
|
|
40
|
+
f"<body><main><h1>{_e(title)}</h1><p class='legend'>A unit's shade shows its score: "
|
|
41
|
+
f"<span class='u' style='background: rgba(var(--up), .45)'>pushes the value up</span>, "
|
|
42
|
+
f"<span class='u' style='background: rgba(var(--down), .45)'>pushes it down</span>; hover for the "
|
|
43
|
+
f"number.</p>"]
|
|
44
|
+
for e in explanations:
|
|
45
|
+
parts.append(_section(e))
|
|
46
|
+
parts.append("</main></body></html>")
|
|
47
|
+
return "".join(parts)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write(explanations, path) -> None:
|
|
51
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
52
|
+
f.write(page(explanations))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _section(e) -> str:
|
|
56
|
+
scores = dict(e.scores)
|
|
57
|
+
top = max([abs(v) for v in scores.values()] + [1e-300])
|
|
58
|
+
noise = e.noise["std"] if e.noise else None
|
|
59
|
+
out = [f"<h2>{_e(e.prompt_id or 'attribute')}</h2><p class='meta'>{_e(e.method)} · target {_e(e.target or '')} · "
|
|
60
|
+
f"{e.masks} masks · R² {_fmt(e.r2)} on {e.test_masks} held-out masks · noise {_fmt(noise)} · "
|
|
61
|
+
f"{e.requests.get('requests', 0)} requests</p>"]
|
|
62
|
+
if e.prompt is not None:
|
|
63
|
+
out.append(f"<div class='prompt'>{_prompt_html(e.prompt, scores, top)}</div>")
|
|
64
|
+
rows = sorted(scores.items(), key=lambda t: -t[1])
|
|
65
|
+
texts = dict(zip(e.unit_ids, e.unit_texts or [""] * len(e.unit_ids)))
|
|
66
|
+
out.append("<div class='wrap'><table><tr><th>unit</th><th>score</th><th>text</th></tr>")
|
|
67
|
+
for uid, v in rows:
|
|
68
|
+
out.append(f"<tr><td>{_e(uid)}</td><td class='num'>{v:+.4f}</td><td>{_e(_short(texts.get(uid, '')))}</td></tr>")
|
|
69
|
+
out.append("</table></div>")
|
|
70
|
+
inter = e.top_interactions(10)
|
|
71
|
+
if inter:
|
|
72
|
+
out.append("<div class='wrap'><table><tr><th>units together</th><th>score</th></tr>")
|
|
73
|
+
out += [f"<tr><td>{_e(' & '.join(s))}</td><td class='num'>{w:+.4f}</td></tr>" for s, w in inter]
|
|
74
|
+
out.append("</table></div>")
|
|
75
|
+
if e.removal:
|
|
76
|
+
out.append("<div class='wrap'><table><tr><th>removing the top r</th><th>units removed</th>"
|
|
77
|
+
"<th>the value moves by</th><th>Rem(r)</th></tr>")
|
|
78
|
+
for r in e.removal:
|
|
79
|
+
removed = ", ".join(e.unit_ids[i] for i in r["removed"])
|
|
80
|
+
out.append(f"<tr><td>{r['r']}</td><td>{_e(removed)}</td><td class='num'>{r['change']:+.4f}</td>"
|
|
81
|
+
f"<td class='num'>{_fmt(r['rem'])}</td></tr>")
|
|
82
|
+
out.append("</table></div>")
|
|
83
|
+
return "".join(out)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _prompt_html(prompt, scores, top) -> str:
|
|
87
|
+
def unit(u: Unit) -> str:
|
|
88
|
+
v = scores.get(u.id, 0.0)
|
|
89
|
+
alpha = 0.6 * abs(v) / top
|
|
90
|
+
colour = "--up" if v >= 0 else "--down"
|
|
91
|
+
return (f"<span class='u' title='{_e(u.id)}: {v:+.4f}' style='background: rgba(var({colour}), "
|
|
92
|
+
f"{alpha:.3f})'>{_e(u.text)}</span>{_e(u.sep)}")
|
|
93
|
+
|
|
94
|
+
def pieces(content) -> str:
|
|
95
|
+
out = []
|
|
96
|
+
for p in content:
|
|
97
|
+
if isinstance(p, str):
|
|
98
|
+
out.append(_e(p))
|
|
99
|
+
elif isinstance(p, Unit):
|
|
100
|
+
out.append(unit(p))
|
|
101
|
+
elif isinstance(p, Group):
|
|
102
|
+
out.append(_e(p.lead) + "".join(unit(u) for u in p.units) + _e(p.sep))
|
|
103
|
+
return "".join(out)
|
|
104
|
+
|
|
105
|
+
if prompt.messages is None:
|
|
106
|
+
return pieces(prompt.text)
|
|
107
|
+
return "".join(f"<span class='role'>{_e(m.role)}</span>{pieces(m.content)}" for m in prompt.messages)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _e(text) -> str:
|
|
111
|
+
return html.escape(str(text), quote=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _short(text: str, width: int = 120) -> str:
|
|
115
|
+
text = text.replace("\n", " ")
|
|
116
|
+
return text if len(text) <= width else text[: width - 1] + "…"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _fmt(x) -> str:
|
|
120
|
+
return "n/a" if x is None or (isinstance(x, float) and math.isnan(x)) else f"{x:.3g}"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Responses and labels (docs/interface.md §4): checks that label a response, and replies sampled from the model."""
|
|
2
|
+
|
|
3
|
+
from .checks import Verdict, describe, normalise, reference, regex, verdict
|
|
4
|
+
from .sample import sample
|
|
5
|
+
|
|
6
|
+
__all__ = ["Verdict", "describe", "normalise", "reference", "regex", "sample", "verdict"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Checks: what labels a response (docs/interface.md §4). A check is any function check(prompt, response) giving a
|
|
2
|
+
Verdict, a label ("positive", "negative" or None), True for negative and False for positive, or a number (a judge's
|
|
3
|
+
score, a classifier's probability); `verdict` reads any of these; `describe` names a check in a run's settings."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
import hashlib
|
|
9
|
+
import math
|
|
10
|
+
import numbers
|
|
11
|
+
import re
|
|
12
|
+
import string
|
|
13
|
+
import types
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from ..prompt import LABELS, Prompt
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Verdict:
|
|
23
|
+
"""A check's answer: a label, or a number, and optionally the span [start, end) of the response that matters."""
|
|
24
|
+
|
|
25
|
+
label: str | None = None
|
|
26
|
+
score: float | None = None
|
|
27
|
+
span: tuple[int, int] | None = None
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
if self.label is not None and self.label not in LABELS:
|
|
31
|
+
raise ValueError(f"a verdict's label is positive, negative or None, not {self.label!r}")
|
|
32
|
+
if self.score is not None and not math.isfinite(self.score):
|
|
33
|
+
raise ValueError("a verdict's score must be finite")
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def value(self) -> float | None:
|
|
37
|
+
"""What Sampled averages: 1 for negative, 0 for positive, else the score; None when there is neither."""
|
|
38
|
+
if self.score is not None:
|
|
39
|
+
return float(self.score)
|
|
40
|
+
return None if self.label is None else float(self.label == "negative")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def verdict(result) -> Verdict:
|
|
44
|
+
"""Any check's answer as a Verdict."""
|
|
45
|
+
if isinstance(result, Verdict):
|
|
46
|
+
return result
|
|
47
|
+
if result is None or isinstance(result, str):
|
|
48
|
+
return Verdict(label=result)
|
|
49
|
+
if isinstance(result, bool):
|
|
50
|
+
return Verdict(label="negative" if result else "positive")
|
|
51
|
+
if isinstance(result, numbers.Real):
|
|
52
|
+
return Verdict(score=float(result))
|
|
53
|
+
raise TypeError(f"a check gives a Verdict, a label, a bool or a number, not {type(result).__name__}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def regex(pattern: str, *, negative: bool = True, flags: int = re.IGNORECASE):
|
|
57
|
+
"""A check by pattern: a response that matches it is negative (or positive, with negative=False) and the match is
|
|
58
|
+
its span; one that does not match is the other label."""
|
|
59
|
+
compiled = re.compile(pattern, flags)
|
|
60
|
+
|
|
61
|
+
def check(prompt: Prompt, response: str) -> Verdict:
|
|
62
|
+
m = compiled.search(response)
|
|
63
|
+
hit, miss = ("negative", "positive") if negative else ("positive", "negative")
|
|
64
|
+
if m is None:
|
|
65
|
+
return Verdict(label=miss)
|
|
66
|
+
return Verdict(label=hit, span=(m.start(), m.end()) if m.end() > m.start() else None)
|
|
67
|
+
|
|
68
|
+
shown = "" if flags == re.IGNORECASE else f", flags={re.RegexFlag(flags)!r}"
|
|
69
|
+
check.__name__ = f"regex({pattern!r}, negative={negative}{shown})"
|
|
70
|
+
check.named = True
|
|
71
|
+
return check
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def reference(*, contains: bool = True):
|
|
75
|
+
"""A check against the prompt's `reference`: positive when the response, normalised (lower case, no punctuation,
|
|
76
|
+
single spaces), contains the normalised reference (or equals it, with contains=False); negative otherwise."""
|
|
77
|
+
|
|
78
|
+
def check(prompt: Prompt, response: str) -> Verdict:
|
|
79
|
+
if prompt.reference is None:
|
|
80
|
+
raise ValueError(f"prompt {prompt.id!r} has no reference to check against")
|
|
81
|
+
ref, got = normalise(prompt.reference), normalise(response)
|
|
82
|
+
ok = (ref in got) if contains else (ref == got)
|
|
83
|
+
return Verdict(label="positive" if ok else "negative")
|
|
84
|
+
|
|
85
|
+
check.__name__ = f"reference(contains={contains})"
|
|
86
|
+
check.named = True
|
|
87
|
+
return check
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def describe(check) -> str:
|
|
91
|
+
"""A check's name in a run's settings and an explanation's target, so that a resumed run cannot mix two checks.
|
|
92
|
+
regex's and reference's names carry their arguments. Any other check (a function, a bound method, a callable
|
|
93
|
+
object) is named by its name and a digest of its code (bytecode, constants and names, not line numbers) and of the
|
|
94
|
+
values it holds: its closure's and defaults', or its object's attributes, with numbers, strings and patterns by
|
|
95
|
+
value and other objects (a client, a model) by type. A check that depends on anything else should carry it in its
|
|
96
|
+
__name__ and set `named = True`."""
|
|
97
|
+
if getattr(check, "named", False) is True:
|
|
98
|
+
return check.__name__
|
|
99
|
+
if isinstance(check, functools.partial):
|
|
100
|
+
return f"partial({describe(check.func)}, {_held(check.args)}, {_held(check.keywords)})"
|
|
101
|
+
fn = getattr(check, "__func__", check) # a bound method: its function, and its object
|
|
102
|
+
if isinstance(fn, types.FunctionType):
|
|
103
|
+
owner = getattr(check, "__self__", None)
|
|
104
|
+
state = [_code(fn.__code__), [_cell(c) for c in fn.__closure__ or ()], _held(fn.__defaults__),
|
|
105
|
+
_held(fn.__kwdefaults__), _held(getattr(owner, "__dict__", None))]
|
|
106
|
+
name = fn.__qualname__
|
|
107
|
+
else:
|
|
108
|
+
call = getattr(type(check).__call__, "__code__", None)
|
|
109
|
+
state = [_code(call) if call is not None else "", _held(getattr(check, "__dict__", None))]
|
|
110
|
+
name = type(check).__qualname__
|
|
111
|
+
return f"{name} [{hashlib.sha256(repr(state).encode()).hexdigest()[:12]}]"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _code(code: types.CodeType) -> str:
|
|
115
|
+
def const(c):
|
|
116
|
+
if isinstance(c, types.CodeType):
|
|
117
|
+
return _code(c)
|
|
118
|
+
if isinstance(c, (tuple, frozenset)): # a set's order changes with the hash seed
|
|
119
|
+
parts = [const(x) for x in c]
|
|
120
|
+
return repr(sorted(parts) if isinstance(c, frozenset) else parts)
|
|
121
|
+
return repr(c)
|
|
122
|
+
|
|
123
|
+
return repr((code.co_code.hex(), [const(c) for c in code.co_consts], code.co_names, code.co_varnames))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cell(cell) -> str:
|
|
127
|
+
try:
|
|
128
|
+
return _held(cell.cell_contents)
|
|
129
|
+
except ValueError: # a cell not yet filled
|
|
130
|
+
return "<empty>"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _held(value) -> str:
|
|
134
|
+
if value is None or isinstance(value, (str, bytes, numbers.Number)):
|
|
135
|
+
return repr(value)
|
|
136
|
+
if isinstance(value, re.Pattern):
|
|
137
|
+
return f"re.compile({value.pattern!r}, {int(value.flags)})"
|
|
138
|
+
if isinstance(value, np.ndarray):
|
|
139
|
+
digest = hashlib.sha256(np.ascontiguousarray(value).tobytes()).hexdigest()
|
|
140
|
+
return f"array({value.dtype}, {value.shape}, {digest})"
|
|
141
|
+
if isinstance(value, (tuple, list)):
|
|
142
|
+
return "[" + ", ".join(_held(v) for v in value) + "]"
|
|
143
|
+
if isinstance(value, (set, frozenset)):
|
|
144
|
+
return "{" + ", ".join(sorted(_held(v) for v in value)) + "}"
|
|
145
|
+
if isinstance(value, dict):
|
|
146
|
+
return "{" + ", ".join(sorted(f"{_held(k)}: {_held(v)}" for k, v in value.items())) + "}"
|
|
147
|
+
if isinstance(value, types.FunctionType): # a function it calls: by its code, not recursing
|
|
148
|
+
return value.__name__ if getattr(value, "named", False) is True else _code(value.__code__)
|
|
149
|
+
return f"<{type(value).__qualname__}>"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_PUNCTUATION = str.maketrans({c: " " for c in string.punctuation})
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def normalise(text: str) -> str:
|
|
156
|
+
return " ".join(text.lower().translate(_PUNCTUATION).split())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Responses sampled from the full prompt and labelled (docs/interface.md §4): `sample` adds k replies of the model to
|
|
2
|
+
a prompt's responses, each labelled by a check, so that scored targets (LogProb, Pooled, Contrast) can explain them."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ..cache import Cache
|
|
9
|
+
from ..prompt import Prompt, Response
|
|
10
|
+
from ..wrapper.contract import Sampling
|
|
11
|
+
from .checks import verdict
|
|
12
|
+
|
|
13
|
+
DEFAULT = Sampling(n=8, temperature=0.7, top_p=0.95, max_tokens=256, seed=0)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sample(prompt: Prompt, model, sampling: Sampling = DEFAULT, check=None, *, cache: Cache | str | None = None,
|
|
17
|
+
replace: str | None = None) -> Prompt:
|
|
18
|
+
"""The prompt with `sampling.n` replies to its full text added to its responses, as sample-0, sample-1, …, each
|
|
19
|
+
labelled by `check` (none: unlabelled). Empty replies are left out. The replies are cached like every answer."""
|
|
20
|
+
from ..value import Value # noqa: PLC0415 - value imports the targets, which import this package
|
|
21
|
+
from ..targets.sampled import Sampled
|
|
22
|
+
|
|
23
|
+
replies = Value(prompt, model, Sampled(lambda p, r: 0.0, samples=sampling.n, sampling=sampling), replace=replace,
|
|
24
|
+
cache=cache).replies(np.ones((1, prompt.n), dtype=bool))[0]
|
|
25
|
+
taken = {r.id for r in prompt.responses}
|
|
26
|
+
added = []
|
|
27
|
+
for k, text in enumerate(replies):
|
|
28
|
+
if not text.strip():
|
|
29
|
+
continue
|
|
30
|
+
rid = f"sample-{k}"
|
|
31
|
+
if rid in taken:
|
|
32
|
+
raise ValueError(f"prompt {prompt.id!r} already has a response {rid!r}")
|
|
33
|
+
v = verdict(check(prompt, text)) if check is not None else None
|
|
34
|
+
added.append(Response(rid, text, label=None if v is None else v.label, span=None if v is None else v.span))
|
|
35
|
+
return Prompt(prompt.id, messages=prompt.messages, text=prompt.text, reference=prompt.reference,
|
|
36
|
+
responses=(*prompt.responses, *added))
|