contrastset 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Misera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,365 @@
1
+ Metadata-Version: 2.4
2
+ Name: contrastset
3
+ Version: 0.1.0
4
+ Summary: Build and check contrast sets (matched stimulus pairs) for interpretability experiments on local LLMs.
5
+ Keywords: interpretability,llm,contrast sets,probing,datasets
6
+ Author: Thomas Misera
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: pydantic>=2.7
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: pyyaml>=6
21
+ Requires-Dist: anthropic>=2.3.0 ; extra == 'anthropic'
22
+ Requires-Dist: torch>=2.14.1 ; extra == 'hf'
23
+ Requires-Dist: transformers>=5.21.0 ; extra == 'hf'
24
+ Requires-Python: >=3.11
25
+ Project-URL: Homepage, https://github.com/modemuser/contrastset
26
+ Project-URL: Repository, https://github.com/modemuser/contrastset
27
+ Provides-Extra: anthropic
28
+ Provides-Extra: hf
29
+ Description-Content-Type: text/markdown
30
+
31
+ # contrastset
32
+
33
+ > **Status: v0.1.0.** Used for one experiment so far (an "I don't know" readout in
34
+ > Qwen3-8B). The file formats and the CLI may still change.
35
+
36
+ Build and check **contrast sets**, matched pairs of texts that differ in exactly one
37
+ controlled variable, for interpretability experiments on local LLMs (large language
38
+ models). A probe (a small classifier trained on a model's internal activations)
39
+ finds whatever separates the two sides of your data. If the sides also differ in
40
+ length, wording, or how famous the names are, a probe trained on those pairs learns
41
+ those surface cues instead of the variable you care about, and its accuracy looks
42
+ like a result. Matched pairs remove the alternatives by construction: the two sides
43
+ share everything but the contrast, so whatever separates them is the contrast.
44
+ contrastset makes such pairs cheap to produce and checks that they differ in one
45
+ thing only.
46
+
47
+ Three commands, each useful on its own, sharing one file format: JSONL (JSON Lines,
48
+ one JSON object per line), readable by eye and with `jq`. Data goes to stdout,
49
+ progress and reports to stderr, so every command pipes.
50
+
51
+ | command | reads | writes |
52
+ |------------|-------------------------------------|---------------------------|
53
+ | `grade` | entities with questions and answers | what the subject model knows, with every raw sample |
54
+ | `generate` | a spec (+ a graded file, if used) | pairs |
55
+ | `check` | any pair file (`a` and `b` per line) | per-pair verdicts, exit code for CI |
56
+
57
+ Two models are involved and never confused: the **subject model** is the small
58
+ local model being studied (graded behaviourally, by its answers, never by asking it
59
+ whether it knows); the **generator model** writes sentence frames and never sees
60
+ what the subject knows.
61
+
62
+ ```bash
63
+ pip install "contrastset @ git+https://github.com/modemuser/contrastset@v0.1.0" # core: grade/generate/check over HTTP
64
+ pip install "contrastset[hf] @ git+https://github.com/modemuser/contrastset@v0.1.0" # + in-process transformers, log-prob grading
65
+ contrastset --help
66
+ ```
67
+
68
+ Python 3.11+. Extras: `hf` (torch, transformers), `anthropic` (a generator model
69
+ through the Anthropic API). The core needs neither: Ollama and any
70
+ OpenAI-compatible server are reached over HTTP. For development: `uv sync`, then
71
+ `uv run pytest`.
72
+
73
+ ## The ladder
74
+
75
+ Each example adds one concept. Every example folder holds its inputs and its
76
+ committed outputs, so you can read the result of each step without running it.
77
+ Commands are run from inside the example folder. The committed files were made
78
+ with `SOURCE_DATE_EPOCH=1793145600` (timestamps fixed), so rerunning a
79
+ deterministic step reproduces them byte for byte.
80
+
81
+ ### 00 · Marks & Tegmark: does your probe stage work at all?
82
+
83
+ Not a contrastset feature but a check on the stage after it: a difference-of-means
84
+ probe on Marks and Tegmark's true/false statements, on Qwen3-8B, with a frozen split
85
+ and the expected per-layer AUC. If your probe code can't reproduce this, a null
86
+ result on your own pairs means nothing. See
87
+ [`examples/00-marks-tegmark/README.md`](examples/00-marks-tegmark/README.md); it is
88
+ also the opt-in smoke test (see Testing).
89
+
90
+ ### 01 · lexical control: spec, inline slots, hand-written frame, `check`
91
+
92
+ No model, no graded file, about a second.
93
+
94
+ ```bash
95
+ cd examples/01-lexical-control
96
+ contrastset generate spec.yaml > pairs.jsonl
97
+ contrastset check pairs.jsonl --spec spec.yaml > check.jsonl
98
+ ```
99
+
100
+ A **spec** says what the contrast is. Here, two inline word lists fill one
101
+ placeholder, `[[X1]]`, in hand-written **frames**:
102
+
103
+ ```yaml
104
+ frames:
105
+ - The [[X1]] is next to the window.
106
+ slots:
107
+ select:
108
+ a: {source: inline, values: [table, lamp, chair]}
109
+ b: {source: inline, values: [shelf, sofa, desk]}
110
+ ```
111
+
112
+ Each pair fills the same frame twice, once with a value for side a and once for
113
+ side b, so the sides differ only in the slot, by construction. A probe that tells
114
+ these pairs apart is reading word identity: run it as a control next to every real
115
+ contrast set. `check` runs the checks the spec lists (here `length`, `overlap`) and
116
+ exits non-zero if any pair fails.
117
+
118
+ ### 02 · capitals: `grade`, then select slots from what the model knows
119
+
120
+ ```bash
121
+ cd examples/02-capitals
122
+ contrastset grade entities.jsonl --model qwen3:14b --template template.txt > graded.jsonl
123
+ contrastset generate spec.yaml --graded graded.jsonl > pairs.jsonl
124
+ contrastset check pairs.jsonl --spec spec.yaml > check.jsonl
125
+ ```
126
+
127
+ **Entities** are what you ask about, with reference answers (any alias counts):
128
+
129
+ ```json
130
+ {"entity": "Burundi", "type": "country", "questions": [{"id": "capital", "q": "What is the capital of Burundi?", "answers": ["Gitega"]}]}
131
+ ```
132
+
133
+ `grade` asks every question 8 times at temperature 0.7 plus once greedily (always
134
+ the most likely token), matches the answers against the references and sorts each
135
+ (entity, question) into a **bin**: `known`, `confidently_wrong` (the same wrong
136
+ answer again and again), `unstable` (scattered: guessing) or `refused`. Every raw
137
+ sample is kept.
138
+
139
+ The **template** is the exact text the model sees, chat format included; the
140
+ adapters send it as-is. It is always your choice (`--template` is required and has
141
+ no default) and carries the refusal phrases written for its own wording:
142
+
143
+ ```
144
+ ---
145
+ refusal_phrases: ["i don't know", "i do not know", "not sure", ...]
146
+ ---
147
+ <|im_start|>user
148
+ {{question}} Answer with just the city name. If you do not know, say "I don't know".<|im_end|>
149
+ ...
150
+ ```
151
+
152
+ A **graded slot** selects candidates from the graded file. The unit is an (entity,
153
+ question) pair, and selectors filter a flattened view of it. To see that view,
154
+ print it, since it is itself a valid slot file:
155
+
156
+ ```bash
157
+ contrastset generate --candidates --graded graded.jsonl | jq -c 'select(.bin == "known")' | head -2
158
+ ```
159
+
160
+ Fields: `value` (= `entity`), `question`, `bin`, `correct_rate`, `consistency`,
161
+ `refusal_rate`, `n_correct`, `n`, `type`, `entity_bin`, `q.<id>` (the bin of the
162
+ entity's other questions), `answer.<id>`, `meta.<key>`. Filter in the spec with
163
+ `where` (`{bin: known}`, `{correct_rate: {max: 0.125}}`), or with jq and feed the
164
+ result back as `{source: file, path: ...}`: selectors are a convenience, not a
165
+ requirement.
166
+
167
+ This example doubles as a sanity check of the matcher: a 14B model knows nearly
168
+ every capital, so many `unstable` answers would mean the matcher, not the model, is
169
+ broken. qwen3:14b knows 34 of 37; the three it doesn't are the classic confusions
170
+ (Burundi → "Bujumbura", the capital until 2019; Sri Lanka → "Colombo"; Côte
171
+ d'Ivoire split between Abidjan and Yamoussoukro). With only three unknown
172
+ countries, every b side recurs about 17 times, and `check` says so: `duplicates`
173
+ warns on all 102 pairs. Read the warnings as information about your data, not
174
+ noise.
175
+
176
+ ### 03 · two-clause rivers: sides, familiar entities, clause order, factors, behaviour
177
+
178
+ ```bash
179
+ cd examples/03-two-clause-rivers
180
+ contrastset grade entities.jsonl --model qwen3:14b --template template.txt > graded.jsonl
181
+ contrastset generate spec.yaml --graded graded.jsonl --adapter anthropic --model claude-haiku-5-5 --limit 60 > pairs.jsonl
182
+ contrastset check pairs.jsonl --spec spec.yaml > check.jsonl # add the subject flags below for `behaviour`
183
+ contrastset check pairs.jsonl --spec spec.yaml --checks behaviour --require behaviour \
184
+ --graded graded.jsonl --subject-model qwen3:14b --template template.txt > check-behaviour.jsonl
185
+ ```
186
+
187
+ Two-clause questions: side a asks about two towns whose river the model knows,
188
+ side b swaps one for a town it doesn't. New concepts:
189
+
190
+ - **`sides`** say which slots go where: `a: [shared, known]`, `b: [shared, unknown]`.
191
+ A slot on both sides keeps its value.
192
+ - **`slot_defaults: {familiar: true}`**: every town must have its other questions
193
+ (country, region) known, so the entity is familiar and only the river fact varies.
194
+ - **`order: both`** also emits every pair with the clauses reversed (`rotations`
195
+ cycles through every position).
196
+ - **`factors`** control a variable instead of leaving it to chance: here whether the
197
+ neighbouring town is in the same country, balanced and recorded per pair.
198
+ - **A generator model writes the frames** from 3 to 5 example pairs, entity-free, so
199
+ it never sees the contrasted towns. It is unseeded; to rebuild the same pairs,
200
+ reuse their frames: `generate ... --frames-from pairs.jsonl`.
201
+ - **`behaviour`** asks the subject model both sides and scores each clause: is the
202
+ known clause still answered and the unknown one still not, inside the composite?
203
+ It needs `--graded`, a subject model and a template; where any is missing it is
204
+ *skipped*, which is not a pass: `--require behaviour` fails the run on a skip.
205
+
206
+ ### 04 · intent pairs: hand-written pairs, your own check, a blind judge
207
+
208
+ ```bash
209
+ cd examples/04-intent-pairs
210
+ contrastset check pairs.jsonl --spec spec.yaml --checks length,overlap,slot_diff,intent_checks.no_intent_words > check.jsonl
211
+ contrastset check pairs.jsonl --spec spec.yaml --checks blind_judge > check-judge.jsonl # calls Haiku
212
+ ```
213
+
214
+ No `generate`: the pairs are written by hand (the four in `pairs.jsonl` are stubs),
215
+ and the spec has no slots, only a contrast and checks. `check` needs only `a` and
216
+ `b`; other fields (`scenario`) pass through.
217
+
218
+ - **Your own check** is any function `check(pair, spec, /, **options) -> Verdict |
219
+ None`, referenced by dotted path (`intent_checks.no_intent_words`, a module next to
220
+ the spec). This one fails pairs that carry the contrast in a marker word
221
+ ("deliberately"), which `wall-04` does on purpose.
222
+ - **Thresholds are per contrast**: these pairs differ in a clause, so the spec loosens
223
+ `overlap` and `slot_diff`.
224
+ - **`blind_judge`** shows both sides to a judge model in random order and asks which
225
+ one is the target. Over the set it tests the judge's accuracy against chance. For
226
+ a contrast the subject's knowledge defines (example 03) a judge has only surface
227
+ form to go on, so beating chance means a leak. For a contrast readable from meaning,
228
+ like intent, a judge *should* succeed: there it is a manipulation check, not a
229
+ leak test.
230
+
231
+ ## The JSONL contract
232
+
233
+ Every JSONL input (entities, a graded file, pairs) is a path or stdin (`-`); a spec
234
+ is a YAML or JSON file. Every verb writes JSONL to stdout (or `-o FILE`), one line per item as soon as it is done, so you can watch partial
235
+ results and stop early (`grade` and `generate` take `--limit N` for a cheap first
236
+ look). What each
237
+ command **requires** from its input; every other field is passed through unchanged
238
+ and listed once on stderr, so a typo in an optional field is visible without
239
+ breaking files written by other tools:
240
+
241
+ | input | required | used if present |
242
+ |---|---|---|
243
+ | entity (`grade`) | `entity`, `type`, `questions[].q`, `questions[].answers` | `questions[].id`, `meta` |
244
+ | graded (`generate --graded`) | `entity`, `questions[].id`, `questions[].bin` | `type`, `meta`, `bin`, `questions[].{q, answers, correct_rate, ...}` |
245
+ | pair (`check`) | `a`, `b` | `pair_id` (else sha256 of a and b), `frame`, `fills`, `factors` |
246
+
247
+ One known gap in v0.1.0: `grade` keeps unknown fields on an entity line but not
248
+ inside its `questions[]` entries (the test for it is marked as an expected failure).
249
+
250
+ Specs are hand-written configuration and stay strict: an unknown key is an error.
251
+ The schemas are in `src/contrastset/records.py` (`GradedView`, `PairView` are the
252
+ read contracts). Every output line carries provenance: model, template hash,
253
+ sampling settings (including the server's defaults), seed, input hashes, time.
254
+
255
+ ## Log-prob grading (`grade --logprob`)
256
+
257
+ Sampling 8 answers measures what the model *says*, with sampling noise: a question
258
+ it answers correctly 73 % of the time comes out 8/8 about once in twelve tries.
259
+ With `--adapter hf`, `--logprob` also reads the untempered probabilities: the mass
260
+ of completions that start with a correct answer, with the greedy answer, and with a
261
+ refusal phrase. These are binned with the same thresholds and stored beside the
262
+ sampled numbers (`questions[].logprob`; `logprob_sampler` holds the same under the
263
+ sampling distribution). With `-n 0` the grade uses the probabilities alone: one
264
+ greedy decode plus teacher-forced scoring, no samples, deterministic.
265
+
266
+ **Marginalised over tokenizations.** The probability of a text is the sum over
267
+ every token sequence that spells it, and models often write rare names in a
268
+ segmentation the tokenizer would not choose. Scoring the canonical segmentation
269
+ alone undercounted "Rhineland-Palatinate" on Qwen3-8B by 26× (0.031 vs 0.823).
270
+ contrastset expands, step by step, every token consistent with the rest of the
271
+ answer (a byte trie over the vocabulary, no top-k) and sums the finished paths.
272
+
273
+ **The pruned-mass bound.** The one approximation: a partial path whose probability
274
+ falls below 1e-5 is dropped, and the dropped mass is recorded per question
275
+ (`p_correct_pruned_max`, `p_modal_pruned_max`). The true value lies in
276
+ [p, p + pruned_max]. Pruning limits and the bound go into provenance. Refusal
277
+ phrases are still scored in their canonical segmentation (everyday English, where
278
+ that is the model's own), a known limitation.
279
+
280
+ ## One template for grading and probing
281
+
282
+ The template a model is probed or checked with must be the exact string it was
283
+ graded with: bins graded under one wording don't transfer to activations or answers
284
+ collected under another (see Lessons: one line in the prompt can create a whole
285
+ bin). Every graded line records the template's sha256. `check --checks behaviour`
286
+ compares the template it is given with the one in the graded file's provenance and
287
+ refuses a mismatch; `--allow-template-mismatch` overrides, and the override is
288
+ recorded in the check's provenance. Probe code downstream should do the same
289
+ (`grade.require_same_template`).
290
+
291
+ ## Backends
292
+
293
+ | adapter | reaches | use |
294
+ |---|---|---|
295
+ | `ollama` (default) | Ollama's native API in raw mode: your template is sent as-is | subject or generator |
296
+ | `openai`, `openai-chat` | any OpenAI-compatible server (vLLM, llama.cpp server, online APIs) | subject or generator |
297
+ | `anthropic` | the Anthropic API (`[anthropic]` extra) | generator only |
298
+ | `hf` | transformers in-process (`[hf]` extra): samples, log-probabilities, activations | subject |
299
+ | `fake` | canned answers, for tests | tests |
300
+
301
+ **The rule: Ollama when samples suffice, HF log-prob for anything that feeds a
302
+ probe.** Ollama is much faster for sample-level questions (does the model answer
303
+ this? does a composite prompt still work?). A graded file that defines the cells of
304
+ a probe should come from the HF adapter with `--logprob`, on the same weights the
305
+ probe loads: a Q4 Ollama model and the bf16 HF model are different models, and
306
+ log-prob masses carry no sampling noise.
307
+
308
+ **Sampling order.** Where contrastset samples itself (the `hf` adapter), it uses one
309
+ order: top-k, then top-p, both on the untempered probabilities, then temperature,
310
+ then the draw. That is llama.cpp's order, so HF and Ollama grades sample alike;
311
+ transformers' own `generate()` tempers first, and then top-p cuts a sharpened
312
+ distribution and the samples come out less varied. Where a server samples
313
+ (`ollama`), provenance records the server's settings, its overrides and its
314
+ version. Every graded line records all sampler parameters, and `--top-k`/`--top-p`
315
+ set them explicitly.
316
+
317
+ ## Testing
318
+
319
+ | tier | command | needs | time | when |
320
+ |---|---|---|---|---|
321
+ | fast (default) | `uv run pytest` | nothing: no model, no network | ~10 s | always; the only tier CI runs |
322
+ | smoke | `uv run --extra hf pytest -m smoke` | `[hf]`, Qwen/Qwen3-8B weights, network for the statement sets | ~10 min (M-series Mac) | before a release, after touching anything a probe depends on |
323
+ | regrade | `uv run --extra hf pytest -m regrade` | `[hf]`, Qwen/Qwen3-8B weights | ~27 min (M-series Mac) | when the grading path changes |
324
+
325
+ - **fast** reruns every example in the ladder against its committed files (rescoring
326
+ stored samples, regenerating pairs from stored frames, rechecking), plus unit
327
+ tests with a fake adapter.
328
+ - **smoke** runs example 00 end to end: fetch the statement sets, `check` the
329
+ `cities` pairs (every pair must differ in the country slot only), read activations
330
+ at every layer, fit the per-layer arrow on the frozen split, and assert held-out
331
+ AUC ≥ 0.95 at every layer from 20 to the last.
332
+ - **regrade** regrades the 29 towns of example 03 with HF Qwen3-8B, template B
333
+ (`experiments/templates/qwen3-name-only.txt`), `-n 0 --logprob`, and compares
334
+ with the frozen `tests/regrade/graded-29-B.jsonl`: bins and modal answers must
335
+ match exactly, masses within the pruned-mass bound.
336
+
337
+ ## Lessons
338
+
339
+ Two things building this tool taught, with the data in [FINDINGS.md](FINDINGS.md):
340
+
341
+ - **Tokenization undercount.** Log-prob grading over the tokenizer's own
342
+ segmentation undercounts multi-token answers by up to 26×, because the model
343
+ writes rare names in other segmentations. Sum over all of them (above).
344
+ - **The refusal instruction makes the refused bin.** Of 49 towns an earlier grade
345
+ had refused, Qwen3-8B still refused the river for 45 with "If you do not know, say
346
+ 'I don't know'" in the template, and for none without it. The refused bin was the instruction working, not a
347
+ state of the model, and the instruction also hid answers the model gives reliably
348
+ without it (3 of the 45). Grade with the template you probe with, and be wary of
349
+ bins a single prompt line can create.
350
+
351
+ The downstream experiment (difference-of-means probes on Qwen3-8B, where example 00
352
+ comes from) is kept in a separate repository, knowing-probe, which pins this
353
+ version.
354
+
355
+ ## Not in scope
356
+
357
+ No probing, plotting or model internals in the package: output is a dataset file;
358
+ probe with TransformerLens, nnsight or whatever you already use. (Example 00 has a
359
+ small probe script, outside the package, as a check on that stage.) The design
360
+ brief is [PLAN.md](PLAN.md); maintenance passes are logged in
361
+ [NOTEBOOK.md](NOTEBOOK.md).
362
+
363
+ ## Licence
364
+
365
+ MIT, see [LICENSE](LICENSE).