ArchitSharma commited on
Commit
b3d11b8
·
1 Parent(s): 9f9fbec

Release FeatureLens v0.3.0

Browse files
.github/workflows/ci.yml CHANGED
@@ -17,7 +17,7 @@ jobs:
17
  python -m pip install --upgrade pip
18
  pip install "torch>=2.8,<2.12" "huggingface_hub>=0.34,<2" "numpy>=2,<3" "pytest>=8.3,<10" "ruff>=0.9,<1"
19
  - name: Ruff
20
- run: python -m ruff check featurelens tests scripts experiments/split.py experiments/build_dataset.py
21
  - name: Unit tests
22
  run: python -m pytest -q
23
  - name: Compile
 
17
  python -m pip install --upgrade pip
18
  pip install "torch>=2.8,<2.12" "huggingface_hub>=0.34,<2" "numpy>=2,<3" "pytest>=8.3,<10" "ruff>=0.9,<1"
19
  - name: Ruff
20
+ run: python -m ruff check app.py featurelens experiments tests scripts
21
  - name: Unit tests
22
  run: python -m pytest -q
23
  - name: Compile
CHANGELOG.md CHANGED
@@ -1,26 +1,33 @@
1
  # Changelog
2
 
3
- ## 0.2.0 — 2026-08-13
4
 
5
- ### Added
 
 
 
6
 
7
- - Live norm-matched random residual control for causal interventions.
8
- - Target-token and JS-divergence causal specificity ratios.
9
- - Feature-scale dose-response sweep (0× through 3×).
10
- - Cross-layer representation trajectory for layers 4, 14 and 26.
11
- - Native Gradio activation, dose-response and trajectory plots.
12
- - Bootstrap 95% confidence intervals in the generated offline report.
13
- - Paired sign-flip randomization test for SAE-vs-random causal-effect differences.
14
- - Hosted/local validation matrix.
15
 
16
- ### Changed
 
17
 
18
- - Space SDK and dependency pin to Gradio 6.24.0.
19
- - Explicit client-side rendering (`ssr_mode=False`) to avoid the observed SSR async-user warning.
20
- - Shorter ZeroGPU callback durations and a smaller default generation budget.
21
- - More explicit inactive-feature warning and error surfacing.
22
- - Dark/light-mode-safe UI styling using Gradio theme variables.
23
 
24
- ### Removed
 
 
 
 
25
 
26
- - Build/cache directories from release artifacts.
 
 
 
 
 
 
1
  # Changelog
2
 
3
+ ## v0.3.0
4
 
5
+ ### Causal measurement
6
+ - Replaced first-token-only target evaluation with exact full-continuation teacher-forced log-probability scoring.
7
+ - Added total sequence and mean-per-token log-probability deltas plus per-target-token decomposition.
8
+ - Retained next-token probability/JS diagnostics and greedy generation as complementary outputs.
9
 
10
+ ### Distributed feature causality
11
+ - Added joint multi-feature ablation/scaling using summed reconstruction-preserving SAE decoder deltas.
12
+ - Added live top-1/top-3/top-5 joint-ablation sweep with norm-matched random controls.
13
+ - Added offline `experiments/run_feature_sets.py` and report integration.
 
 
 
 
14
 
15
+ ### Robustness
16
+ - Added a live paraphrase-robustness explorer with TopK Jaccard, sparse cosine, overlap table, and activation comparison.
17
 
18
+ ### Efficiency
19
+ - Batched all six single-feature dose-response edits into one model forward after the baseline.
20
+ - Batched targeted/random 1/3/5 feature-set sweep conditions into one model forward after the baseline.
 
 
21
 
22
+ ### UI / deployment
23
+ - Replaced the bright blue visual emphasis with muted teal/stone accents and explicit chart palettes.
24
+ - Added visible `Prompt tokens` headings and aligned validation terminology with actual UI labels.
25
+ - Explicitly labels the dose-response panel as a scale intervention: 0× = ablation, 1× = no edit.
26
+ - Kept SSR disabled for the Hugging Face Space.
27
 
28
+ ## v0.2.0
29
+ - Added live norm-matched random controls.
30
+ - Added single-feature causal dose-response.
31
+ - Added layer trajectory diagnostics.
32
+ - Added bootstrap confidence intervals and paired sign-flip tests.
33
+ - Hardened Gradio / ZeroGPU deployment.
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
  title: FeatureLens
3
  emoji: 🔬
4
- colorFrom: blue
5
- colorTo: indigo
6
  sdk: gradio
7
  python_version: "3.12.12"
8
  sdk_version: "6.24.0"
@@ -13,49 +13,68 @@ license: mit
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
- > **v0.2:** live norm-matched controls, causal dose-response sweeps, layer trajectories, statistical uncertainty, and a hardened ZeroGPU deployment.
17
 
18
  **FeatureLens asks one concrete question:**
19
 
20
  > Do sparse features that predict a concept also causally influence model behaviour?
21
 
22
- It uses **Qwen3-1.7B-Base** with the official **Qwen-Scope residual-stream sparse autoencoders (SAEs)**. The live Hugging Face Space inspects feature activations and performs causal residual-stream interventions; the offline pipeline measures whether predictive features remain predictive on held-out paraphrases and whether manipulating them actually changes downstream behavior.
23
 
24
- This is deliberately **not** a clone of an SAE viewer and does not depend on thesis code or thesis results.
25
 
26
- ## What is different from a feature viewer?
27
 
28
- FeatureLens separates three claims that are often blurred together:
29
 
30
- 1. **Representation:** does an SAE reconstruct the residual stream reasonably well?
31
- 2. **Prediction:** does a feature distinguish a controlled concept on held-out prompts?
32
- 3. **Causation:** does changing that feature coefficient alter downstream token probabilities or generation more than a norm-matched random perturbation?
 
 
 
 
33
 
34
- A feature can score highly on (2) and weakly on (3). FeatureLens treats that as a valid result rather than a failure.
35
 
36
  ## Live workbench
37
 
38
- The Gradio app supports:
39
 
40
- - prompt input and token-level inspection;
41
- - early / middle / late residual layers: **4, 14, 26**;
42
- - strongest TopK SAE features at a selected token;
43
- - reconstruction cosine and normalized MSE;
44
- - feature **ablation**, **scaling**, or **injection**;
45
- - baseline vs modified greedy generation;
46
- - first-token probability table and Jensen-Shannon divergence;
47
- - **live norm-matched random residual control** for every causal edit;
48
- - target-token causal specificity ratio against that matched control;
49
- - **dose-response sweep** over 0×, 0.5×, 1×, 1.5×, 2× and 3× feature scaling;
50
- - **layer trajectory** diagnostics across layers 4, 14 and 26;
51
- - optional target-continuation probability / log-probability delta;
52
- - optional concept hints loaded from real offline benchmark artifacts.
 
53
 
54
- ### Reconstruction-preserving causal edit
55
 
56
- Qwen-Scope provides encoder and decoder weights. Let the original residual be `h`, the selected sparse feature activation be `z_i`, and the feature decoder direction be `d_i`.
57
 
58
- FeatureLens patches the **original** residual:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  ```text
61
  ablate: h' = h - z_i d_i
@@ -63,7 +82,52 @@ scale α: h' = h + (α - 1) z_i d_i
63
  inject δ: h' = h + δ d_i
64
  ```
65
 
66
- It does **not** replace `h` with the SAE reconstruction. This keeps SAE reconstruction error out of the causal perturbation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
  ## Offline experiment
69
 
@@ -71,29 +135,29 @@ The repository ships a controlled benchmark with:
71
 
72
  - **224 discovery prompts** across 7 concepts;
73
  - **112 paraphrase pairs** kept together during splitting;
74
- - concepts: code, mathematics, positive sentiment, negative sentiment, French, factual entities, uncertainty;
75
  - **28 separate causal completion tasks**;
76
- - one forward pass captures all configured residual layers for each batch;
77
- - TopK sparse feature activations and dense residuals are saved separately.
78
 
79
  The evaluation computes:
80
 
81
  - SAE reconstruction cosine / NMSE;
82
- - actual active-feature count / sparsity;
83
  - held-out feature/concept AUROC and F1;
84
  - paraphrase TopK Jaccard and sparse-activation cosine;
85
  - layer-wise multinomial linear probes on dense residual states;
86
  - selected-feature ablation and 2× amplification;
87
- - target first-token probability, log-probability, rank and JS divergence;
88
- - **norm-matched random residual-direction controls**;
89
- - bootstrap 95% confidence intervals for aggregate metrics;
90
- - a paired sign-flip randomization test for SAE-vs-random causal-effect differences.
 
 
91
 
92
- Feature selection uses the **training split**. Held-out AUROC/F1 are reported afterward. Paraphrases from the same pair never cross the train/test boundary.
93
 
94
- ## Run the full benchmark
95
 
96
- A CUDA machine is strongly recommended. The default pipeline downloads Qwen3-1.7B-Base plus the three selected Qwen-Scope layer checkpoints.
97
 
98
  ```bash
99
  python -m venv .venv
@@ -102,6 +166,17 @@ pip install -r requirements.txt
102
  python experiments/run_all.py
103
  ```
104
 
 
 
 
 
 
 
 
 
 
 
 
105
  Outputs are materialized under `artifacts/`:
106
 
107
  ```text
@@ -115,24 +190,21 @@ artifacts/
115
  ├── layer_metrics.csv
116
  ├── stability.csv
117
  ├── causal_results.csv
 
118
  ├── summary.json
119
  ├── report.md
120
  └── figures/
 
 
 
 
121
  ```
122
 
123
- `report.md` and `summary.json` are generated from the measured results. The code contains no fabricated benchmark numbers.
124
 
125
  ## Hugging Face ZeroGPU deployment
126
 
127
- FeatureLens is intentionally a **Gradio SDK Space**, not a Docker Space, because ZeroGPU currently supports Gradio SDK Spaces.
128
-
129
- 1. Create a new **Gradio** Space.
130
- 2. In the Space hardware settings, select **ZeroGPU**.
131
- 3. Push this repository as-is.
132
- 4. No API key is required; both Qwen repositories are public.
133
- 5. On first cold start, the Space downloads Qwen3-1.7B-Base and only SAE layers **4, 14 and 26**, not all 28 SAE files.
134
-
135
- The app uses the `spaces.GPU` decorator when available and falls back to ordinary execution locally. On a Hugging Face Space it eagerly places the model and selected SAEs on the ZeroGPU CUDA-emulation device at startup, matching Hugging Face's recommended loading pattern.
136
 
137
  Useful environment overrides:
138
 
@@ -145,54 +217,38 @@ FEATURELENS_SAE_DTYPE=float16
145
  FEATURELENS_MAX_NEW_TOKENS=32
146
  ```
147
 
148
- For offline benchmarking, use `FEATURELENS_SAE_DTYPE=float32` (the scripts already use FP32 SAE weights by default).
149
 
150
  ## Repository layout
151
 
152
  ```text
153
  FeatureLens/
154
- ├── app.py # Gradio / ZeroGPU workbench
155
  ├── featurelens/
156
- │ ├── config.py # model + layer configuration
157
- │ ├── sae.py # TopK SAE loading / encode / decode
158
- │ ├── interventions.py # causal deltas + random controls
159
- │ ├── runtime.py # hooks, generation, probability deltas
160
- │ ├── metrics.py # reconstruction + divergence metrics
161
- │ ├── stats.py # bootstrap CIs + paired randomization test
162
- ── catalog.py # offline result integration
 
163
  ├── experiments/
164
  │ ├── build_dataset.py
165
  │ ├── collect_activations.py
166
  │ ├── evaluate_features.py
167
  │ ├── run_causal.py
 
168
  │ ├── make_report.py
169
  │ └── run_all.py
170
  ├── data/
171
- │ ├── prompts.jsonl
172
- │ └── causal_tasks.jsonl
173
  ├── tests/
174
  ├── scripts/release_check.py
175
  ├── docs/
176
  └── research_config.json
177
  ```
178
 
179
- ## Methodological limitations
180
-
181
- - Qwen-Scope features are TopK sparse directions, not guaranteed monosemantic concepts.
182
- - The controlled benchmark is intentionally small enough to reproduce on modest research compute; it is not a universal feature ontology.
183
- - The live demo applies one residual edit at one selected prompt token. This is cleaner for causal attribution than repeatedly steering every generated token, but it may produce smaller behavioral effects.
184
- - A target string may tokenize into multiple tokens. The live workbench explicitly labels its target metric as the **first-token** probability in that case.
185
- - A causal effect can depend strongly on prompt, layer, feature scale, and downstream task. The report therefore includes raw per-task rows rather than only aggregate means.
186
-
187
- ## v0.2 deployment hardening
188
-
189
- The Space pins **Gradio 6.24.0** in both the Space metadata and Python dependencies and launches with `ssr_mode=False`. This deliberately avoids the SSR execution path that produced an un-awaited `get_current_user` coroutine warning in the initial deployment while keeping the app fully functional in client-side rendering mode.
190
-
191
- The ZeroGPU callbacks also use shorter declared GPU durations than v0.1 and generation defaults to 16 new tokens, reducing queue cost for ordinary demo usage.
192
-
193
- See [`docs/VALIDATION.md`](docs/VALIDATION.md) for the pre-push and hosted test matrix.
194
-
195
- ## Reproducibility and checks
196
 
197
  ```bash
198
  python -m ruff check app.py featurelens experiments tests scripts
@@ -201,11 +257,22 @@ python -m compileall -q app.py featurelens experiments scripts
201
  python scripts/release_check.py
202
  ```
203
 
 
 
 
 
 
 
 
 
 
 
 
204
  ## Resume-ready description
205
 
206
  > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
207
- > Built an SAE-based interpretability system for Qwen3-1.7B that discovers concept-associated residual features, benchmarks held-out predictiveness and paraphrase stability against linear probes, and causally ablates/amplifies features with norm-matched controls while measuring downstream token-probability and generation shifts.
208
 
209
  ## Acknowledgements
210
 
211
- FeatureLens builds on the open Qwen3 model and Qwen-Scope SAE checkpoints from the Qwen team. See the upstream model cards and Qwen-Scope technical report for model and SAE details and licensing requirements.
 
1
  ---
2
  title: FeatureLens
3
  emoji: 🔬
4
+ colorFrom: gray
5
+ colorTo: green
6
  sdk: gradio
7
  python_version: "3.12.12"
8
  sdk_version: "6.24.0"
 
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
+ > **v0.3:** full-continuation causal scoring, joint sparse feature-set interventions, batched 1/3/5-feature ablation sweeps, paraphrase-robustness inspection, and a calmer low-saturation UI.
17
 
18
  **FeatureLens asks one concrete question:**
19
 
20
  > Do sparse features that predict a concept also causally influence model behaviour?
21
 
22
+ It uses **Qwen3-1.7B-Base** with the official **Qwen-Scope residual-stream sparse autoencoders (SAEs)**. The live Hugging Face Space inspects sparse residual features and performs controlled interventions; the offline pipeline measures held-out predictiveness, paraphrase stability, single-feature causality, and whether causal influence is distributed across small sparse feature sets.
23
 
24
+ FeatureLens is deliberately **not** an SAE viewer clone and is independent of thesis code and thesis datasets.
25
 
26
+ ## Evidence ladder
27
 
28
+ FeatureLens keeps several claims separate:
29
 
30
+ 1. **Reconstruction** does the SAE represent the residual stream reasonably well?
31
+ 2. **Prediction** does a feature distinguish a controlled concept on held-out paraphrase groups?
32
+ 3. **Robustness** does a sparse representation survive a paraphrase?
33
+ 4. **Single-feature causality** — does changing one feature alter downstream behaviour?
34
+ 5. **Feature-set causality** — does jointly editing a sparse subspace reveal distributed influence?
35
+ 6. **Specificity** — is the effect larger than a norm-matched random residual perturbation?
36
+ 7. **Dose-response** — does the behavioural effect change coherently as a feature coefficient is varied?
37
 
38
+ A feature can be predictive but weakly causal. A feature set can also fail to outperform a matched random perturbation. Both are valid experimental outcomes.
39
 
40
  ## Live workbench
41
 
42
+ The Gradio Space supports:
43
 
44
+ - prompt input with an explicit **Prompt tokens** view and selected-token highlight;
45
+ - residual layers **4, 14, 26**;
46
+ - strongest active TopK SAE features and reconstruction diagnostics;
47
+ - feature **ablation**, **scaling**, and **injection**;
48
+ - baseline vs SAE-edited greedy generation;
49
+ - next-token probability shifts and Jensen-Shannon divergence;
50
+ - **full-continuation teacher-forced scoring**, including per-token log-probability rows;
51
+ - deterministic **norm-matched random controls**;
52
+ - a batched single-feature scale dose-response sweep;
53
+ - joint multi-feature ablation/scaling;
54
+ - a batched **1 / 3 / 5 strongest-feature joint-ablation sweep**;
55
+ - an interactive **paraphrase robustness** comparison;
56
+ - early/middle/late representation trajectories;
57
+ - optional empirical feature hints loaded only from real offline artifacts.
58
 
59
+ ### Why full-continuation scoring matters
60
 
61
+ v0.2 measured the first token of a user-supplied target. That is insufficient for a continuation such as `2x` when it tokenizes into multiple tokens.
62
 
63
+ v0.3 concatenates the exact target token IDs to the prompt and scores every target token teacher-forced. It reports:
64
+
65
+ - total target sequence log probability;
66
+ - **mean target log probability per token**;
67
+ - SAE-edit deltas;
68
+ - norm-matched random-control deltas;
69
+ - a token-by-token decomposition.
70
+
71
+ Mean log probability per target token is the primary length-comparable causal metric used by the v0.3 offline report.
72
+
73
+ Greedy text may remain unchanged even when these probability-level metrics move. That is expected: deterministic generation changes only after an edit moves a different token across the argmax boundary.
74
+
75
+ ## Reconstruction-preserving causal edits
76
+
77
+ Let the original residual be `h`, sparse activation `z_i`, and decoder direction `d_i`.
78
 
79
  ```text
80
  ablate: h' = h - z_i d_i
 
82
  inject δ: h' = h + δ d_i
83
  ```
84
 
85
+ FeatureLens patches the **original** residual. It does not replace `h` with the complete SAE reconstruction, so SAE reconstruction error is not introduced as a causal confound.
86
+
87
+ For a feature set `S`, ablation/scaling deltas are summed before patching:
88
+
89
+ ```text
90
+ h' = h + Σ_i∈S Δz_i d_i
91
+ ```
92
+
93
+ `inject` is intentionally excluded from multi-feature sets because one shared additive coefficient is not naturally comparable across unrelated decoder directions.
94
+
95
+ ## Batched causal sweeps
96
+
97
+ The stronger v0.3 experiments are designed not to multiply ZeroGPU round-trips unnecessarily.
98
+
99
+ ### Single-feature scale dose-response
100
+
101
+ The UI evaluates:
102
+
103
+ ```text
104
+ 0×, 0.5×, 1×, 1.5×, 2×, 3×
105
+ ```
106
+
107
+ where **0× = ablation** and **1× = no intervention**. All six residual edits are stacked and evaluated in one model forward after the baseline.
108
+
109
+ ### Feature-set size sweep
110
+
111
+ The live and offline experiments jointly ablate the strongest active / concept-selected features at:
112
+
113
+ ```text
114
+ k = 1, 3, 5
115
+ ```
116
+
117
+ Each targeted edit is paired with a random residual perturbation of identical L2 norm. The edited and control conditions are batched together after one baseline forward.
118
+
119
+ This directly tests a plausible failure mode of single-feature interpretability: a concept may be represented across a small sparse subspace rather than one SAE unit.
120
+
121
+ ## Paraphrase robustness explorer
122
+
123
+ The **Paraphrase robustness** tab compares the same residual layer for an original prompt and a manually supplied rewording. It reports:
124
+
125
+ - full TopK feature-set Jaccard;
126
+ - sparse activation cosine without densifying the 32,768-wide SAE vector;
127
+ - shared / original-only / paraphrase-only displayed features;
128
+ - side-by-side activation bars.
129
+
130
+ High overlap is robustness evidence, not a semantic proof about any individual feature.
131
 
132
  ## Offline experiment
133
 
 
135
 
136
  - **224 discovery prompts** across 7 concepts;
137
  - **112 paraphrase pairs** kept together during splitting;
138
+ - code, mathematics, positive sentiment, negative sentiment, French, factual entities, uncertainty;
139
  - **28 separate causal completion tasks**;
140
+ - residual and SAE activation collection at layers 4, 14 and 26.
 
141
 
142
  The evaluation computes:
143
 
144
  - SAE reconstruction cosine / NMSE;
 
145
  - held-out feature/concept AUROC and F1;
146
  - paraphrase TopK Jaccard and sparse-activation cosine;
147
  - layer-wise multinomial linear probes on dense residual states;
148
  - selected-feature ablation and 2× amplification;
149
+ - exact full-target sequence and mean-per-token log probabilities;
150
+ - next-token probability/rank, JS divergence, and top-1 changes;
151
+ - norm-matched random residual controls;
152
+ - **top-1 / top-3 / top-5 same-layer concept-feature joint ablations**;
153
+ - bootstrap 95% confidence intervals;
154
+ - paired sign-flip tests for SAE-vs-control effect differences.
155
 
156
+ Feature selection uses only the **training split**. Held-out AUROC/F1 are reported afterward. Paraphrases from the same pair never cross the train/test boundary.
157
 
158
+ ## Run the benchmark
159
 
160
+ A CUDA machine is strongly recommended.
161
 
162
  ```bash
163
  python -m venv .venv
 
166
  python experiments/run_all.py
167
  ```
168
 
169
+ The pipeline runs:
170
+
171
+ ```text
172
+ build_dataset
173
+ → collect_activations
174
+ → evaluate_features
175
+ → run_causal
176
+ → run_feature_sets
177
+ → make_report
178
+ ```
179
+
180
  Outputs are materialized under `artifacts/`:
181
 
182
  ```text
 
190
  ├── layer_metrics.csv
191
  ├── stability.csv
192
  ├── causal_results.csv
193
+ ├── feature_set_results.csv
194
  ├── summary.json
195
  ├── report.md
196
  └── figures/
197
+ ├── feature_auroc.png
198
+ ├── layer_diagnostics.png
199
+ ├── causal_effects.png
200
+ └── feature_set_effects.png
201
  ```
202
 
203
+ `report.md` and `summary.json` are generated from measured results. The repository contains no fabricated benchmark numbers.
204
 
205
  ## Hugging Face ZeroGPU deployment
206
 
207
+ FeatureLens is a **Gradio SDK Space**. The live demo only needs Qwen3-1.7B-Base plus SAE layers **4, 14, 26** rather than all 28 layer checkpoints.
 
 
 
 
 
 
 
 
208
 
209
  Useful environment overrides:
210
 
 
217
  FEATURELENS_MAX_NEW_TOKENS=32
218
  ```
219
 
220
+ The app uses the `spaces.GPU` decorator when available and falls back to ordinary local execution. SSR is explicitly disabled in `app.py` to avoid the earlier Gradio auth-coroutine warning observed during v0.2 deployment testing.
221
 
222
  ## Repository layout
223
 
224
  ```text
225
  FeatureLens/
226
+ ├── app.py
227
  ├── featurelens/
228
+ │ ├── config.py
229
+ │ ├── sae.py
230
+ │ ├── interventions.py
231
+ │ ├── runtime.py
232
+ │ ├── metrics.py
233
+ │ ├── stats.py
234
+ ── selection.py
235
+ │ └── catalog.py
236
  ├── experiments/
237
  │ ├── build_dataset.py
238
  │ ├── collect_activations.py
239
  │ ├── evaluate_features.py
240
  │ ├── run_causal.py
241
+ │ ├── run_feature_sets.py
242
  │ ├── make_report.py
243
  │ └── run_all.py
244
  ├── data/
 
 
245
  ├── tests/
246
  ├── scripts/release_check.py
247
  ├── docs/
248
  └── research_config.json
249
  ```
250
 
251
+ ## Validation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
 
253
  ```bash
254
  python -m ruff check app.py featurelens experiments tests scripts
 
257
  python scripts/release_check.py
258
  ```
259
 
260
+ See [`docs/VALIDATION.md`](docs/VALIDATION.md) for the exact human HF smoke tests. The names in that document match the actual labels in the v0.3 UI.
261
+
262
+ ## Methodological limitations
263
+
264
+ - Qwen-Scope features are sparse directions, not guaranteed monosemantic concepts.
265
+ - A controlled seven-concept benchmark is not a universal feature ontology.
266
+ - Similar TopK sets under paraphrasing do not prove identical semantic meaning.
267
+ - Joint feature ablation can create a larger residual perturbation as `k` increases; norm-matched controls are therefore essential.
268
+ - Full target scoring is teacher-forced: it measures how the edit changes probability assigned to a specified continuation, not free-running sequence probability under sampled generation.
269
+ - Causal effects remain prompt-, layer-, token-, scale-, and task-dependent.
270
+
271
  ## Resume-ready description
272
 
273
  > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
274
+ > Built an SAE-based interpretability system for Qwen3-1.7B that discovers held-out concept-associated residual features, measures paraphrase stability against dense probes, and causally tests single features and sparse feature sets with full-continuation scoring, dose-response analysis, and norm-matched controls.
275
 
276
  ## Acknowledgements
277
 
278
+ FeatureLens builds on the open Qwen3 model and Qwen-Scope residual-stream SAE checkpoints from the Qwen team.
app.py CHANGED
@@ -7,24 +7,63 @@ from featurelens.config import SETTINGS
7
  from featurelens.hf_runtime import gpu
8
  from featurelens.runtime import RUNTIME
9
 
 
 
 
 
 
 
 
10
  CSS = """
11
  .gradio-container { max-width: 1320px !important; }
12
  .hero { padding: 8px 2px 2px; }
13
  .hero h1 { margin: 0; font-size: 2.35rem; letter-spacing: -0.045em; }
14
  .hero p { margin: .35rem 0 0; opacity: .72; font-size: 1rem; }
15
- .research-q { border-left: 4px solid var(--primary-500); padding: 10px 14px; margin: 10px 0 14px; border-radius: 0 10px 10px 0; background: var(--background-fill-secondary); }
 
 
 
 
 
 
16
  .badges { display:flex; flex-wrap:wrap; gap:7px; margin:8px 0 3px; }
17
- .badge { border:1px solid var(--border-color-primary); background:var(--background-fill-secondary); border-radius:999px; padding:4px 9px; font-size:12px; }
18
- .step { font-size: .78rem; text-transform: uppercase; letter-spacing:.09em; opacity:.62; font-weight:700; margin-top:2px; }
19
- .token-wrap { display:flex; flex-wrap:wrap; gap:5px; padding:8px 2px; line-height:1.7; }
20
- .token { background:var(--background-fill-secondary); border:1px solid var(--border-color-primary); border-radius:7px; padding:2px 7px; font-family:ui-monospace,SFMono-Regular,monospace; font-size:12px; }
21
- .token.selected { border:2px solid var(--primary-500); color:var(--primary-600); font-weight:600; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  .token sup { opacity:.55; margin-right:4px; }
23
- .small-note { opacity:.65; font-size:12px; }
24
- .callout { border:1px solid var(--border-color-primary); background:var(--background-fill-secondary); border-radius:12px; padding:10px 12px; }
 
 
 
 
 
 
25
  """
26
 
27
- THEME = gr.themes.Soft(primary_hue="blue", neutral_hue="slate")
28
 
29
 
30
  def _raise_ui_error(exc: Exception) -> None:
@@ -33,7 +72,8 @@ def _raise_ui_error(exc: Exception) -> None:
33
 
34
  def _analysis_metrics_markdown(result) -> str:
35
  return (
36
- f"**Layer {result.layer} · token {result.token_index}** \n"
 
37
  f"Active SAE features: **{int(result.metrics['active_features'])}/{SETTINGS.sae_top_k}** \n"
38
  f"Reconstruction cosine: **{result.metrics['cosine']:.4f}** · "
39
  f"NMSE: **{result.metrics['nmse']:.4f}** \n"
@@ -42,27 +82,27 @@ def _analysis_metrics_markdown(result) -> str:
42
 
43
 
44
  def _intervention_metrics_markdown(result) -> str:
45
- target = "No target continuation supplied — specificity is shown using JS divergence only."
46
- if result.baseline_target_prob is not None:
47
- warning = ""
48
- if result.target_token_count > 1:
49
- warning = (
50
- f" \n_Note: target text tokenizes to {result.target_token_count} tokens; "
51
- "the displayed causal metric is for its first token._"
52
- )
53
  target = (
54
- f"Target first token: `{result.target_token}` \n"
55
- f"Baseline p: **{result.baseline_target_prob:.6f}** · "
56
- f"SAE edit p: **{result.modified_target_prob:.6f}** · "
57
- f"Random-control p: **{result.random_target_prob:.6f}** \n"
58
- f"SAE Δlog p: **{result.target_logprob_delta:+.4f}** · "
59
- f"random Δlog p: **{result.random_target_logprob_delta:+.4f}** · "
60
- f"specificity ratio: **{result.target_specificity_ratio:.2f}×**{warning}"
 
 
61
  )
62
  inactive = ""
63
  if abs(result.feature_activation) < 1e-12:
64
  inactive = (
65
- " \n⚠️ **Selected feature is inactive at this token.** Ablate/scale therefore produces "
66
  "a zero feature delta; use `inject` to test the decoder direction directly."
67
  )
68
  return (
@@ -71,23 +111,59 @@ def _intervention_metrics_markdown(result) -> str:
71
  f"Perturbation L2: **{result.perturbation_norm:.4f}** \n"
72
  f"Next-token JS: **{result.js_divergence:.6f}** · "
73
  f"random-control JS: **{result.random_js_divergence:.6f}** · "
74
- f"specificity: **{result.js_specificity_ratio:.2f}×** \n\n"
75
- f"{target}{inactive}"
 
 
76
  )
77
 
78
 
79
  def _dose_metrics_markdown(result) -> str:
80
- note = ""
81
- if result.target_token_count > 1:
82
- note = (
83
- f" Target text spans {result.target_token_count} tokens; the curve measures its first token."
84
- )
85
  inactive = ""
86
  if abs(result.feature_activation) < 1e-12:
87
- inactive = " **The feature is inactive here, so a multiplicative sweep is flat by construction.**"
88
  return (
89
  f"Feature activation at baseline: **{result.feature_activation:.4f}** · "
90
- f"target first token: `{result.target_token}`.{note}{inactive}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  )
92
 
93
 
@@ -99,10 +175,15 @@ def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
99
  result = RUNTIME.analyze(prompt, int(layer), int(token_index), int(top_n))
100
  choices = [str(int(row[1])) for row in result.rows]
101
  feature_update = gr.update(choices=choices, value=choices[0] if choices else None)
 
 
 
 
102
  chart_df = pd.DataFrame(
103
  {
104
  "Feature": [str(int(row[1])) for row in result.rows],
105
  "Activation": [float(row[2]) for row in result.rows],
 
106
  }
107
  )
108
  return (
@@ -110,6 +191,7 @@ def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
110
  result.rows,
111
  chart_df,
112
  feature_update,
 
113
  _analysis_metrics_markdown(result),
114
  )
115
  except Exception as exc:
@@ -148,12 +230,13 @@ def run_intervention(
148
  result.modified_text,
149
  _intervention_metrics_markdown(result),
150
  result.top_token_rows,
 
151
  )
152
  except Exception as exc:
153
  _raise_ui_error(exc)
154
 
155
 
156
- @gpu(duration=40)
157
  def run_dose_response(
158
  prompt: str,
159
  layer: int,
@@ -167,7 +250,7 @@ def run_dose_response(
167
  if feature_id is None or str(feature_id).strip() == "":
168
  raise ValueError("Choose or enter a feature id.")
169
  if not target_text.strip():
170
- raise ValueError("Enter a target continuation before running a dose-response sweep.")
171
  result = RUNTIME.dose_response(
172
  text=prompt,
173
  layer=int(layer),
@@ -179,13 +262,15 @@ def run_dose_response(
179
  "Multiplier",
180
  "Δ feature coefficient",
181
  "Perturbation L2",
182
- "Baseline p(target)",
183
- "Modified p(target)",
184
- "Δ log p(target)",
185
- "JS divergence",
 
186
  ]
187
  table = pd.DataFrame(result.rows, columns=columns)
188
- plot = table[["Multiplier", "Δ log p(target)"]].copy()
 
189
  return table, plot, _dose_metrics_markdown(result)
190
  except Exception as exc:
191
  _raise_ui_error(exc)
@@ -218,6 +303,116 @@ def run_layer_sweep(prompt: str, token_index: int):
218
  _raise_ui_error(exc)
219
 
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  def mode_help(mode: str):
222
  if mode == "ablate":
223
  return gr.update(
@@ -241,20 +436,37 @@ def mode_help(mode: str):
241
  )
242
 
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as demo:
245
  gr.HTML(
246
- '<div class="hero"><h1>FeatureLens <span style="font-size:.42em;opacity:.55">v0.2</span></h1>'
247
  '<p>Causal sparse-feature interpretability for Qwen3-1.7B — inspect → intervene → control → measure.</p></div>'
248
  '<div class="research-q"><b>Research question:</b> Do sparse features that predict a concept also '
249
  'causally influence the model’s behaviour?</div>'
250
  '<div class="badges">'
251
  '<span class="badge">Qwen3-1.7B-Base</span><span class="badge">Qwen-Scope SAE</span>'
252
  '<span class="badge">32,768 features</span><span class="badge">TopK=50</span>'
 
253
  '<span class="badge">norm-matched controls</span><span class="badge">ZeroGPU</span></div>'
254
  )
255
 
256
  with gr.Tab("Workbench"):
257
- gr.HTML('<div class="step">Step 1 · Choose a prompt and residual location</div>')
258
  with gr.Row(equal_height=False):
259
  with gr.Column(scale=5):
260
  prompt = gr.Textbox(
@@ -284,49 +496,54 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
284
  value=-1,
285
  precision=0,
286
  label="Prompt token index",
287
- info="-1 = final prompt token. Inspect once to see all token positions.",
288
  )
289
- top_n = gr.Slider(5, 20, value=12, step=1, label="Top features")
290
  analyze_btn = gr.Button("Inspect sparse features", variant="primary")
291
 
292
- token_view = gr.HTML('<div class="small-note">Token positions appear here after inspection.</div>')
 
293
  analysis_metrics = gr.Markdown()
294
  with gr.Row(equal_height=False):
295
  feature_table = gr.Dataframe(
296
  headers=["Rank", "Feature id", "Activation", "Offline concept hint"],
297
  datatype=["number", "number", "number", "str"],
298
  interactive=False,
299
- label="Strongest TopK features",
300
  wrap=True,
301
  scale=3,
302
  )
303
  feature_plot = gr.BarPlot(
304
  x="Feature",
305
  y="Activation",
 
 
306
  title="Activation profile",
307
  x_title="Feature id",
308
  y_title="Activation",
 
 
309
  scale=2,
310
  )
311
 
312
- gr.HTML('<div class="step">Step 2 · Intervene and compare against a matched control</div>')
313
  gr.Markdown(
314
- "FeatureLens changes only the selected SAE coefficient and adds that decoder-vector delta "
315
- "to the **original** residual. The live negative control applies a deterministic random "
316
- "residual perturbation with the **same L2 norm**."
317
  )
318
  with gr.Row(equal_height=False):
319
  with gr.Column(scale=2):
320
  feature_id = gr.Dropdown(
321
  choices=[],
322
  allow_custom_value=True,
323
- label="Feature id",
324
  info="Inspection populates the strongest active features; custom IDs are also allowed.",
325
  )
326
  mode = gr.Radio(
327
  choices=["ablate", "scale", "inject"],
328
  value="ablate",
329
- label="Intervention",
330
  )
331
  coefficient = gr.Number(
332
  value=0.0,
@@ -334,73 +551,220 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
334
  label="Coefficient (unused for ablation)",
335
  )
336
  target_text = gr.Textbox(
337
- label="Optional target continuation",
338
  placeholder="e.g. 2x",
339
- info="Supplying this enables target-token Δlog p and causal-specificity metrics.",
 
 
 
340
  )
341
  max_new = gr.Slider(
342
  4,
343
  SETTINGS.max_new_tokens,
344
- value=min(16, SETTINGS.max_new_tokens),
345
  step=1,
346
- label="Max new tokens",
347
  )
348
- intervene_btn = gr.Button("Run causal test", variant="primary")
349
  intervention_metrics = gr.Markdown()
350
  with gr.Column(scale=3):
351
  with gr.Row():
352
- baseline_out = gr.Textbox(label="Baseline generation", lines=7, interactive=False)
353
- modified_out = gr.Textbox(label="SAE-intervened generation", lines=7, interactive=False)
354
  token_prob_table = gr.Dataframe(
355
  headers=["Token", "Baseline p", "SAE-edit p", "Δ probability"],
356
  datatype=["str", "number", "number", "number"],
357
  interactive=False,
358
  label="Next-token distribution shift",
359
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
 
361
- gr.HTML('<div class="step">Step 3 · Test doseresponse</div>')
362
- with gr.Accordion("Causal doseresponse sweep", open=False):
363
  gr.Markdown(
364
- "A single coefficient can be cherry-picked. This sweep scales the same active feature "
365
- "through **0×, 0.5×, 1×, 1.5×, 2× and 3×** and measures target-token Δlog p. "
366
- "A coherent monotonic response is stronger causal evidence than one isolated edit."
367
  )
368
- dose_btn = gr.Button("Run doseresponse sweep")
369
  dose_metrics = gr.Markdown()
370
  with gr.Row():
371
- dose_table = gr.Dataframe(interactive=False, label="Dose-response measurements", scale=3)
372
  dose_plot = gr.LinePlot(
373
  x="Multiplier",
374
- y="Δ log p(target)",
375
- title="Causal dose–response",
 
 
376
  x_title="Feature multiplier",
377
- y_title="Δ log p(target)",
 
378
  scale=2,
379
  )
380
 
381
- analyze_btn.click(
382
- analyze_prompt,
383
- inputs=[prompt, layer, token_index, top_n],
384
- outputs=[token_view, feature_table, feature_plot, feature_id, analysis_metrics],
 
 
 
 
 
 
 
 
 
 
385
  )
386
- mode.change(mode_help, inputs=[mode], outputs=[coefficient])
387
- intervene_btn.click(
388
- run_intervention,
389
- inputs=[prompt, layer, token_index, feature_id, mode, coefficient, target_text, max_new],
390
- outputs=[baseline_out, modified_out, intervention_metrics, token_prob_table],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  )
392
- dose_btn.click(
393
- run_dose_response,
394
- inputs=[prompt, layer, token_index, feature_id, target_text],
395
- outputs=[dose_table, dose_plot, dose_metrics],
396
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
 
398
  with gr.Tab("Layer trajectory"):
399
  gr.Markdown(
400
- "### Follow the representation across early, middle and late residual streams\n"
401
  "This is **not** a cross-layer feature-ID comparison — SAE dictionaries are layer-specific. "
402
- "Instead, it compares reconstruction quality and sparsity/concentration statistics at the "
403
- "same prompt token across layers 4, 14 and 26."
404
  )
405
  with gr.Row():
406
  trajectory_prompt = gr.Textbox(
@@ -417,6 +781,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
417
  scale=1,
418
  )
419
  trajectory_btn = gr.Button("Compare layers", variant="primary")
 
420
  trajectory_tokens = gr.HTML()
421
  with gr.Row():
422
  trajectory_table = gr.Dataframe(interactive=False, label="Layer diagnostics", scale=3)
@@ -424,24 +789,26 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
424
  x="Layer",
425
  y="Value",
426
  color="Metric",
 
 
 
 
 
427
  title="Representation trajectory",
428
  x_title="Layer",
429
  y_title="Normalized value",
 
430
  scale=2,
431
  )
432
- trajectory_btn.click(
433
- run_layer_sweep,
434
- inputs=[trajectory_prompt, trajectory_token],
435
- outputs=[trajectory_tokens, trajectory_table, trajectory_plot],
436
- )
437
 
438
  with gr.Tab("Offline benchmark"):
439
  gr.Markdown(RUNTIME.catalog.benchmark_markdown())
440
  gr.Markdown(
441
  "The offline pipeline evaluates held-out feature/concept AUROC + F1, reconstruction quality, "
442
- "paraphrase stability, dense residual linear probes, causal ablation/amplification and "
443
- "norm-matched random-direction controls. Results are loaded from `artifacts/`; the app never "
444
- "ships invented benchmark numbers."
 
445
  )
446
 
447
  with gr.Tab("Method"):
@@ -449,24 +816,36 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
449
  r"""
450
  ### Reconstruction-preserving intervention
451
 
452
- For residual vector $h$, selected sparse coefficient $z_i$, decoder direction $d_i$, and scale $\alpha$:
453
 
454
  - **Ablate:** $h' = h - z_i d_i$
455
  - **Scale:** $h' = h + (\alpha - 1) z_i d_i$
456
  - **Inject:** $h' = h + \delta d_i$
457
 
458
- The app edits the original residual rather than replacing it with the full SAE reconstruction, so SAE
459
- reconstruction error is not introduced as a causal confound.
 
 
 
 
 
 
 
 
 
 
460
 
461
  ### Evidence ladder
462
 
463
  1. **Reconstruction:** does the SAE represent the residual reasonably well?
464
  2. **Prediction:** does a feature predict a controlled concept on held-out paraphrase groups?
465
- 3. **Intervention:** does changing it alter downstream behaviour?
466
- 4. **Specificity:** is that effect larger than a norm-matched random residual perturbation?
467
- 5. **Dose-response:** does effect size change coherently as the feature coefficient is varied?
 
 
468
 
469
- A high AUROC alone remains correlational evidence.
470
  """
471
  )
472
 
@@ -475,8 +854,60 @@ A high AUROC alone remains correlational evidence.
475
  "FeatureLens is independent of thesis code and thesis datasets.</small>"
476
  )
477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  if __name__ == "__main__":
479
- # Explicit CSR avoids an HF/Gradio SSR auth-coroutine warning seen in 6.23.x-era Space builds.
480
  demo.queue(default_concurrency_limit=1, max_size=8).launch(
481
  css=CSS,
482
  theme=THEME,
 
7
  from featurelens.hf_runtime import gpu
8
  from featurelens.runtime import RUNTIME
9
 
10
+ # Muted, low-saturation palette chosen to stay readable in light and dark UI modes.
11
+ MUTED_TEAL = "#6F8F8B"
12
+ MUTED_OCHRE = "#B08D57"
13
+ MUTED_RED = "#A66B6B"
14
+ MUTED_PURPLE = "#8B7EA8"
15
+ MUTED_STONE = "#A59E93"
16
+
17
  CSS = """
18
  .gradio-container { max-width: 1320px !important; }
19
  .hero { padding: 8px 2px 2px; }
20
  .hero h1 { margin: 0; font-size: 2.35rem; letter-spacing: -0.045em; }
21
  .hero p { margin: .35rem 0 0; opacity: .72; font-size: 1rem; }
22
+ .research-q {
23
+ border-left: 4px solid #6F8F8B;
24
+ padding: 10px 14px;
25
+ margin: 10px 0 14px;
26
+ border-radius: 0 10px 10px 0;
27
+ background: var(--background-fill-secondary);
28
+ }
29
  .badges { display:flex; flex-wrap:wrap; gap:7px; margin:8px 0 3px; }
30
+ .badge {
31
+ border:1px solid var(--border-color-primary);
32
+ background:var(--background-fill-secondary);
33
+ border-radius:999px;
34
+ padding:4px 9px;
35
+ font-size:12px;
36
+ }
37
+ .step {
38
+ font-size: .78rem;
39
+ text-transform: uppercase;
40
+ letter-spacing:.09em;
41
+ opacity:.66;
42
+ font-weight:700;
43
+ margin-top:2px;
44
+ }
45
+ .token-wrap { display:flex; flex-wrap:wrap; gap:5px; padding:6px 2px 10px; line-height:1.7; }
46
+ .token {
47
+ background:var(--background-fill-secondary);
48
+ border:1px solid var(--border-color-primary);
49
+ border-radius:7px;
50
+ padding:2px 7px;
51
+ font-family:ui-monospace,SFMono-Regular,monospace;
52
+ font-size:12px;
53
+ }
54
+ .token.selected { border:2px solid #7FA39F; font-weight:650; }
55
  .token sup { opacity:.55; margin-right:4px; }
56
+ .small-note { opacity:.68; font-size:12px; }
57
+ .callout {
58
+ border:1px solid var(--border-color-primary);
59
+ background:var(--background-fill-secondary);
60
+ border-radius:12px;
61
+ padding:10px 12px;
62
+ }
63
+ .metric-note { opacity:.78; }
64
  """
65
 
66
+ THEME = gr.themes.Soft(primary_hue="teal", secondary_hue="stone", neutral_hue="stone")
67
 
68
 
69
  def _raise_ui_error(exc: Exception) -> None:
 
72
 
73
  def _analysis_metrics_markdown(result) -> str:
74
  return (
75
+ "#### Analysis metrics\n"
76
+ f"**Layer {result.layer} · prompt token {result.token_index}** \n"
77
  f"Active SAE features: **{int(result.metrics['active_features'])}/{SETTINGS.sae_top_k}** \n"
78
  f"Reconstruction cosine: **{result.metrics['cosine']:.4f}** · "
79
  f"NMSE: **{result.metrics['nmse']:.4f}** \n"
 
82
 
83
 
84
  def _intervention_metrics_markdown(result) -> str:
85
+ target = (
86
+ "No target continuation supplied. The causal comparison therefore uses only "
87
+ "next-token Jensen-Shannon divergence."
88
+ )
89
+ if result.baseline_sequence_logprob is not None:
90
+ tokens = " ".join(repr(token) for token in result.target_tokens)
 
 
91
  target = (
92
+ f"**Target continuation:** {result.target_token_count} token(s): {tokens} \n"
93
+ f"Sequence log p — baseline: **{result.baseline_sequence_logprob:.4f}** · "
94
+ f"SAE edit: **{result.modified_sequence_logprob:.4f}** · "
95
+ f"random control: **{result.random_sequence_logprob:.4f}** \n"
96
+ f"SAE Δ sequence log p: **{result.sequence_logprob_delta:+.4f}** · "
97
+ f"random Δ: **{result.random_sequence_logprob_delta:+.4f}** \n"
98
+ f"SAE Δ mean log p/token: **{result.mean_logprob_delta:+.4f}** · "
99
+ f"random Δ: **{result.random_mean_logprob_delta:+.4f}** · "
100
+ f"specificity ratio: **{result.target_specificity_ratio:.2f}×**"
101
  )
102
  inactive = ""
103
  if abs(result.feature_activation) < 1e-12:
104
  inactive = (
105
+ " \n⚠️ **Selected feature is inactive at this prompt token.** Ablate/scale produces "
106
  "a zero feature delta; use `inject` to test the decoder direction directly."
107
  )
108
  return (
 
111
  f"Perturbation L2: **{result.perturbation_norm:.4f}** \n"
112
  f"Next-token JS: **{result.js_divergence:.6f}** · "
113
  f"random-control JS: **{result.random_js_divergence:.6f}** · "
114
+ f"JS specificity: **{result.js_specificity_ratio:.2f}×** \n\n"
115
+ f"{target}{inactive} \n\n"
116
+ "_Greedy generations can remain identical even when probability-level causal metrics move; "
117
+ "an edit must cross an argmax boundary before deterministic text changes._"
118
  )
119
 
120
 
121
  def _dose_metrics_markdown(result) -> str:
122
+ tokens = " ".join(repr(token) for token in result.target_tokens)
 
 
 
 
123
  inactive = ""
124
  if abs(result.feature_activation) < 1e-12:
125
+ inactive = " **The feature is inactive here, so multiplicative scaling is flat by construction.**"
126
  return (
127
  f"Feature activation at baseline: **{result.feature_activation:.4f}** · "
128
+ f"target continuation: {len(result.target_tokens)} token(s): {tokens}.{inactive} \n\n"
129
+ "This panel is **always a scale intervention**: 0× = ablation, 1× = no edit, "
130
+ "2× = double the original coefficient. It does not use the intervention radio above."
131
+ )
132
+
133
+
134
+ def _feature_set_metrics_markdown(result) -> str:
135
+ tokens = " ".join(repr(token) for token in result.target_tokens)
136
+ inactive_count = sum(abs(float(row[1])) < 1e-12 for row in result.feature_rows)
137
+ inactive_note = (
138
+ f" \n{inactive_count} selected feature(s) were inactive and therefore contributed zero "
139
+ "delta under ablation/scale."
140
+ if inactive_count
141
+ else ""
142
+ )
143
+ return (
144
+ f"Selected feature set: **{len(result.feature_ids)} features** · "
145
+ f"perturbation L2: **{result.perturbation_norm:.4f}** \n"
146
+ f"Target continuation: {len(result.target_tokens)} token(s): {tokens} \n"
147
+ f"SAE Δ mean log p/token: **{result.mean_logprob_delta:+.4f}** · "
148
+ f"random Δ: **{result.random_mean_logprob_delta:+.4f}** · "
149
+ f"specificity: **{result.target_specificity_ratio:.2f}×** \n"
150
+ f"SAE Δ sequence log p: **{result.sequence_logprob_delta:+.4f}** · "
151
+ f"random Δ: **{result.random_sequence_logprob_delta:+.4f}** \n"
152
+ f"Next-token JS: **{result.js_divergence:.6f}** · "
153
+ f"random-control JS: **{result.random_js_divergence:.6f}** · "
154
+ f"JS specificity: **{result.js_specificity_ratio:.2f}×**{inactive_note}"
155
+ )
156
+
157
+
158
+ def _paraphrase_metrics_markdown(result) -> str:
159
+ return (
160
+ "#### Robustness metrics\n"
161
+ f"Full TopK feature-set Jaccard: **{result.topk_jaccard:.3f}** \n"
162
+ f"Sparse activation cosine: **{result.sparse_cosine:.3f}** \n"
163
+ f"Shared features among displayed top-{result.top_n}: "
164
+ f"**{result.shared_top_n}/{result.top_n}** \n\n"
165
+ "Higher values mean the sparse representation is more stable under the supplied paraphrase; "
166
+ "they do **not** prove the shared features have identical semantics."
167
  )
168
 
169
 
 
175
  result = RUNTIME.analyze(prompt, int(layer), int(token_index), int(top_n))
176
  choices = [str(int(row[1])) for row in result.rows]
177
  feature_update = gr.update(choices=choices, value=choices[0] if choices else None)
178
+ feature_set_update = gr.update(
179
+ choices=choices,
180
+ value=choices[: min(3, len(choices))],
181
+ )
182
  chart_df = pd.DataFrame(
183
  {
184
  "Feature": [str(int(row[1])) for row in result.rows],
185
  "Activation": [float(row[2]) for row in result.rows],
186
+ "Series": ["Activation"] * len(result.rows),
187
  }
188
  )
189
  return (
 
191
  result.rows,
192
  chart_df,
193
  feature_update,
194
+ feature_set_update,
195
  _analysis_metrics_markdown(result),
196
  )
197
  except Exception as exc:
 
230
  result.modified_text,
231
  _intervention_metrics_markdown(result),
232
  result.top_token_rows,
233
+ result.target_token_rows,
234
  )
235
  except Exception as exc:
236
  _raise_ui_error(exc)
237
 
238
 
239
+ @gpu(duration=35)
240
  def run_dose_response(
241
  prompt: str,
242
  layer: int,
 
250
  if feature_id is None or str(feature_id).strip() == "":
251
  raise ValueError("Choose or enter a feature id.")
252
  if not target_text.strip():
253
+ raise ValueError("Enter a target continuation before running the scale dose-response.")
254
  result = RUNTIME.dose_response(
255
  text=prompt,
256
  layer=int(layer),
 
262
  "Multiplier",
263
  "Δ feature coefficient",
264
  "Perturbation L2",
265
+ "Baseline mean log p/token",
266
+ "Modified mean log p/token",
267
+ mean log p/token",
268
+ "Δ sequence log p",
269
+ "Next-token JS",
270
  ]
271
  table = pd.DataFrame(result.rows, columns=columns)
272
+ plot = table[["Multiplier", "Δ mean log p/token"]].copy()
273
+ plot["Series"] = "SAE feature"
274
  return table, plot, _dose_metrics_markdown(result)
275
  except Exception as exc:
276
  _raise_ui_error(exc)
 
303
  _raise_ui_error(exc)
304
 
305
 
306
+ @gpu(duration=35)
307
+ def run_feature_set(
308
+ prompt: str,
309
+ layer: int,
310
+ token_index: int,
311
+ feature_ids: list[str] | None,
312
+ mode: str,
313
+ coefficient: float,
314
+ target_text: str,
315
+ ):
316
+ try:
317
+ if not prompt.strip():
318
+ raise ValueError("Enter a prompt in the Workbench first.")
319
+ selected = [int(float(value)) for value in (feature_ids or [])]
320
+ if not selected:
321
+ raise ValueError("Select at least one feature in 'Feature set'.")
322
+ if not target_text.strip():
323
+ raise ValueError("Enter a target continuation for the feature-set causal test.")
324
+ result = RUNTIME.intervene_feature_set(
325
+ text=prompt,
326
+ layer=int(layer),
327
+ token_index=int(token_index),
328
+ feature_ids=selected,
329
+ mode=mode,
330
+ coefficient=float(coefficient),
331
+ target_text=target_text,
332
+ )
333
+ return result.feature_rows, _feature_set_metrics_markdown(result), result.target_token_rows
334
+ except Exception as exc:
335
+ _raise_ui_error(exc)
336
+
337
+
338
+ @gpu(duration=35)
339
+ def run_feature_set_sweep(
340
+ prompt: str,
341
+ layer: int,
342
+ token_index: int,
343
+ target_text: str,
344
+ ):
345
+ try:
346
+ if not prompt.strip():
347
+ raise ValueError("Enter a prompt in the Workbench first.")
348
+ if not target_text.strip():
349
+ raise ValueError("Enter a target continuation before running the set-size sweep.")
350
+ result = RUNTIME.feature_set_size_sweep(
351
+ text=prompt,
352
+ layer=int(layer),
353
+ token_index=int(token_index),
354
+ target_text=target_text,
355
+ )
356
+ columns = [
357
+ "Set size k",
358
+ "Feature ids",
359
+ "Perturbation L2",
360
+ "Baseline mean log p/token",
361
+ "SAE mean log p/token",
362
+ "SAE Δ mean log p/token",
363
+ "Random Δ mean log p/token",
364
+ "Specificity ratio",
365
+ "SAE Δ sequence log p",
366
+ "SAE next-token JS",
367
+ "Random next-token JS",
368
+ ]
369
+ table = pd.DataFrame(result.rows, columns=columns)
370
+ plot_rows = []
371
+ for _, row in table.iterrows():
372
+ plot_rows.append([row["Set size k"], "Top-k SAE ablation", row["SAE Δ mean log p/token"]])
373
+ plot_rows.append([row["Set size k"], "Norm-matched random", row["Random Δ mean log p/token"]])
374
+ plot = pd.DataFrame(plot_rows, columns=["Set size k", "Condition", "Δ mean log p/token"])
375
+ tokens = " ".join(repr(token) for token in result.target_tokens)
376
+ note = (
377
+ f"Target continuation: {len(result.target_tokens)} token(s): {tokens}. "
378
+ "For each k, FeatureLens jointly **ablates the k strongest active SAE features** at the "
379
+ "selected prompt token and compares that joint edit with a norm-matched random residual control."
380
+ )
381
+ return table, plot, note
382
+ except Exception as exc:
383
+ _raise_ui_error(exc)
384
+
385
+
386
+ @gpu(duration=30)
387
+ def run_paraphrase_compare(
388
+ original_prompt: str,
389
+ paraphrase_prompt: str,
390
+ layer: int,
391
+ token_index_a: int,
392
+ token_index_b: int,
393
+ top_n: int,
394
+ ):
395
+ try:
396
+ result = RUNTIME.compare_paraphrases(
397
+ text_a=original_prompt,
398
+ text_b=paraphrase_prompt,
399
+ layer=int(layer),
400
+ token_index_a=int(token_index_a),
401
+ token_index_b=int(token_index_b),
402
+ top_n=int(top_n),
403
+ )
404
+ chart = pd.DataFrame(result.chart_rows, columns=["Feature", "Prompt", "Activation"])
405
+ return (
406
+ RUNTIME.token_html(result.tokens_a, result.token_index_a),
407
+ RUNTIME.token_html(result.tokens_b, result.token_index_b),
408
+ _paraphrase_metrics_markdown(result),
409
+ result.rows,
410
+ chart,
411
+ )
412
+ except Exception as exc:
413
+ _raise_ui_error(exc)
414
+
415
+
416
  def mode_help(mode: str):
417
  if mode == "ablate":
418
  return gr.update(
 
436
  )
437
 
438
 
439
+ def set_mode_help(mode: str):
440
+ if mode == "ablate":
441
+ return gr.update(
442
+ value=0.0,
443
+ interactive=False,
444
+ label="Set coefficient (unused for ablation)",
445
+ info="Jointly sets every selected active feature coefficient to zero.",
446
+ )
447
+ return gr.update(
448
+ value=2.0,
449
+ interactive=True,
450
+ label="Shared feature multiplier",
451
+ info="Applies the same multiplier to each selected feature before summing decoder deltas.",
452
+ )
453
+
454
+
455
  with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as demo:
456
  gr.HTML(
457
+ '<div class="hero"><h1>FeatureLens <span style="font-size:.42em;opacity:.55">v0.3</span></h1>'
458
  '<p>Causal sparse-feature interpretability for Qwen3-1.7B — inspect → intervene → control → measure.</p></div>'
459
  '<div class="research-q"><b>Research question:</b> Do sparse features that predict a concept also '
460
  'causally influence the model’s behaviour?</div>'
461
  '<div class="badges">'
462
  '<span class="badge">Qwen3-1.7B-Base</span><span class="badge">Qwen-Scope SAE</span>'
463
  '<span class="badge">32,768 features</span><span class="badge">TopK=50</span>'
464
+ '<span class="badge">full-sequence scoring</span><span class="badge">feature sets</span>'
465
  '<span class="badge">norm-matched controls</span><span class="badge">ZeroGPU</span></div>'
466
  )
467
 
468
  with gr.Tab("Workbench"):
469
+ gr.HTML('<div class="step">Step 1 · Inspect one prompt location</div>')
470
  with gr.Row(equal_height=False):
471
  with gr.Column(scale=5):
472
  prompt = gr.Textbox(
 
496
  value=-1,
497
  precision=0,
498
  label="Prompt token index",
499
+ info="-1 = final prompt token. Inspect once to see all token positions below.",
500
  )
501
+ top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
502
  analyze_btn = gr.Button("Inspect sparse features", variant="primary")
503
 
504
+ gr.Markdown("#### Prompt tokens\nThe selected prompt token is outlined more strongly.")
505
+ token_view = gr.HTML('<div class="small-note">Prompt tokens appear here after clicking <b>Inspect sparse features</b>.</div>')
506
  analysis_metrics = gr.Markdown()
507
  with gr.Row(equal_height=False):
508
  feature_table = gr.Dataframe(
509
  headers=["Rank", "Feature id", "Activation", "Offline concept hint"],
510
  datatype=["number", "number", "number", "str"],
511
  interactive=False,
512
+ label="Strongest active SAE features",
513
  wrap=True,
514
  scale=3,
515
  )
516
  feature_plot = gr.BarPlot(
517
  x="Feature",
518
  y="Activation",
519
+ color="Series",
520
+ color_map={"Activation": MUTED_TEAL},
521
  title="Activation profile",
522
  x_title="Feature id",
523
  y_title="Activation",
524
+ x_label_angle=-35,
525
+ height=330,
526
  scale=2,
527
  )
528
 
529
+ gr.HTML('<div class="step">Step 2 · Run one single-feature causal test</div>')
530
  gr.Markdown(
531
+ "FeatureLens edits the **original residual**, then compares the SAE edit with a deterministic "
532
+ "random residual perturbation of the **same L2 norm**. If a target continuation is supplied, "
533
+ "v0.3 scores the **entire continuation teacher-forced**, not only its first token."
534
  )
535
  with gr.Row(equal_height=False):
536
  with gr.Column(scale=2):
537
  feature_id = gr.Dropdown(
538
  choices=[],
539
  allow_custom_value=True,
540
+ label="Single feature id",
541
  info="Inspection populates the strongest active features; custom IDs are also allowed.",
542
  )
543
  mode = gr.Radio(
544
  choices=["ablate", "scale", "inject"],
545
  value="ablate",
546
+ label="Single-feature intervention",
547
  )
548
  coefficient = gr.Number(
549
  value=0.0,
 
551
  label="Coefficient (unused for ablation)",
552
  )
553
  target_text = gr.Textbox(
554
+ label="Target continuation (optional)",
555
  placeholder="e.g. 2x",
556
+ info=(
557
+ "Exact text to score after the prompt. v0.3 reports full-sequence and per-token "
558
+ "log probabilities. Include a leading space if that is part of the continuation."
559
+ ),
560
  )
561
  max_new = gr.Slider(
562
  4,
563
  SETTINGS.max_new_tokens,
564
+ value=min(12, SETTINGS.max_new_tokens),
565
  step=1,
566
+ label="Greedy generation length",
567
  )
568
+ intervene_btn = gr.Button("Run single-feature causal test", variant="primary")
569
  intervention_metrics = gr.Markdown()
570
  with gr.Column(scale=3):
571
  with gr.Row():
572
+ baseline_out = gr.Textbox(label="Baseline greedy generation", lines=6, interactive=False)
573
+ modified_out = gr.Textbox(label="SAE-edited greedy generation", lines=6, interactive=False)
574
  token_prob_table = gr.Dataframe(
575
  headers=["Token", "Baseline p", "SAE-edit p", "Δ probability"],
576
  datatype=["str", "number", "number", "number"],
577
  interactive=False,
578
  label="Next-token distribution shift",
579
  )
580
+ target_token_table = gr.Dataframe(
581
+ headers=[
582
+ "Target position",
583
+ "Target token",
584
+ "Baseline log p",
585
+ "SAE-edit log p",
586
+ "Random log p",
587
+ "SAE Δ log p",
588
+ "Random Δ log p",
589
+ ],
590
+ datatype=["number", "str", "number", "number", "number", "number", "number"],
591
+ interactive=False,
592
+ label="Target continuation token-by-token score",
593
+ )
594
 
595
+ gr.HTML('<div class="step">Step 3 · Check single-feature dose-response</div>')
596
+ with gr.Accordion("Single-feature scale dose-response", open=False):
597
  gr.Markdown(
598
+ "This panel is **always a scale intervention**, regardless of the radio choice above. "
599
+ "It evaluates **0×, 0.5×, 1×, 1.5×, 2× and 3×** in one batched forward pass after the "
600
+ "baseline. Here **0× = ablation** and **1× = no intervention**."
601
  )
602
+ dose_btn = gr.Button("Run scale dose-response")
603
  dose_metrics = gr.Markdown()
604
  with gr.Row():
605
+ dose_table = gr.Dataframe(interactive=False, label="Scale dose-response measurements", scale=3)
606
  dose_plot = gr.LinePlot(
607
  x="Multiplier",
608
+ y="Δ mean log p/token",
609
+ color="Series",
610
+ color_map={"SAE feature": MUTED_TEAL},
611
+ title="Scale dose-response",
612
  x_title="Feature multiplier",
613
+ y_title="Δ mean log p/token",
614
+ height=330,
615
  scale=2,
616
  )
617
 
618
+ with gr.Tab("Feature sets"):
619
+ gr.Markdown(
620
+ "### Test distributed sparse representations\n"
621
+ "This tab reuses the **Prompt**, **Residual layer**, and **Prompt token index** from the Workbench. "
622
+ "Click **Inspect sparse features** there first; its displayed feature IDs populate the selector below."
623
+ )
624
+ feature_set_ids = gr.Dropdown(
625
+ choices=[],
626
+ value=[],
627
+ multiselect=True,
628
+ allow_custom_value=True,
629
+ max_choices=12,
630
+ label="Feature set",
631
+ info="Choose several active features from the Workbench inspection, or enter custom IDs.",
632
  )
633
+ with gr.Row():
634
+ set_mode = gr.Radio(
635
+ choices=["ablate", "scale"],
636
+ value="ablate",
637
+ label="Feature-set intervention",
638
+ info="Inject is omitted because one shared additive coefficient is not comparable across multiple directions.",
639
+ )
640
+ set_coefficient = gr.Number(
641
+ value=0.0,
642
+ interactive=False,
643
+ label="Set coefficient (unused for ablation)",
644
+ )
645
+ set_target = gr.Textbox(
646
+ label="Target continuation",
647
+ value="2x",
648
+ info="Required. The complete continuation is scored teacher-forced.",
649
+ )
650
+ set_btn = gr.Button("Run joint feature-set causal test", variant="primary")
651
+ set_metrics = gr.Markdown()
652
+ with gr.Row():
653
+ set_feature_table = gr.Dataframe(
654
+ headers=["Feature id", "Original activation", "Δ coefficient", "Offline concept hint"],
655
+ datatype=["number", "number", "number", "str"],
656
+ interactive=False,
657
+ label="Joint intervention features",
658
+ scale=2,
659
+ )
660
+ set_target_table = gr.Dataframe(
661
+ headers=[
662
+ "Target position",
663
+ "Target token",
664
+ "Baseline log p",
665
+ "SAE-edit log p",
666
+ "Random log p",
667
+ "SAE Δ log p",
668
+ "Random Δ log p",
669
+ ],
670
+ datatype=["number", "str", "number", "number", "number", "number", "number"],
671
+ interactive=False,
672
+ label="Target continuation token-by-token score",
673
+ scale=3,
674
+ )
675
+
676
+ gr.Markdown("### Top-k joint-ablation sweep")
677
+ gr.Markdown(
678
+ "Instead of choosing a single feature, this experiment automatically ablates the **1, 3, and 5 "
679
+ "strongest active features** at the current Workbench location. All six edited/control conditions "
680
+ "are evaluated in one batched forward pass after the baseline."
681
  )
682
+ set_sweep_target = gr.Textbox(
683
+ label="Target continuation for set-size sweep",
684
+ value="2x",
 
685
  )
686
+ set_sweep_btn = gr.Button("Run 1/3/5-feature ablation sweep")
687
+ set_sweep_note = gr.Markdown()
688
+ with gr.Row():
689
+ set_sweep_table = gr.Dataframe(interactive=False, label="Feature-set size measurements", scale=3)
690
+ set_sweep_plot = gr.LinePlot(
691
+ x="Set size k",
692
+ y="Δ mean log p/token",
693
+ color="Condition",
694
+ color_map={
695
+ "Top-k SAE ablation": MUTED_TEAL,
696
+ "Norm-matched random": MUTED_STONE,
697
+ },
698
+ title="Effect vs feature-set size",
699
+ x_title="Number of jointly ablated features",
700
+ y_title="Δ mean log p/token",
701
+ height=330,
702
+ scale=2,
703
+ )
704
+
705
+ with gr.Tab("Paraphrase robustness"):
706
+ gr.Markdown(
707
+ "### Does the sparse representation survive a rewording?\n"
708
+ "Compare the TopK SAE representation of an original prompt with a manually supplied paraphrase. "
709
+ "This mirrors the offline paraphrase-stability evaluation without pretending that feature IDs "
710
+ "have known semantics."
711
+ )
712
+ with gr.Row():
713
+ para_a = gr.Textbox(
714
+ label="Original prompt",
715
+ lines=4,
716
+ value="The derivative of x squared is",
717
+ )
718
+ para_b = gr.Textbox(
719
+ label="Paraphrase",
720
+ lines=4,
721
+ value="Differentiate x squared with respect to x:",
722
+ )
723
+ with gr.Row():
724
+ para_layer = gr.Dropdown(
725
+ choices=list(SETTINGS.layers),
726
+ value=SETTINGS.layers[1] if len(SETTINGS.layers) > 1 else SETTINGS.layers[0],
727
+ label="Residual layer",
728
+ )
729
+ para_idx_a = gr.Number(value=-1, precision=0, label="Original prompt token index")
730
+ para_idx_b = gr.Number(value=-1, precision=0, label="Paraphrase token index")
731
+ para_top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
732
+ para_btn = gr.Button("Compare paraphrase representations", variant="primary")
733
+ with gr.Row():
734
+ with gr.Column():
735
+ gr.Markdown("#### Original prompt tokens")
736
+ para_tokens_a = gr.HTML()
737
+ with gr.Column():
738
+ gr.Markdown("#### Paraphrase tokens")
739
+ para_tokens_b = gr.HTML()
740
+ para_metrics = gr.Markdown()
741
+ with gr.Row():
742
+ para_table = gr.Dataframe(
743
+ headers=["Feature id", "Original activation", "Paraphrase activation", "Status", "Offline concept hint"],
744
+ datatype=["number", "number", "number", "str", "str"],
745
+ interactive=False,
746
+ label="Top-feature overlap",
747
+ scale=3,
748
+ )
749
+ para_plot = gr.BarPlot(
750
+ x="Feature",
751
+ y="Activation",
752
+ color="Prompt",
753
+ color_map={"Original": MUTED_TEAL, "Paraphrase": MUTED_PURPLE},
754
+ title="Original vs paraphrase activation",
755
+ x_title="Feature id",
756
+ y_title="Activation",
757
+ x_label_angle=-35,
758
+ height=330,
759
+ scale=2,
760
+ )
761
 
762
  with gr.Tab("Layer trajectory"):
763
  gr.Markdown(
764
+ "### Follow representation structure across early, middle and late residual streams\n"
765
  "This is **not** a cross-layer feature-ID comparison — SAE dictionaries are layer-specific. "
766
+ "It compares reconstruction quality and sparsity/concentration statistics at the same prompt "
767
+ "token across layers 4, 14 and 26."
768
  )
769
  with gr.Row():
770
  trajectory_prompt = gr.Textbox(
 
781
  scale=1,
782
  )
783
  trajectory_btn = gr.Button("Compare layers", variant="primary")
784
+ gr.Markdown("#### Prompt tokens")
785
  trajectory_tokens = gr.HTML()
786
  with gr.Row():
787
  trajectory_table = gr.Dataframe(interactive=False, label="Layer diagnostics", scale=3)
 
789
  x="Layer",
790
  y="Value",
791
  color="Metric",
792
+ color_map={
793
+ "Reconstruction cosine": MUTED_TEAL,
794
+ "Top-5 mass": MUTED_OCHRE,
795
+ "Activation entropy": MUTED_RED,
796
+ },
797
  title="Representation trajectory",
798
  x_title="Layer",
799
  y_title="Normalized value",
800
+ height=330,
801
  scale=2,
802
  )
 
 
 
 
 
803
 
804
  with gr.Tab("Offline benchmark"):
805
  gr.Markdown(RUNTIME.catalog.benchmark_markdown())
806
  gr.Markdown(
807
  "The offline pipeline evaluates held-out feature/concept AUROC + F1, reconstruction quality, "
808
+ "paraphrase stability, dense residual linear probes, single-feature causal edits, and v0.3 "
809
+ "top-k feature-set ablations against norm-matched controls. Target outcomes use **full continuation "
810
+ "teacher-forced log probability**, with mean log p/token used for length-comparable aggregate tests. "
811
+ "Results are loaded from `artifacts/`; the app never ships invented benchmark numbers."
812
  )
813
 
814
  with gr.Tab("Method"):
 
816
  r"""
817
  ### Reconstruction-preserving intervention
818
 
819
+ For residual vector $h$, sparse coefficient $z_i$, decoder direction $d_i$, and scale $\alpha$:
820
 
821
  - **Ablate:** $h' = h - z_i d_i$
822
  - **Scale:** $h' = h + (\alpha - 1) z_i d_i$
823
  - **Inject:** $h' = h + \delta d_i$
824
 
825
+ For a feature set $S$, FeatureLens sums the individual ablation/scale deltas before patching the original residual:
826
+
827
+ $$h' = h + \sum_{i \in S} \Delta z_i d_i.$$
828
+
829
+ The app never replaces $h$ with the complete SAE reconstruction, so SAE reconstruction error is not introduced
830
+ as a causal confound.
831
+
832
+ ### Full-continuation scoring
833
+
834
+ For a user-supplied target continuation, v0.3 concatenates its token IDs to the prompt and computes teacher-forced
835
+ log probabilities for **every target token**. The primary length-comparable live/offline statistic is the change in
836
+ **mean log probability per target token**. The next-token distribution is still reported separately.
837
 
838
  ### Evidence ladder
839
 
840
  1. **Reconstruction:** does the SAE represent the residual reasonably well?
841
  2. **Prediction:** does a feature predict a controlled concept on held-out paraphrase groups?
842
+ 3. **Robustness:** does the sparse representation remain stable across a paraphrase?
843
+ 4. **Single-feature intervention:** does changing one feature alter downstream behaviour?
844
+ 5. **Feature-set intervention:** does jointly editing a sparse subspace reveal distributed causal influence?
845
+ 6. **Specificity:** are those effects larger than norm-matched random residual perturbations?
846
+ 7. **Dose-response:** does effect size change coherently as a feature coefficient is varied?
847
 
848
+ A high AUROC or high paraphrase overlap alone remains correlational evidence.
849
  """
850
  )
851
 
 
854
  "FeatureLens is independent of thesis code and thesis datasets.</small>"
855
  )
856
 
857
+ # Event wiring is kept together so cross-tab state is explicit.
858
+ analyze_btn.click(
859
+ analyze_prompt,
860
+ inputs=[prompt, layer, token_index, top_n],
861
+ outputs=[
862
+ token_view,
863
+ feature_table,
864
+ feature_plot,
865
+ feature_id,
866
+ feature_set_ids,
867
+ analysis_metrics,
868
+ ],
869
+ )
870
+ mode.change(mode_help, inputs=[mode], outputs=[coefficient])
871
+ intervene_btn.click(
872
+ run_intervention,
873
+ inputs=[prompt, layer, token_index, feature_id, mode, coefficient, target_text, max_new],
874
+ outputs=[
875
+ baseline_out,
876
+ modified_out,
877
+ intervention_metrics,
878
+ token_prob_table,
879
+ target_token_table,
880
+ ],
881
+ )
882
+ dose_btn.click(
883
+ run_dose_response,
884
+ inputs=[prompt, layer, token_index, feature_id, target_text],
885
+ outputs=[dose_table, dose_plot, dose_metrics],
886
+ )
887
+ set_mode.change(set_mode_help, inputs=[set_mode], outputs=[set_coefficient])
888
+ set_btn.click(
889
+ run_feature_set,
890
+ inputs=[prompt, layer, token_index, feature_set_ids, set_mode, set_coefficient, set_target],
891
+ outputs=[set_feature_table, set_metrics, set_target_table],
892
+ )
893
+ set_sweep_btn.click(
894
+ run_feature_set_sweep,
895
+ inputs=[prompt, layer, token_index, set_sweep_target],
896
+ outputs=[set_sweep_table, set_sweep_plot, set_sweep_note],
897
+ )
898
+ para_btn.click(
899
+ run_paraphrase_compare,
900
+ inputs=[para_a, para_b, para_layer, para_idx_a, para_idx_b, para_top_n],
901
+ outputs=[para_tokens_a, para_tokens_b, para_metrics, para_table, para_plot],
902
+ )
903
+ trajectory_btn.click(
904
+ run_layer_sweep,
905
+ inputs=[trajectory_prompt, trajectory_token],
906
+ outputs=[trajectory_tokens, trajectory_table, trajectory_plot],
907
+ )
908
+
909
  if __name__ == "__main__":
910
+ # Explicit CSR avoids the Gradio SSR auth-coroutine warning seen in earlier Space builds.
911
  demo.queue(default_concurrency_limit=1, max_size=8).launch(
912
  css=CSS,
913
  theme=THEME,
artifacts/README.md CHANGED
@@ -1,6 +1,6 @@
1
  # Generated artifacts
2
 
3
- This directory intentionally ships without invented results.
4
 
5
  Run:
6
 
@@ -8,6 +8,16 @@ Run:
8
  python experiments/run_all.py
9
  ```
10
 
11
- to create activation caches, `feature_catalog.csv`, `layer_metrics.csv`, `stability.csv`, `causal_results.csv`, `summary.json`, `report.md`, and figures.
12
 
13
- Large activation arrays are ignored by Git. Commit the small CSV/JSON/report/figure outputs if you want the live Space to show benchmark-derived feature hints and the benchmark summary.
 
 
 
 
 
 
 
 
 
 
 
1
  # Generated artifacts
2
 
3
+ This directory intentionally ships without invented empirical results.
4
 
5
  Run:
6
 
 
8
  python experiments/run_all.py
9
  ```
10
 
11
+ to create:
12
 
13
+ - activation caches;
14
+ - `feature_catalog.csv`;
15
+ - `layer_metrics.csv`;
16
+ - `stability.csv`;
17
+ - `causal_results.csv`;
18
+ - `feature_set_results.csv`;
19
+ - `summary.json`;
20
+ - `report.md`;
21
+ - report figures.
22
+
23
+ Large residual/activation arrays are ignored by Git. Commit only small CSV/JSON/report/figure outputs if you want the live Space to display benchmark-derived feature hints and measured conclusions.
docs/HF_DEPLOY.md CHANGED
@@ -1,24 +1,54 @@
1
  # Hugging Face deployment
2
 
3
- FeatureLens targets a Gradio SDK Space with ZeroGPU hardware.
4
 
5
- ## Why Gradio rather than Docker?
6
 
7
- ZeroGPU currently supports Gradio SDK Spaces. The workbench therefore uses `app.py` plus `requirements.txt` and avoids a Dockerfile.
8
 
9
- ## Cold start
 
10
 
11
- On Hugging Face, `FEATURELENS_EAGER_LOAD` defaults to `1`. The runtime loads Qwen3-1.7B-Base and only the configured Qwen-Scope layer files. The default layer set is 4, 14 and 26.
12
 
13
- The model and SAEs are placed on `cuda` during startup. ZeroGPU provides CUDA emulation outside GPU-decorated callbacks and a real GPU during `spaces.GPU` execution.
14
 
15
- ## GPU-decorated functions
16
 
17
- - feature inspection: 45-second maximum allocation;
18
- - baseline-vs-modified generation: 60-second maximum allocation.
19
 
20
- Keeping prompt length and generation length bounded protects the free daily quota and improves queue behavior.
 
 
 
 
 
 
21
 
22
- ## No persistent benchmark jobs in the Space
23
 
24
- The full experiment is intentionally offline. A free ZeroGPU quota is appropriate for interactive inspection, not a hundreds-of-forward-passes benchmark. Run `python experiments/run_all.py` elsewhere once, commit the small report/catalog artifacts, and the Space will display them automatically.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Hugging Face deployment
2
 
3
+ FeatureLens targets a **Gradio SDK Space** with ZeroGPU hardware.
4
 
5
+ ## Runtime shape
6
 
7
+ On Hugging Face, `FEATURELENS_EAGER_LOAD` defaults to `1`. The runtime loads:
8
 
9
+ - `Qwen/Qwen3-1.7B-Base`;
10
+ - Qwen-Scope SAE layers **4, 14, 26** only.
11
 
12
+ The full 28-layer SAE repository is not required by the live app.
13
 
14
+ `app.py` explicitly launches with `ssr_mode=False`. This avoids the SSR/auth path that produced the earlier `get_current_user was never awaited` warning during v0.2 deployment testing.
15
 
16
+ ## GPU-decorated actions
17
 
18
+ Current v0.3 callback allocations:
 
19
 
20
+ - **Inspect sparse features** 30 s;
21
+ - **Run single-feature causal test** — 45 s;
22
+ - **Run scale dose-response** — 35 s;
23
+ - **Run joint feature-set causal test** — 35 s;
24
+ - **Run 1/3/5-feature ablation sweep** — 35 s;
25
+ - **Compare paraphrase representations** — 30 s;
26
+ - **Compare layers** — 35 s.
27
 
28
+ These durations are allocation ceilings, not expected runtimes.
29
 
30
+ ## Batched interventions
31
+
32
+ v0.3 deliberately batches experiment conditions inside one GPU callback:
33
+
34
+ - all six dose-response multipliers share one edited forward after the baseline;
35
+ - targeted/random conditions for k=1/3/5 feature-set ablation share one edited forward after the baseline;
36
+ - full-target SAE and random-control scoring is batched when possible.
37
+
38
+ This keeps the stronger diagnostics compatible with a quota-limited interactive Space.
39
+
40
+ ## Greedy generation vs target scoring
41
+
42
+ The single-feature causal test still runs baseline and edited greedy generation because the visible text comparison is useful for a demo. Probability-level evidence uses teacher-forced full-continuation scoring, because deterministic text can remain unchanged despite meaningful logit shifts.
43
+
44
+ The feature-set and set-size panels intentionally avoid extra free-running generations and focus on causal probability metrics.
45
+
46
+ ## Offline benchmark
47
+
48
+ Do **not** run the full research benchmark in the public Space. Run:
49
+
50
+ ```bash
51
+ python experiments/run_all.py
52
+ ```
53
+
54
+ on separate CUDA compute, then commit only the small report/catalog/CSV/figure artifacts you want the Space to display. Large activation arrays remain gitignored.
docs/METHODOLOGY.md CHANGED
@@ -2,38 +2,100 @@
2
 
3
  ## Primary hypothesis
4
 
5
- A sparse feature that is predictive of a semantic category is not automatically a causal mechanism for behavior. FeatureLens evaluates association and intervention separately.
6
 
7
  ## Discovery data
8
 
9
- Seven controlled concepts are represented by 16 independently authored prompt pairs per concept. Each pair contains two paraphrases. The split is grouped by pair id so lexical near-duplicates never cross train and test.
10
 
11
- The final token residual is used for the offline concept benchmark because it has access to the complete prompt prefix and keeps the activation tensor small enough to reproduce easily. The live workbench is token-selectable and is not restricted to the final token.
12
 
13
- ## Sparse feature evaluation
14
 
15
- For each configured layer, FeatureLens stores the TopK SAE code for each sample. Candidate features must fire at least three times on the training split. Features are ranked using training AUROC and an activation-rate contrast tie-break. AUROC and F1 shown in the report are then calculated on the held-out split.
16
 
17
- This makes the feature catalog a selection procedure rather than a post-hoc leaderboard over the test set.
18
 
19
  ## Dense baseline
20
 
21
- A multinomial logistic-regression probe is fit to the dense residual vectors at the same layers. This asks whether the representation carries concept information even if no single SAE feature isolates it cleanly.
22
 
23
- ## Causal intervention
24
 
25
- If the SAE code of the selected prompt residual is `z` and the selected feature is `i`, FeatureLens changes only coefficient `z_i`. The resulting decoder delta is added back to the original residual. Full SAE reconstruction is never substituted for the original activation.
26
 
27
- Ablation uses `Δz_i = -z_i`; amplification uses `Δz_i = z_i`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  ## Negative control
30
 
31
- For every SAE residual perturbation, a deterministic random direction with identical L2 norm is generated and patched at the same layer/token. This does not prove specificity by itself, but it gives a much stronger baseline than reporting an intervention effect without a perturbation control.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
- ## Causal outcomes
34
 
35
- The causal task set is separate from feature discovery. The main behavioral score is the change in log-probability of the first token of a target completion. The pipeline also stores probability delta, target rank change, JS divergence of the next-token distribution, and whether the top-1 token changed.
36
 
37
- ## Interpretation
 
38
 
39
- The auto-generated report uses conservative heuristics to summarize measured results, but preserves all raw rows. A high predictive AUROC with a small causal effect is explicitly reported as evidence that correlation did not translate into strong downstream control.
 
2
 
3
  ## Primary hypothesis
4
 
5
+ A sparse feature that predicts a semantic category is not automatically a causal mechanism for behavior. FeatureLens therefore evaluates reconstruction, association, robustness, and causal intervention separately.
6
 
7
  ## Discovery data
8
 
9
+ Seven controlled concepts are represented by 16 prompt pairs per concept. Each pair contains two paraphrases. The split is grouped by `pair_id`, so lexical near-duplicates never cross train and held-out test.
10
 
11
+ The offline discovery benchmark uses the final prompt-token residual because it summarizes the complete prompt prefix and keeps cached tensors small. The live workbench remains token-selectable.
12
 
13
+ ## Sparse feature selection
14
 
15
+ For each configured residual layer, FeatureLens stores the TopK SAE code. Candidate features must fire often enough on the training split. They are ranked using training AUROC with activation-rate contrast as a tie-break.
16
 
17
+ Held-out AUROC and F1 are computed **after** selection. The test split is not used to pick the winning feature.
18
 
19
  ## Dense baseline
20
 
21
+ A multinomial logistic-regression probe is fit to dense residual vectors at the same layers. This asks whether concept information exists in the representation even when no individual sparse feature isolates it cleanly.
22
 
23
+ ## Paraphrase robustness
24
 
25
+ Sparse representation stability is measured with:
26
 
27
+ - TopK support Jaccard;
28
+ - sparse activation cosine.
29
+
30
+ The live **Paraphrase robustness** explorer computes the same style of diagnostics for a manually supplied prompt pair. High overlap is robustness evidence only; it does not establish semantic identity of individual feature IDs.
31
+
32
+ ## Reconstruction-preserving causal edit
33
+
34
+ For residual `h`, selected SAE activation `z_i`, decoder direction `d_i`, and multiplier `α`:
35
+
36
+ ```text
37
+ ablate: h' = h - z_i d_i
38
+ scale: h' = h + (α - 1) z_i d_i
39
+ inject: h' = h + δ d_i
40
+ ```
41
+
42
+ The delta is added to the **original residual**. FeatureLens never replaces the residual with the full SAE reconstruction, avoiding reconstruction error as a causal confound.
43
+
44
+ ## Full-continuation target scoring
45
+
46
+ v0.3 scores the complete user/task target rather than only its first token.
47
+
48
+ Prompt token IDs are concatenated with the exact target token IDs. A teacher-forced forward pass supplies a log probability for every target token. FeatureLens stores:
49
+
50
+ - total target sequence log probability;
51
+ - mean log probability per target token;
52
+ - per-token log probabilities;
53
+ - SAE-edit deltas;
54
+ - matched-control deltas.
55
+
56
+ The primary aggregate causal metric is **Δ mean target log probability per token** because it is comparable across targets with different token lengths.
57
+
58
+ Next-token probability/rank and JS divergence remain secondary diagnostics.
59
 
60
  ## Negative control
61
 
62
+ For every targeted residual perturbation, FeatureLens constructs a deterministic random residual direction with the same L2 norm and patches it at the same layer and prompt token.
63
+
64
+ The paired targeted-vs-random comparison asks whether the chosen SAE direction matters more than an arbitrary perturbation of equal magnitude.
65
+
66
+ ## Single-feature dose-response
67
+
68
+ The live dose-response experiment is explicitly a **scale sweep**:
69
+
70
+ ```text
71
+ 0×, 0.5×, 1×, 1.5×, 2×, 3×
72
+ ```
73
+
74
+ `0×` is ablation and `1×` is the exact no-edit control. All six residual deltas are evaluated as one batch after the baseline.
75
+
76
+ A monotonic curve would strengthen causal evidence, but monotonicity is not assumed. Non-monotonic responses are retained as results.
77
+
78
+ ## Feature-set intervention
79
+
80
+ A concept may be distributed across multiple sparse features. For a same-layer feature set `S`, FeatureLens sums individual ablation/scale deltas:
81
+
82
+ ```text
83
+ h' = h + Σ_i∈S Δz_i d_i
84
+ ```
85
+
86
+ Features from different SAE layers are never summed into one residual intervention.
87
+
88
+ The live app supports custom same-layer feature sets. The offline pipeline additionally selects a concept's best layer using training-only feature scores and tests its top **1 / 3 / 5** distinct features.
89
+
90
+ ## Feature-set size control
91
+
92
+ For each k in `1, 3, 5`, targeted joint ablation is compared with a norm-matched random residual perturbation. Increasing k is **not** assumed to increase causal specificity: a larger feature set can simply create a larger perturbation, which is why matched controls and paired statistics remain necessary.
93
 
94
+ ## Statistical interpretation
95
 
96
+ The generated report calculates:
97
 
98
+ - bootstrap 95% confidence intervals;
99
+ - paired sign-flip randomization tests for targeted-vs-random absolute effects.
100
 
101
+ A large point-estimate ratio alone is not treated as strong causal-specificity evidence if paired uncertainty does not support it. The report preserves raw rows regardless of the narrative classification.
docs/VALIDATION.md CHANGED
@@ -1,76 +1,416 @@
1
- # FeatureLens validation matrix
2
 
3
- FeatureLens has two distinct validation targets: software correctness and scientific validity. Passing one does not imply the other.
4
 
5
- ## Before every push
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  ```bash
8
- python -m ruff check app.py featurelens experiments tests scripts
9
  python -m pytest -q
10
  python -m compileall -q app.py featurelens experiments scripts
11
  python scripts/release_check.py
12
  ```
13
 
14
- Also run `git status` and confirm that no `__pycache__/`, `pycache/`, `.pytest_cache/`, model weights, or `artifacts/activations/` files are staged.
 
 
 
 
15
 
16
- ## Local UI smoke test
17
 
18
  ```bash
19
- python app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  ```
21
 
22
- Verify that the app opens without an SSR banner and without `get_current_user ... was never awaited` warnings. The app intentionally launches with `ssr_mode=False`.
23
-
24
- On a CUDA machine, test one example through each path:
25
-
26
- 1. Inspect features on layer 14, final token.
27
- 2. Ablate the strongest active feature.
28
- 3. Supply a target continuation and confirm SAE-edit and random-control metrics both appear.
29
- 4. Run the dose-response sweep and confirm the 1× row has approximately zero intervention effect.
30
- 5. Run the layer trajectory and confirm rows for 4, 14 and 26.
31
-
32
- ## Hugging Face Space smoke test
33
-
34
- After a push/rebuild:
35
-
36
- 1. Confirm Space hardware is ZeroGPU and status reaches **Running**.
37
- 2. Check startup logs for model/SAE download or load failures.
38
- 3. Confirm the launch line does **not** say `with SSR`.
39
- 4. Run `The derivative of x squared is` at layer 14, token `-1`.
40
- 5. Confirm the feature dropdown is populated after inspection.
41
- 6. Run an ablation with `target continuation = 2x`.
42
- 7. Confirm baseline and modified generations render and random-control metrics appear.
43
- 8. Run a dose-response sweep once; confirm the curve renders.
44
- 9. Run the layer trajectory once; confirm all three layers render.
45
- 10. Refresh the page and repeat a short inspection to catch state/cold-start regressions.
46
-
47
- ## Scientific acceptance checks
48
-
49
- Do not publish headline findings until the offline benchmark is run on the real Qwen3/Qwen-Scope weights.
50
-
51
- Required checks:
52
-
53
- - paraphrase groups do not cross train/test splits;
54
- - feature selection uses training metrics only;
55
- - held-out AUROC/F1 are reported separately;
56
- - reconstruction metrics are reported per layer;
57
- - causal tasks are separate from discovery prompts;
58
- - SAE edits and random controls are paired by task/intervention;
59
- - perturbation norms match within numerical tolerance;
60
- - bootstrap confidence intervals are included;
61
- - paired sign-flip p-value is reported as uncertainty evidence, not as proof of a mechanistic claim;
62
- - raw causal rows remain available for inspection;
63
- - null or mixed results are retained in the generated report.
64
-
65
- ## Recommended manual adversarial tests
66
-
67
- Use prompts designed to expose failure modes rather than only attractive examples:
68
-
69
- - a mathematics prompt with a code-like token;
70
- - English content containing one French entity name;
71
- - negation such as `This is not a positive review`;
72
- - an uncertainty prompt that becomes certain in its final clause;
73
- - a custom inactive feature ID with `ablate` versus `inject`;
74
- - a multi-token target continuation to verify the first-token warning;
75
- - token index `0`, `-1`, and an invalid out-of-range index;
76
- - feature ID `0`, `32767`, and an invalid `32768`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FeatureLens v0.3 validation guide
2
 
3
+ This document intentionally uses the **exact labels visible in the v0.3 Gradio UI**.
4
 
5
+ The goal is to distinguish three things:
6
+
7
+ 1. software correctness;
8
+ 2. Hugging Face / ZeroGPU deployment correctness;
9
+ 3. scientific sanity checks.
10
+
11
+ Do not interpret one successful prompt as a scientific result. The live tests below only verify that the instrumentation behaves coherently.
12
+
13
+ ---
14
+
15
+ ## A. Local software gate
16
+
17
+ From the repository root:
18
 
19
  ```bash
 
20
  python -m pytest -q
21
  python -m compileall -q app.py featurelens experiments scripts
22
  python scripts/release_check.py
23
  ```
24
 
25
+ Expected:
26
+
27
+ - all tests pass;
28
+ - `compileall` exits without output/error;
29
+ - release check ends with `FeatureLens release check: PASS` and `release: v0.3.0`.
30
 
31
+ Optional lint gate:
32
 
33
  ```bash
34
+ python -m ruff check app.py featurelens experiments tests scripts
35
+ ```
36
+
37
+ ---
38
+
39
+ ## B. Hugging Face startup test
40
+
41
+ ### Test B1 — container startup
42
+
43
+ Open the Space container logs after a fresh rebuild.
44
+
45
+ Expected launch lines:
46
+
47
+ ```text
48
+ * Running on local URL: http://0.0.0.0:7860
49
+ ```
50
+
51
+ The earlier warning below should **not** reappear:
52
+
53
+ ```text
54
+ coroutine 'App.create_app.<locals>.get_current_user' was never awaited
55
+ ```
56
+
57
+ The launch output should also not contain `with SSR ⚡` because `app.py` uses `ssr_mode=False`.
58
+
59
+ Pass condition: Space reaches `Running` and the UI loads.
60
+
61
+ ---
62
+
63
+ ## C. Workbench inspection
64
+
65
+ ### Test C1 — inspect a mathematics prompt
66
+
67
+ Open **Workbench**.
68
+
69
+ Set:
70
+
71
+ - **Prompt**: `The derivative of x squared is`
72
+ - **Residual layer**: `14`
73
+ - **Prompt token index**: `-1`
74
+ - **Displayed active features**: `12`
75
+
76
+ Click **Inspect sparse features**.
77
+
78
+ Verify all of the following:
79
+
80
+ 1. **Prompt tokens** appears directly above token-like boxes. One token has a stronger outline; this is the selected prompt token.
81
+ 2. **Analysis metrics** appears and includes:
82
+ - active SAE features;
83
+ - reconstruction cosine;
84
+ - NMSE;
85
+ - Top-5 activation mass.
86
+ 3. **Strongest active SAE features** contains 12 rows unless fewer than 12 are available.
87
+ 4. **Activation profile** contains the same displayed feature IDs as the table.
88
+ 5. **Single feature id** is populated with those feature IDs.
89
+ 6. Open **Feature sets** and confirm **Feature set** is populated, with up to the first 3 displayed features selected by default.
90
+
91
+ Sanity checks:
92
+
93
+ - reconstruction cosine must be finite and normally lie in `[-1, 1]`;
94
+ - NMSE must be finite and non-negative;
95
+ - feature IDs must lie in `[0, 32767]`;
96
+ - activations should be non-negative for the TopK ReLU SAE.
97
+
98
+ ---
99
+
100
+ ## D. Single-feature causal test
101
+
102
+ Use the same Workbench prompt/location from Test C1.
103
+
104
+ Choose the strongest value in **Single feature id**.
105
+
106
+ ### Test D1 — ablation with a multi-token target
107
+
108
+ Set:
109
+
110
+ - **Single-feature intervention**: `ablate`
111
+ - **Target continuation (optional)**: `2x`
112
+ - **Greedy generation length**: `8`
113
+
114
+ Click **Run single-feature causal test**.
115
+
116
+ Verify:
117
+
118
+ 1. **Baseline greedy generation** is populated.
119
+ 2. **SAE-edited greedy generation** is populated.
120
+ 3. **Next-token distribution shift** is populated.
121
+ 4. The metrics report:
122
+ - original feature activation;
123
+ - Δ coefficient;
124
+ - perturbation L2;
125
+ - next-token JS;
126
+ - random-control JS;
127
+ - full target continuation token count;
128
+ - baseline / SAE / random sequence log p;
129
+ - SAE and random Δ sequence log p;
130
+ - SAE and random Δ mean log p/token;
131
+ - specificity ratio.
132
+ 5. **Target continuation token-by-token score** has one row for every token in `2x`.
133
+
134
+ Important expected behaviour:
135
+
136
+ - ablation should report `Δ coefficient = -original activation` for an active feature;
137
+ - targeted and random perturbation L2 norms should be matched internally;
138
+ - greedy generations **may be identical** even when log-probability and JS metrics differ. This is not a failure.
139
+
140
+ ### Test D2 — scale
141
+
142
+ Set:
143
+
144
+ - **Single-feature intervention**: `scale`
145
+ - **Feature multiplier**: `2`
146
+ - keep **Target continuation (optional)**: `2x`
147
+
148
+ Click **Run single-feature causal test**.
149
+
150
+ Expected:
151
+
152
+ - for an active feature, `Δ coefficient ≈ +original activation`;
153
+ - full-continuation metrics are present;
154
+ - output need not differ at the text level.
155
+
156
+ ### Test D3 — inject
157
+
158
+ Set:
159
+
160
+ - **Single-feature intervention**: `inject`
161
+ - **Additive feature coefficient**: `5`
162
+ - keep target `2x`.
163
+
164
+ Expected:
165
+
166
+ - `Δ coefficient = +5` regardless of the original feature activation;
167
+ - perturbation L2 is finite;
168
+ - full-continuation metrics are present.
169
+
170
+ ---
171
+
172
+ ## E. Single-feature scale dose-response
173
+
174
+ Keep the Test C1 prompt/location, strongest **Single feature id**, and target `2x`.
175
+
176
+ Open **Single-feature scale dose-response**.
177
+
178
+ Click **Run scale dose-response**.
179
+
180
+ This test has no ambiguous intervention mode: the panel is **always a scale sweep**.
181
+
182
+ Expected rows in **Scale dose-response measurements**:
183
+
184
+ ```text
185
+ 0.0
186
+ 0.5
187
+ 1.0
188
+ 1.5
189
+ 2.0
190
+ 3.0
191
  ```
192
 
193
+ Interpretation of the multiplier:
194
+
195
+ - `0×` = ablation;
196
+ - `0.5×` = halve the original activation;
197
+ - `1×` = no intervention;
198
+ - `1.5×` = increase by 50%;
199
+ - `2×` = double;
200
+ - `3×` = triple.
201
+
202
+ Critical no-op sanity check for the `1×` row:
203
+
204
+ - `Δ feature coefficient ≈ 0`;
205
+ - `Perturbation L2 ≈ 0`;
206
+ - `Δ mean log p/token ≈ 0`;
207
+ - sequence log p 0`;
208
+ - `Next-token JS 0`.
209
+
210
+ The curve need not be monotonic. A non-monotonic curve is a scientific observation, not a software error, provided the `1×` no-op row is correct.
211
+
212
+ ---
213
+
214
+ ## F. Joint feature-set causal test
215
+
216
+ First run Test C1 so **Feature set** is populated.
217
+
218
+ Open **Feature sets**.
219
+
220
+ ### Test F1 top three joint ablation
221
+
222
+ Use the default 3 selected values in **Feature set**.
223
+
224
+ Set:
225
+
226
+ - **Feature-set intervention**: `ablate`
227
+ - **Target continuation**: `2x`
228
+
229
+ Click **Run joint feature-set causal test**.
230
+
231
+ Verify:
232
+
233
+ 1. **Joint intervention features** contains the selected feature IDs, original activations, and each Δ coefficient.
234
+ 2. Under ablation, each active feature's Δ coefficient is the negative of its original activation.
235
+ 3. The metrics report:
236
+ - selected set size;
237
+ - total joint perturbation L2;
238
+ - full target sequence / mean-per-token effects;
239
+ - matched random-control effects;
240
+ - specificity ratios.
241
+ 4. **Target continuation token-by-token score** contains all target tokens.
242
+
243
+ ### Test F2 joint scale
244
+
245
+ Set:
246
+
247
+ - **Feature-set intervention**: `scale`
248
+ - **Shared feature multiplier**: `2`
249
+
250
+ Run again.
251
+
252
+ Expected: every selected active feature receives its own `+original activation` coefficient delta before the decoder deltas are summed.
253
+
254
+ ---
255
+
256
+ ## G. 1/3/5-feature ablation sweep
257
+
258
+ Still in **Feature sets**.
259
+
260
+ Set **Target continuation for set-size sweep** to `2x`.
261
+
262
+ Click **Run 1/3/5-feature ablation sweep**.
263
+
264
+ Expected **Feature-set size measurements** rows:
265
+
266
+ ```text
267
+ 1
268
+ 3
269
+ 5
270
+ ```
271
+
272
+ For each row verify:
273
+
274
+ - the feature ID list contains exactly `k` IDs;
275
+ - perturbation L2 is finite;
276
+ - SAE and random Δ mean log p/token are present;
277
+ - specificity ratio is finite unless the random effect is numerically zero;
278
+ - SAE and random next-token JS are present.
279
+
280
+ This sweep always performs **joint ablation of the strongest active features** at the current Workbench prompt location. It does not use **Feature-set intervention** or **Shared feature multiplier**.
281
+
282
+ Scientific sanity check: effect magnitude does **not** have to increase with k. If k=5 is weaker or less specific than k=1, retain that result.
283
+
284
+ ---
285
+
286
+ ## H. Paraphrase robustness
287
+
288
+ Open **Paraphrase robustness**.
289
+
290
+ Set:
291
+
292
+ - **Original prompt**: `The derivative of x squared is`
293
+ - **Paraphrase**: `Differentiate x squared with respect to x:`
294
+ - **Residual layer**: `14`
295
+ - **Original prompt token index**: `-1`
296
+ - **Paraphrase token index**: `-1`
297
+ - **Displayed active features**: `12`
298
+
299
+ Click **Compare paraphrase representations**.
300
+
301
+ Verify:
302
+
303
+ 1. **Original prompt tokens** and **Paraphrase tokens** both render.
304
+ 2. **Robustness metrics** reports:
305
+ - full TopK feature-set Jaccard;
306
+ - sparse activation cosine;
307
+ - shared displayed top features.
308
+ 3. **Top-feature overlap** labels features as `shared`, `original only`, or `paraphrase only`.
309
+ 4. **Original vs paraphrase activation** renders both conditions with distinct muted colors.
310
+
311
+ Bounds:
312
+
313
+ - Jaccard must lie in `[0, 1]`;
314
+ - sparse cosine should lie approximately in `[0, 1]` because SAE TopK activations are non-negative.
315
+
316
+ Repeat with a deliberately weak/non-paraphrase second prompt. The metrics should be allowed to decrease; there is no hard-coded expected threshold.
317
+
318
+ ---
319
+
320
+ ## I. Layer trajectory
321
+
322
+ Open **Layer trajectory**.
323
+
324
+ Set:
325
+
326
+ - **Prompt**: `The derivative of x squared is`
327
+ - **Prompt token index**: `-1`
328
+
329
+ Click **Compare layers**.
330
+
331
+ Expected:
332
+
333
+ - **Prompt tokens** renders;
334
+ - **Layer diagnostics** contains rows `4`, `14`, `26`;
335
+ - **Representation trajectory** contains:
336
+ - Reconstruction cosine;
337
+ - Top-5 mass;
338
+ - Activation entropy.
339
+
340
+ Do not compare feature ID numbers across layers. Each layer uses a separate SAE dictionary.
341
+
342
+ ---
343
+
344
+ ## J. Adversarial / edge-case tests
345
+
346
+ These are intended to find bugs, not produce attractive screenshots.
347
+
348
+ ### J1 — invalid token index
349
+
350
+ Set **Prompt token index** to an index larger than the prompt length and click **Inspect sparse features**.
351
+
352
+ Expected: a clear UI error explaining the token index is outside the prompt length.
353
+
354
+ ### J2 — inactive custom feature
355
+
356
+ After inspection, enter a custom **Single feature id** that is not active at the selected token and choose `ablate`.
357
+
358
+ Expected: FeatureLens warns that ablation/scaling produces a zero feature delta for an inactive feature.
359
+
360
+ Then switch to `inject` with coefficient `5`.
361
+
362
+ Expected: a non-zero decoder-direction perturbation is still possible.
363
+
364
+ ### J3 — negation prompt
365
+
366
+ Inspect:
367
+
368
+ ```text
369
+ This is not a positive review.
370
+ ```
371
+
372
+ Do not assume a positive-sentiment feature should dominate merely because the word `positive` appears.
373
+
374
+ ### J4 — mixed language
375
+
376
+ Inspect:
377
+
378
+ ```text
379
+ The answer est probablement correct, but I am not certain.
380
+ ```
381
+
382
+ Use this only as a robustness/adversarial check. Do not retrofit semantic labels from one example.
383
+
384
+ ### J5 — target whitespace
385
+
386
+ Compare target continuations `2x` and ` 2x`.
387
+
388
+ They may tokenize differently. v0.3 intentionally scores the exact text entered in **Target continuation (optional)**.
389
+
390
+ ---
391
+
392
+ ## K. Scientific benchmark acceptance
393
+
394
+ After the live Space is stable, run:
395
+
396
+ ```bash
397
+ python experiments/run_all.py
398
+ ```
399
+
400
+ Expected new v0.3 artifact:
401
+
402
+ ```text
403
+ artifacts/feature_set_results.csv
404
+ ```
405
+
406
+ The generated report should use `target_mean_logprob_delta` when the v0.3 causal results are present and should include `figures/feature_set_effects.png`.
407
+
408
+ Before using numbers on a resume or portfolio page, manually inspect:
409
+
410
+ - held-out feature selection discipline;
411
+ - paraphrase split integrity;
412
+ - single-feature SAE vs random paired rows;
413
+ - feature-set SAE vs random paired rows;
414
+ - inactive-feature rate;
415
+ - confidence intervals / sign-flip tests;
416
+ - whether any headline claim is actually supported by the saved raw rows.
experiments/make_report.py CHANGED
@@ -24,9 +24,7 @@ def parse_args() -> argparse.Namespace:
24
 
25
  def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
26
  scored = catalog.copy()
27
- scored['activation_contrast'] = (
28
- scored['activation_rate_pos'] - scored['activation_rate_neg']
29
- )
30
  ordered = scored.sort_values(
31
  ['concept', 'train_auroc', 'activation_contrast'],
32
  ascending=[True, False, False],
@@ -34,7 +32,20 @@ def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
34
  return ordered.groupby('concept', as_index=False).first()
35
 
36
 
37
- def _save_plots(artifact_dir: Path, selected: pd.DataFrame, layers: pd.DataFrame, causal: pd.DataFrame) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  fig_dir = artifact_dir / 'figures'
39
  fig_dir.mkdir(parents=True, exist_ok=True)
40
 
@@ -51,8 +62,18 @@ def _save_plots(artifact_dir: Path, selected: pd.DataFrame, layers: pd.DataFrame
51
 
52
  figure = plt.figure(figsize=(7.0, 4.2))
53
  ax = figure.add_subplot(111)
54
- ax.plot(layers['layer'], layers['linear_probe_macro_auroc'], marker='o', label='Linear probe AUROC')
55
- ax.plot(layers['layer'], layers['reconstruction_cosine'], marker='o', label='SAE reconstruction cosine')
 
 
 
 
 
 
 
 
 
 
56
  ax.set_xlabel('Layer')
57
  ax.set_ylim(0, 1.05)
58
  ax.set_title('Layer-wise representation diagnostics')
@@ -61,22 +82,81 @@ def _save_plots(artifact_dir: Path, selected: pd.DataFrame, layers: pd.DataFrame
61
  figure.savefig(fig_dir / 'layer_diagnostics.png', dpi=160)
62
  plt.close(figure)
63
 
 
64
  grouped = (
65
- causal.groupby(['intervention', 'condition'])['target_logprob_delta']
66
  .apply(lambda values: float(np.mean(np.abs(values))))
67
- .reset_index(name='mean_abs_delta_logp')
68
  )
69
- pivot = grouped.pivot(index='intervention', columns='condition', values='mean_abs_delta_logp')
70
  figure = plt.figure(figsize=(7.0, 4.2))
71
  ax = figure.add_subplot(111)
72
  pivot.plot(kind='bar', ax=ax)
73
- ax.set_ylabel('Mean |Δ log p(target)|')
74
- ax.set_title('SAE interventions vs norm-matched random controls')
75
  ax.tick_params(axis='x', rotation=0)
76
  figure.tight_layout()
77
  figure.savefig(fig_dir / 'causal_effects.png', dpi=160)
78
  plt.close(figure)
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  def main() -> None:
82
  args = parse_args()
@@ -84,8 +164,10 @@ def main() -> None:
84
  layers = pd.read_csv(args.artifact_dir / 'layer_metrics.csv')
85
  stability = pd.read_csv(args.artifact_dir / 'stability.csv')
86
  causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
 
 
87
  selected = _selected_features(catalog)
88
- _save_plots(args.artifact_dir, selected, layers, causal)
89
 
90
  mean_auc = float(selected['auroc'].mean())
91
  median_auc = float(selected['auroc'].median())
@@ -94,68 +176,120 @@ def main() -> None:
94
  mean_jaccard = float(stability['topk_jaccard'].mean())
95
  mean_sparse_cos = float(stability['sparse_cosine'].mean())
96
 
97
- sae = causal[causal['condition'] == 'sae_feature']
98
- random = causal[causal['condition'] == 'random_norm_matched']
99
- sae_abs = float(np.mean(np.abs(sae['target_logprob_delta'])))
100
- random_abs = float(np.mean(np.abs(random['target_logprob_delta'])))
101
- ratio = sae_abs / max(random_abs, 1e-12)
102
-
103
- paired = causal.pivot_table(
104
  index=['task_id', 'intervention'],
105
- columns='condition',
106
- values='target_logprob_delta',
107
- aggfunc='first',
108
- ).dropna(subset=['sae_feature', 'random_norm_matched'])
109
- paired_sae = np.abs(paired['sae_feature'].to_numpy(dtype=float))
110
- paired_random = np.abs(paired['random_norm_matched'].to_numpy(dtype=float))
111
- diff_mean = float(np.mean(paired_sae - paired_random))
112
- diff_ci_low, diff_ci_high = paired_bootstrap_difference_ci(
113
- paired_sae, paired_random, seed=43
114
  )
115
- sign_flip_p = paired_sign_flip_pvalue(paired_sae, paired_random, seed=44)
116
-
117
  active_rate = float(np.mean(sae['feature_activation'] > 0))
118
  top1_change = float(sae['top1_changed'].mean())
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  if mean_auc >= 0.8 and sae_abs < 0.08:
121
  interpretation = (
122
- 'The selected sparse features were strongly predictive on held-out prompts, but their '
123
- 'interventions produced only modest downstream target-probability changes. This supports '
124
- 'the project’s central warning that representation-level correlation need not imply strong '
125
- 'causal control.'
 
 
 
 
 
 
126
  )
127
  elif mean_auc >= 0.8 and sae_abs >= 0.08 and ratio >= 1.5:
128
  interpretation = (
129
- 'The selected sparse features were strongly predictive and their interventions produced '
130
- 'larger target-probability shifts than norm-matched random residual perturbations, providing '
131
- 'evidence that at least some predictive features also have downstream causal influence.'
 
132
  )
133
  elif mean_auc < 0.65:
134
  interpretation = (
135
- 'Feature/concept predictiveness was limited on held-out prompts, so strong causal claims '
136
- 'would be premature. The main result is diagnostic: the chosen concepts or feature-selection '
137
- 'procedure should be refined before interpreting intervention effects.'
138
  )
139
  else:
140
  interpretation = (
141
- 'The results show mixed predictive and causal evidence. FeatureLens therefore reports the '
142
- 'association and intervention measurements separately rather than collapsing them into a '
143
- 'single interpretability score.'
144
  )
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  headline = (
147
- f'Selected SAE features averaged {mean_auc:.3f} held-out AUROC; SAE interventions changed '
148
- f'target log-probability by {sae_abs:.3f} on average versus {random_abs:.3f} for norm-matched '
149
- 'random residual controls.'
150
  )
151
  highlights = [
152
  f'Median selected-feature held-out AUROC: {median_auc:.3f}; mean AUROC 95% bootstrap CI [{auc_ci_low:.3f}, {auc_ci_high:.3f}].',
153
  f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
154
  f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
155
- f'Selected feature active on {active_rate:.1%} of causal prompts; modified top-1 token on {top1_change:.1%}.',
156
- f'Mean absolute causal effect / random-control effect ratio: {ratio:.2f}×.',
157
- f'Paired mean |Δlog p| advantage over random: {diff_mean:+.3f}, 95% bootstrap CI [{diff_ci_low:+.3f}, {diff_ci_high:+.3f}], sign-flip p={sign_flip_p:.4f}.',
158
  ]
 
 
 
 
 
 
 
 
159
  summary = {
160
  'headline': headline,
161
  'highlights': highlights,
@@ -168,14 +302,16 @@ def main() -> None:
168
  'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
169
  'mean_paraphrase_topk_jaccard': mean_jaccard,
170
  'mean_paraphrase_sparse_cosine': mean_sparse_cos,
171
- 'mean_abs_sae_target_logprob_delta': sae_abs,
172
- 'mean_abs_random_target_logprob_delta': random_abs,
 
173
  'causal_to_random_effect_ratio': ratio,
174
- 'paired_mean_abs_effect_advantage': diff_mean,
175
- 'paired_mean_abs_effect_advantage_bootstrap_ci_95': [diff_ci_low, diff_ci_high],
176
- 'paired_sign_flip_pvalue': sign_flip_p,
177
  'causal_prompt_feature_active_rate': active_rate,
178
  'sae_top1_change_rate': top1_change,
 
179
  },
180
  }
181
  (args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
@@ -200,15 +336,17 @@ def main() -> None:
200
  '## Experimental design',
201
  '',
202
  '- Model: Qwen3-1.7B-Base.',
203
- '- SAEs: Qwen-Scope residual-stream TopK SAEs at the configured early/middle/late layers.',
204
  '- Discovery set: controlled concept prompts with paired paraphrases.',
205
- '- Split discipline: paraphrase groups are kept entirely in train or held-out test.',
206
  '- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
207
  '- Linear baseline: multinomial logistic regression on the dense residual stream.',
208
- '- Causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
209
- '- Negative control: deterministic random residual direction matched to the SAE perturbation L2 norm.',
210
- '- Uncertainty: bootstrap 95% confidence intervals and a paired sign-flip randomization test for SAE-vs-control effect differences.',
211
- '- Behavioural metric: first-token target probability/log-probability, rank, JS divergence, and top-1 changes.',
 
 
212
  '',
213
  '## Figures',
214
  '',
@@ -216,17 +354,23 @@ def main() -> None:
216
  '',
217
  '![Layer diagnostics](figures/layer_diagnostics.png)',
218
  '',
219
- '![Causal effects](figures/causal_effects.png)',
220
- '',
221
- '## Interpretation guardrails',
222
- '',
223
- 'A high feature/concept AUROC is treated as correlational evidence only. Causal evidence requires a downstream change under intervention and is interpreted relative to the norm-matched random control. The narrative above is generated from saved metrics; no result values are hard-coded.',
224
- '',
225
- '## Reproducibility',
226
- '',
227
- 'Run `python experiments/run_all.py` from the repository root. Raw activation matrices, splits, selected features, causal rows, figures, and this report are all materialized under `artifacts/`.',
228
- '',
229
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  (args.artifact_dir / 'report.md').write_text('\n'.join(lines), encoding='utf-8')
231
  print(headline)
232
  print(f'Wrote {args.artifact_dir / "report.md"}')
 
24
 
25
  def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
26
  scored = catalog.copy()
27
+ scored['activation_contrast'] = scored['activation_rate_pos'] - scored['activation_rate_neg']
 
 
28
  ordered = scored.sort_values(
29
  ['concept', 'train_auroc', 'activation_contrast'],
30
  ascending=[True, False, False],
 
32
  return ordered.groupby('concept', as_index=False).first()
33
 
34
 
35
+ def _effect_column(frame: pd.DataFrame) -> str:
36
+ """Prefer the v0.3 length-normalized full-continuation metric, with v0.2 fallback."""
37
+ if 'target_mean_logprob_delta' in frame.columns:
38
+ return 'target_mean_logprob_delta'
39
+ return 'target_logprob_delta'
40
+
41
+
42
+ def _save_plots(
43
+ artifact_dir: Path,
44
+ selected: pd.DataFrame,
45
+ layers: pd.DataFrame,
46
+ causal: pd.DataFrame,
47
+ feature_sets: pd.DataFrame | None,
48
+ ) -> None:
49
  fig_dir = artifact_dir / 'figures'
50
  fig_dir.mkdir(parents=True, exist_ok=True)
51
 
 
62
 
63
  figure = plt.figure(figsize=(7.0, 4.2))
64
  ax = figure.add_subplot(111)
65
+ ax.plot(
66
+ layers['layer'],
67
+ layers['linear_probe_macro_auroc'],
68
+ marker='o',
69
+ label='Linear probe AUROC',
70
+ )
71
+ ax.plot(
72
+ layers['layer'],
73
+ layers['reconstruction_cosine'],
74
+ marker='o',
75
+ label='SAE reconstruction cosine',
76
+ )
77
  ax.set_xlabel('Layer')
78
  ax.set_ylim(0, 1.05)
79
  ax.set_title('Layer-wise representation diagnostics')
 
82
  figure.savefig(fig_dir / 'layer_diagnostics.png', dpi=160)
83
  plt.close(figure)
84
 
85
+ causal_metric = _effect_column(causal)
86
  grouped = (
87
+ causal.groupby(['intervention', 'condition'])[causal_metric]
88
  .apply(lambda values: float(np.mean(np.abs(values))))
89
+ .reset_index(name='mean_abs_effect')
90
  )
91
+ pivot = grouped.pivot(index='intervention', columns='condition', values='mean_abs_effect')
92
  figure = plt.figure(figsize=(7.0, 4.2))
93
  ax = figure.add_subplot(111)
94
  pivot.plot(kind='bar', ax=ax)
95
+ ax.set_ylabel('Mean |Δ mean log p/token|' if causal_metric == 'target_mean_logprob_delta' else 'Mean |Δ log p(target)|')
96
+ ax.set_title('Single-feature SAE edits vs norm-matched controls')
97
  ax.tick_params(axis='x', rotation=0)
98
  figure.tight_layout()
99
  figure.savefig(fig_dir / 'causal_effects.png', dpi=160)
100
  plt.close(figure)
101
 
102
+ if feature_sets is not None and not feature_sets.empty:
103
+ set_metric = _effect_column(feature_sets)
104
+ grouped_sets = (
105
+ feature_sets.groupby(['set_size', 'condition'])[set_metric]
106
+ .apply(lambda values: float(np.mean(np.abs(values))))
107
+ .reset_index(name='mean_abs_effect')
108
+ )
109
+ set_pivot = grouped_sets.pivot(index='set_size', columns='condition', values='mean_abs_effect')
110
+ figure = plt.figure(figsize=(7.0, 4.2))
111
+ ax = figure.add_subplot(111)
112
+ set_pivot.plot(kind='line', marker='o', ax=ax)
113
+ ax.set_xlabel('Jointly ablated feature count')
114
+ ax.set_ylabel('Mean |Δ mean log p/token|' if set_metric == 'target_mean_logprob_delta' else 'Mean |Δ log p(target)|')
115
+ ax.set_title('Distributed feature-set causal effect')
116
+ figure.tight_layout()
117
+ figure.savefig(fig_dir / 'feature_set_effects.png', dpi=160)
118
+ plt.close(figure)
119
+
120
+
121
+ def _paired_stats(
122
+ frame: pd.DataFrame,
123
+ *,
124
+ index: list[str],
125
+ sae_condition: str,
126
+ random_condition: str,
127
+ seed: int,
128
+ ) -> dict[str, float | list[float]]:
129
+ metric = _effect_column(frame)
130
+ paired = frame.pivot_table(
131
+ index=index,
132
+ columns='condition',
133
+ values=metric,
134
+ aggfunc='first',
135
+ ).dropna(subset=[sae_condition, random_condition])
136
+ sae_abs = np.abs(paired[sae_condition].to_numpy(dtype=float))
137
+ random_abs = np.abs(paired[random_condition].to_numpy(dtype=float))
138
+ if sae_abs.size == 0:
139
+ return {
140
+ 'sae_abs': float('nan'),
141
+ 'random_abs': float('nan'),
142
+ 'ratio': float('nan'),
143
+ 'paired_advantage': float('nan'),
144
+ 'ci': [float('nan'), float('nan')],
145
+ 'pvalue': float('nan'),
146
+ 'n_pairs': 0,
147
+ }
148
+ diff = sae_abs - random_abs
149
+ low, high = paired_bootstrap_difference_ci(sae_abs, random_abs, seed=seed)
150
+ return {
151
+ 'sae_abs': float(np.mean(sae_abs)),
152
+ 'random_abs': float(np.mean(random_abs)),
153
+ 'ratio': float(np.mean(sae_abs) / max(float(np.mean(random_abs)), 1e-12)),
154
+ 'paired_advantage': float(np.mean(diff)),
155
+ 'ci': [float(low), float(high)],
156
+ 'pvalue': float(paired_sign_flip_pvalue(sae_abs, random_abs, seed=seed + 1)),
157
+ 'n_pairs': int(sae_abs.size),
158
+ }
159
+
160
 
161
  def main() -> None:
162
  args = parse_args()
 
164
  layers = pd.read_csv(args.artifact_dir / 'layer_metrics.csv')
165
  stability = pd.read_csv(args.artifact_dir / 'stability.csv')
166
  causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
167
+ feature_set_path = args.artifact_dir / 'feature_set_results.csv'
168
+ feature_sets = pd.read_csv(feature_set_path) if feature_set_path.exists() else None
169
  selected = _selected_features(catalog)
170
+ _save_plots(args.artifact_dir, selected, layers, causal, feature_sets)
171
 
172
  mean_auc = float(selected['auroc'].mean())
173
  median_auc = float(selected['auroc'].median())
 
176
  mean_jaccard = float(stability['topk_jaccard'].mean())
177
  mean_sparse_cos = float(stability['sparse_cosine'].mean())
178
 
179
+ single = _paired_stats(
180
+ causal,
 
 
 
 
 
181
  index=['task_id', 'intervention'],
182
+ sae_condition='sae_feature',
183
+ random_condition='random_norm_matched',
184
+ seed=43,
 
 
 
 
 
 
185
  )
186
+ sae = causal[causal['condition'] == 'sae_feature']
 
187
  active_rate = float(np.mean(sae['feature_activation'] > 0))
188
  top1_change = float(sae['top1_changed'].mean())
189
 
190
+ set_summary: dict[int, dict[str, float | list[float]]] = {}
191
+ largest_set: dict[str, float | list[float]] | None = None
192
+ largest_k: int | None = None
193
+ if feature_sets is not None and not feature_sets.empty:
194
+ for size in sorted(int(x) for x in feature_sets['set_size'].unique()):
195
+ subset = feature_sets[feature_sets['set_size'] == size]
196
+ set_summary[size] = _paired_stats(
197
+ subset,
198
+ index=['task_id', 'set_size'],
199
+ sae_condition='sae_feature_set',
200
+ random_condition='random_norm_matched',
201
+ seed=100 + size,
202
+ )
203
+ largest_k = max(set_summary)
204
+ largest_set = set_summary[largest_k]
205
+
206
+ sae_abs = float(single['sae_abs'])
207
+ random_abs = float(single['random_abs'])
208
+ ratio = float(single['ratio'])
209
+
210
+ single_ci_low = float(single['ci'][0])
211
+ single_p = float(single['pvalue'])
212
+ single_specific = ratio >= 1.5 and single_ci_low > 0.0 and single_p < 0.05
213
+
214
  if mean_auc >= 0.8 and sae_abs < 0.08:
215
  interpretation = (
216
+ 'The selected sparse features were strongly predictive on held-out prompts, but single-feature '
217
+ 'interventions produced only modest downstream changes. FeatureLens therefore treats the '
218
+ 'representation-level signal as correlational rather than automatically causal.'
219
+ )
220
+ elif mean_auc >= 0.8 and sae_abs >= 0.08 and single_specific:
221
+ interpretation = (
222
+ 'The selected sparse features were strongly predictive and single-feature interventions produced '
223
+ 'larger target-continuation shifts than norm-matched random residual perturbations. The paired '
224
+ 'bootstrap interval excludes zero and the sign-flip test passes the configured 0.05 threshold, '
225
+ 'supporting a causal-specificity claim for at least some predictive features.'
226
  )
227
  elif mean_auc >= 0.8 and sae_abs >= 0.08 and ratio >= 1.5:
228
  interpretation = (
229
+ 'The selected sparse features were strongly predictive and their point-estimate intervention '
230
+ 'effects exceeded norm-matched random controls, but the paired uncertainty test does not support '
231
+ 'a strong causal-specificity claim at the 0.05 threshold. The result is reported as suggestive '
232
+ 'rather than conclusive.'
233
  )
234
  elif mean_auc < 0.65:
235
  interpretation = (
236
+ 'Feature/concept predictiveness was limited on held-out prompts, so strong causal claims would '
237
+ 'be premature. The main result is diagnostic: concept design or feature selection should be '
238
+ 'refined before interpreting intervention effects.'
239
  )
240
  else:
241
  interpretation = (
242
+ 'The results show mixed predictive and causal evidence. FeatureLens reports association, '
243
+ 'robustness, and intervention measurements separately rather than collapsing them into one score.'
 
244
  )
245
 
246
+ if largest_set is not None and largest_k is not None:
247
+ set_advantage = float(largest_set['paired_advantage'])
248
+ set_ci_low = float(largest_set['ci'][0])
249
+ set_p = float(largest_set['pvalue'])
250
+ set_specific = set_ci_low > 0.0 and set_p < 0.05
251
+ if set_advantage > float(single['paired_advantage']) + 0.02 and set_specific:
252
+ interpretation += (
253
+ f' Joint ablation of the top {largest_k} same-layer concept features produced a larger '
254
+ 'paired advantage over random controls than the single-feature edits, with paired uncertainty '
255
+ 'supporting the difference. This is consistent with causal influence being distributed across '
256
+ 'a sparse feature set rather than concentrated in one unit.'
257
+ )
258
+ elif set_advantage > float(single['paired_advantage']) + 0.02:
259
+ interpretation += (
260
+ f' The top-{largest_k} joint-ablation point estimate exceeded the single-feature advantage, '
261
+ 'but its paired uncertainty test does not support a strong distributed-causality claim at the '
262
+ '0.05 threshold. The pattern is therefore treated as suggestive only.'
263
+ )
264
+ elif abs(set_advantage) <= 0.02:
265
+ interpretation += (
266
+ f' Expanding the intervention to the top {largest_k} same-layer features did not materially '
267
+ 'increase specificity over random controls, which argues against assuming that a broader '
268
+ 'concept-associated sparse subspace is automatically more causal.'
269
+ )
270
+
271
+ effect_label = 'mean log p/token' if _effect_column(causal) == 'target_mean_logprob_delta' else 'target log-probability'
272
  headline = (
273
+ f'Selected SAE features averaged {mean_auc:.3f} held-out AUROC; single-feature SAE interventions '
274
+ f'changed {effect_label} by {sae_abs:.3f} in absolute value on average versus {random_abs:.3f} '
275
+ 'for norm-matched random residual controls.'
276
  )
277
  highlights = [
278
  f'Median selected-feature held-out AUROC: {median_auc:.3f}; mean AUROC 95% bootstrap CI [{auc_ci_low:.3f}, {auc_ci_high:.3f}].',
279
  f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
280
  f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
281
+ f'Selected feature active on {active_rate:.1%} of causal prompts; modified next-token top-1 on {top1_change:.1%}.',
282
+ f'Single-feature mean absolute causal effect / random-control effect ratio: {ratio:.2f}×.',
283
+ f'Single-feature paired mean |effect| advantage over random: {float(single["paired_advantage"]):+.3f}, 95% bootstrap CI [{float(single["ci"][0]):+.3f}, {float(single["ci"][1]):+.3f}], sign-flip p={float(single["pvalue"]):.4f}.',
284
  ]
285
+ if largest_set is not None and largest_k is not None:
286
+ highlights.append(
287
+ f'Top-{largest_k} joint ablation: SAE/random mean absolute effect ratio {float(largest_set["ratio"]):.2f}×; '
288
+ f'paired advantage {float(largest_set["paired_advantage"]):+.3f}, 95% CI '
289
+ f'[{float(largest_set["ci"][0]):+.3f}, {float(largest_set["ci"][1]):+.3f}], '
290
+ f'sign-flip p={float(largest_set["pvalue"]):.4f}.'
291
+ )
292
+
293
  summary = {
294
  'headline': headline,
295
  'highlights': highlights,
 
302
  'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
303
  'mean_paraphrase_topk_jaccard': mean_jaccard,
304
  'mean_paraphrase_sparse_cosine': mean_sparse_cos,
305
+ 'single_feature_effect_metric': _effect_column(causal),
306
+ 'mean_abs_sae_effect': sae_abs,
307
+ 'mean_abs_random_effect': random_abs,
308
  'causal_to_random_effect_ratio': ratio,
309
+ 'paired_mean_abs_effect_advantage': float(single['paired_advantage']),
310
+ 'paired_mean_abs_effect_advantage_bootstrap_ci_95': single['ci'],
311
+ 'paired_sign_flip_pvalue': float(single['pvalue']),
312
  'causal_prompt_feature_active_rate': active_rate,
313
  'sae_top1_change_rate': top1_change,
314
+ 'feature_set_results': {str(k): value for k, value in set_summary.items()},
315
  },
316
  }
317
  (args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
 
336
  '## Experimental design',
337
  '',
338
  '- Model: Qwen3-1.7B-Base.',
339
+ '- SAEs: Qwen-Scope residual-stream TopK SAEs at configured early/middle/late layers.',
340
  '- Discovery set: controlled concept prompts with paired paraphrases.',
341
+ '- Split discipline: paraphrase groups stay entirely in train or held-out test.',
342
  '- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
343
  '- Linear baseline: multinomial logistic regression on the dense residual stream.',
344
+ '- Single-feature causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
345
+ '- Feature-set causal edit: joint ablation of top same-layer concept features, evaluated at k=1/3/5 by default.',
346
+ '- Negative control: deterministic random residual direction matched to each SAE perturbation L2 norm.',
347
+ '- Target metric: exact full target continuation scored teacher-forced; mean log probability per target token is the primary length-comparable effect.',
348
+ '- Secondary diagnostics: first-token probability/rank, next-token JS divergence, and top-1 changes.',
349
+ '- Uncertainty: bootstrap 95% confidence intervals and paired sign-flip randomization tests.',
350
  '',
351
  '## Figures',
352
  '',
 
354
  '',
355
  '![Layer diagnostics](figures/layer_diagnostics.png)',
356
  '',
357
+ '![Single-feature causal effects](figures/causal_effects.png)',
 
 
 
 
 
 
 
 
 
358
  ]
359
+ if feature_sets is not None and not feature_sets.empty:
360
+ lines.extend(['', '![Feature-set causal effects](figures/feature_set_effects.png)'])
361
+ lines.extend(
362
+ [
363
+ '',
364
+ '## Interpretation guardrails',
365
+ '',
366
+ 'A high feature/concept AUROC or high paraphrase overlap is correlational evidence only. Causal evidence requires a downstream change under intervention and is interpreted relative to a norm-matched random control. Feature-set effects are not assumed stronger a priori; they are separately measured. The narrative above is generated from saved metrics, with no hard-coded result values.',
367
+ '',
368
+ '## Reproducibility',
369
+ '',
370
+ 'Run `python experiments/run_all.py` from the repository root. Raw activation matrices, splits, selected features, single-feature rows, feature-set rows, figures, and this report are materialized under `artifacts/`.',
371
+ '',
372
+ ]
373
+ )
374
  (args.artifact_dir / 'report.md').write_text('\n'.join(lines), encoding='utf-8')
375
  print(headline)
376
  print(f'Wrote {args.artifact_dir / "report.md"}')
experiments/run_all.py CHANGED
@@ -19,6 +19,7 @@ def main() -> None:
19
  run('collect_activations.py')
20
  run('evaluate_features.py')
21
  run('run_causal.py')
 
22
  run('make_report.py')
23
  print('\nFeatureLens experiment pipeline complete. See artifacts/report.md')
24
 
 
19
  run('collect_activations.py')
20
  run('evaluate_features.py')
21
  run('run_causal.py')
22
+ run('run_feature_sets.py')
23
  run('make_report.py')
24
  print('\nFeatureLens experiment pipeline complete. See artifacts/report.md')
25
 
experiments/run_causal.py CHANGED
@@ -11,7 +11,7 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
11
  from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
12
  from featurelens.config import SETTINGS
13
  from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
14
- from featurelens.metrics import js_divergence_from_logits, safe_log_probability
15
  from featurelens.sae import SAEStore
16
 
17
 
@@ -25,7 +25,6 @@ def parse_args() -> argparse.Namespace:
25
 
26
 
27
  def load_selected_features(path: Path) -> dict[str, dict]:
28
- rows = []
29
  with path.open(newline='', encoding='utf-8') as handle:
30
  rows = list(csv.DictReader(handle))
31
  selected: dict[str, dict] = {}
@@ -54,6 +53,18 @@ def replace_hidden(output, hidden):
54
  return (hidden, *output[1:]) if isinstance(output, tuple) else hidden
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  @torch.inference_mode()
58
  def main() -> None:
59
  args = parse_args()
@@ -91,8 +102,14 @@ def main() -> None:
91
  layer = int(choice['layer'])
92
  feature_id = int(choice['feature_id'])
93
  sae = sae_store.get(layer)
94
- inputs = tokenizer(task['prompt'], return_tensors='pt', truncation=True, max_length=192)
95
- inputs = {key: value.to(device) for key, value in inputs.items()}
 
 
 
 
 
 
96
  capture: dict = {}
97
 
98
  def capture_hook(_module, _inp, output):
@@ -100,87 +117,125 @@ def main() -> None:
100
  capture['hidden'] = hidden_from_output(output).detach()
101
 
102
  handle = model.model.layers[layer].register_forward_hook(capture_hook)
103
- baseline_out = model(**inputs, use_cache=False)
104
  handle.remove()
105
- baseline_logits = baseline_out.logits[0, -1]
106
- residual = capture['hidden'][0, -1]
 
 
 
 
 
 
 
107
  encoding = sae.encode(residual)
108
  original_activation = encoding.activation_for(feature_id)
109
-
110
- target_ids = tokenizer(task['target'], add_special_tokens=False)['input_ids']
111
- if not target_ids:
112
- raise RuntimeError(f"Target tokenization empty for task {task['id']}")
113
- target_id = int(target_ids[0])
114
- baseline_prob = float(torch.softmax(baseline_logits.float(), dim=-1)[target_id].item())
115
- baseline_rank = int((baseline_logits > baseline_logits[target_id]).sum().item()) + 1
116
- baseline_top1 = int(torch.argmax(baseline_logits).item())
117
 
118
  specs = [
119
  ('ablate', InterventionSpec('ablate', 0.0)),
120
  ('amplify_2x', InterventionSpec('scale', 2.0)),
121
  ]
122
- for intervention_name, spec in specs:
 
123
  delta = residual_delta(sae.decoder_direction(feature_id), original_activation, spec)
124
- control_delta = normalized_random_control(delta, seed=args.seed + task_idx * 17 + len(results))
125
-
126
- for condition, applied_delta in (
127
- ('sae_feature', delta),
128
- ('random_norm_matched', control_delta),
129
- ):
130
- applied = {'done': False}
131
-
132
- def edit_hook(_module, _inp, output, *, edit=applied_delta, state=applied):
133
- if state['done']:
134
- return output
135
- hidden = hidden_from_output(output)
136
- modified = hidden.clone()
137
- modified[:, -1, :] = modified[:, -1, :] + edit.to(hidden.device, hidden.dtype)
138
- state['done'] = True
139
- return replace_hidden(output, modified)
140
-
141
- hook = model.model.layers[layer].register_forward_hook(edit_hook)
142
- modified_out = model(**inputs, use_cache=False)
143
- hook.remove()
144
- modified_logits = modified_out.logits[0, -1]
145
- modified_prob = float(
146
- torch.softmax(modified_logits.float(), dim=-1)[target_id].item()
147
- )
148
- modified_rank = int((modified_logits > modified_logits[target_id]).sum().item()) + 1
149
- modified_top1 = int(torch.argmax(modified_logits).item())
150
- results.append(
151
- {
152
- 'task_id': task['id'],
153
- 'concept': concept,
154
- 'prompt': task['prompt'],
155
- 'target_text': task['target'],
156
- 'target_first_token': tokenizer.decode([target_id]),
157
- 'target_token_count': len(target_ids),
158
- 'layer': layer,
159
- 'feature_id': feature_id,
160
- 'feature_train_auroc': choice['train_auroc'],
161
- 'feature_test_auroc': choice['test_auroc'],
162
- 'feature_test_f1': choice['test_f1'],
163
- 'feature_activation': original_activation,
164
- 'intervention': intervention_name,
165
- 'condition': condition,
166
- 'delta_activation': spec.delta_activation(original_activation)
167
- if condition == 'sae_feature'
168
- else math.nan,
169
- 'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
170
- 'baseline_target_prob': baseline_prob,
171
- 'modified_target_prob': modified_prob,
172
- 'target_prob_delta': modified_prob - baseline_prob,
173
- 'target_logprob_delta': safe_log_probability(modified_prob)
174
- - safe_log_probability(baseline_prob),
175
- 'baseline_target_rank': baseline_rank,
176
- 'modified_target_rank': modified_rank,
177
- 'target_rank_delta': modified_rank - baseline_rank,
178
- 'js_divergence': js_divergence_from_logits(
179
- baseline_logits, modified_logits
180
- ),
181
- 'top1_changed': int(modified_top1 != baseline_top1),
182
- }
183
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  print(f"Causal task {task_idx + 1}/{len(tasks)}: {concept}", flush=True)
185
 
186
  args.output.parent.mkdir(parents=True, exist_ok=True)
 
11
  from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
12
  from featurelens.config import SETTINGS
13
  from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
14
+ from featurelens.metrics import js_divergence_from_logits, sequence_logprob_summary
15
  from featurelens.sae import SAEStore
16
 
17
 
 
25
 
26
 
27
  def load_selected_features(path: Path) -> dict[str, dict]:
 
28
  with path.open(newline='', encoding='utf-8') as handle:
29
  rows = list(csv.DictReader(handle))
30
  selected: dict[str, dict] = {}
 
53
  return (hidden, *output[1:]) if isinstance(output, tuple) else hidden
54
 
55
 
56
+ def append_target(inputs: dict[str, torch.Tensor], target_ids: list[int]) -> dict[str, torch.Tensor]:
57
+ prompt_ids = inputs['input_ids']
58
+ target = torch.tensor(target_ids, dtype=prompt_ids.dtype, device=prompt_ids.device).unsqueeze(0)
59
+ full_ids = torch.cat([prompt_ids, target], dim=1)
60
+ attention = inputs.get('attention_mask', torch.ones_like(prompt_ids))
61
+ target_mask = torch.ones((1, len(target_ids)), dtype=attention.dtype, device=attention.device)
62
+ return {
63
+ 'input_ids': full_ids,
64
+ 'attention_mask': torch.cat([attention, target_mask], dim=1),
65
+ }
66
+
67
+
68
  @torch.inference_mode()
69
  def main() -> None:
70
  args = parse_args()
 
102
  layer = int(choice['layer'])
103
  feature_id = int(choice['feature_id'])
104
  sae = sae_store.get(layer)
105
+ prompt_inputs = tokenizer(task['prompt'], return_tensors='pt', truncation=True, max_length=192)
106
+ prompt_inputs = {key: value.to(device) for key, value in prompt_inputs.items()}
107
+ prompt_len = int(prompt_inputs['input_ids'].shape[1])
108
+ target_ids = tokenizer(task['target'], add_special_tokens=False)['input_ids']
109
+ if not target_ids:
110
+ raise RuntimeError(f"Target tokenization empty for task {task['id']}")
111
+ target_ids = [int(x) for x in target_ids]
112
+ full_inputs = append_target(prompt_inputs, target_ids)
113
  capture: dict = {}
114
 
115
  def capture_hook(_module, _inp, output):
 
117
  capture['hidden'] = hidden_from_output(output).detach()
118
 
119
  handle = model.model.layers[layer].register_forward_hook(capture_hook)
120
+ baseline_out = model(**full_inputs, use_cache=False)
121
  handle.remove()
122
+
123
+ baseline_logits = baseline_out.logits[0]
124
+ baseline_next = baseline_logits[prompt_len - 1]
125
+ baseline_seq, baseline_mean, _ = sequence_logprob_summary(
126
+ baseline_logits,
127
+ prompt_length=prompt_len,
128
+ target_ids=target_ids,
129
+ )
130
+ residual = capture['hidden'][0, prompt_len - 1]
131
  encoding = sae.encode(residual)
132
  original_activation = encoding.activation_for(feature_id)
133
+ target_id = target_ids[0]
134
+ baseline_prob = float(torch.softmax(baseline_next.float(), dim=-1)[target_id].item())
135
+ baseline_rank = int((baseline_next > baseline_next[target_id]).sum().item()) + 1
136
+ baseline_top1 = int(torch.argmax(baseline_next).item())
 
 
 
 
137
 
138
  specs = [
139
  ('ablate', InterventionSpec('ablate', 0.0)),
140
  ('amplify_2x', InterventionSpec('scale', 2.0)),
141
  ]
142
+ condition_meta: list[tuple[str, str, InterventionSpec, torch.Tensor, float]] = []
143
+ for spec_idx, (intervention_name, spec) in enumerate(specs):
144
  delta = residual_delta(sae.decoder_direction(feature_id), original_activation, spec)
145
+ control_delta = normalized_random_control(
146
+ delta,
147
+ seed=args.seed + task_idx * 101 + spec_idx,
148
+ )
149
+ condition_meta.extend(
150
+ [
151
+ (
152
+ intervention_name,
153
+ 'sae_feature',
154
+ spec,
155
+ delta,
156
+ float(spec.delta_activation(original_activation)),
157
+ ),
158
+ (
159
+ intervention_name,
160
+ 'random_norm_matched',
161
+ spec,
162
+ control_delta,
163
+ math.nan,
164
+ ),
165
+ ]
166
+ )
167
+
168
+ repeated = {key: value.repeat(len(condition_meta), 1) for key, value in full_inputs.items()}
169
+ deltas = torch.stack([item[3] for item in condition_meta], dim=0)
170
+ applied = {'done': False}
171
+
172
+ def batch_edit_hook(_module, _inp, output):
173
+ if applied['done']:
174
+ return output
175
+ hidden = hidden_from_output(output)
176
+ modified = hidden.clone()
177
+ modified[:, prompt_len - 1, :] = (
178
+ modified[:, prompt_len - 1, :]
179
+ + deltas.to(hidden.device, hidden.dtype)
180
+ )
181
+ applied['done'] = True
182
+ return replace_hidden(output, modified)
183
+
184
+ hook = model.model.layers[layer].register_forward_hook(batch_edit_hook)
185
+ edited_out = model(**repeated, use_cache=False)
186
+ hook.remove()
187
+
188
+ for row_idx, (intervention_name, condition, spec, applied_delta, delta_activation) in enumerate(condition_meta):
189
+ modified_logits = edited_out.logits[row_idx]
190
+ modified_next = modified_logits[prompt_len - 1]
191
+ modified_prob = float(torch.softmax(modified_next.float(), dim=-1)[target_id].item())
192
+ modified_rank = int((modified_next > modified_next[target_id]).sum().item()) + 1
193
+ modified_top1 = int(torch.argmax(modified_next).item())
194
+ modified_seq, modified_mean, _ = sequence_logprob_summary(
195
+ modified_logits,
196
+ prompt_length=prompt_len,
197
+ target_ids=target_ids,
198
+ )
199
+ results.append(
200
+ {
201
+ 'task_id': task['id'],
202
+ 'concept': concept,
203
+ 'prompt': task['prompt'],
204
+ 'target_text': task['target'],
205
+ 'target_first_token': tokenizer.decode([target_id]),
206
+ 'target_token_count': len(target_ids),
207
+ 'layer': layer,
208
+ 'feature_id': feature_id,
209
+ 'feature_train_auroc': choice['train_auroc'],
210
+ 'feature_test_auroc': choice['test_auroc'],
211
+ 'feature_test_f1': choice['test_f1'],
212
+ 'feature_activation': original_activation,
213
+ 'intervention': intervention_name,
214
+ 'condition': condition,
215
+ 'delta_activation': delta_activation,
216
+ 'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
217
+ # First-token metrics retained for backwards compatibility and local diagnostics.
218
+ 'baseline_target_prob': baseline_prob,
219
+ 'modified_target_prob': modified_prob,
220
+ 'target_prob_delta': modified_prob - baseline_prob,
221
+ 'target_logprob_delta': float(
222
+ torch.log_softmax(modified_next.float(), dim=-1)[target_id].item()
223
+ - torch.log_softmax(baseline_next.float(), dim=-1)[target_id].item()
224
+ ),
225
+ 'baseline_target_rank': baseline_rank,
226
+ 'modified_target_rank': modified_rank,
227
+ 'target_rank_delta': modified_rank - baseline_rank,
228
+ # v0.3 primary target metric: exact full continuation, teacher-forced.
229
+ 'baseline_target_sequence_logprob': baseline_seq,
230
+ 'modified_target_sequence_logprob': modified_seq,
231
+ 'target_sequence_logprob_delta': modified_seq - baseline_seq,
232
+ 'baseline_target_mean_logprob': baseline_mean,
233
+ 'modified_target_mean_logprob': modified_mean,
234
+ 'target_mean_logprob_delta': modified_mean - baseline_mean,
235
+ 'js_divergence': js_divergence_from_logits(baseline_next, modified_next),
236
+ 'top1_changed': int(modified_top1 != baseline_top1),
237
+ }
238
+ )
239
  print(f"Causal task {task_idx + 1}/{len(tasks)}: {concept}", flush=True)
240
 
241
  args.output.parent.mkdir(parents=True, exist_ok=True)
experiments/run_feature_sets.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ from pathlib import Path
6
+
7
+ import torch
8
+ from transformers import AutoModelForCausalLM, AutoTokenizer
9
+
10
+ from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
11
+ from featurelens.config import SETTINGS
12
+ from featurelens.interventions import InterventionSpec, joint_residual_delta, normalized_random_control
13
+ from featurelens.metrics import js_divergence_from_logits, sequence_logprob_summary
14
+ from featurelens.sae import SAEStore
15
+ from featurelens.selection import load_feature_sets
16
+
17
+
18
+ def parse_args() -> argparse.Namespace:
19
+ parser = argparse.ArgumentParser(description='Run top-k joint SAE feature-set ablations.')
20
+ parser.add_argument('--tasks', type=Path, default=DATA_DIR / 'causal_tasks.jsonl')
21
+ parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
22
+ parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'feature_set_results.csv')
23
+ parser.add_argument('--sizes', type=int, nargs='+', default=[1, 3, 5])
24
+ parser.add_argument('--seed', type=int, default=42)
25
+ return parser.parse_args()
26
+
27
+
28
+ def hidden_from_output(output):
29
+ return output[0] if isinstance(output, tuple) else output
30
+
31
+
32
+ def replace_hidden(output, hidden):
33
+ return (hidden, *output[1:]) if isinstance(output, tuple) else hidden
34
+
35
+
36
+ def append_target(inputs: dict[str, torch.Tensor], target_ids: list[int]) -> dict[str, torch.Tensor]:
37
+ prompt_ids = inputs['input_ids']
38
+ target = torch.tensor(target_ids, dtype=prompt_ids.dtype, device=prompt_ids.device).unsqueeze(0)
39
+ attention = inputs.get('attention_mask', torch.ones_like(prompt_ids))
40
+ target_mask = torch.ones((1, len(target_ids)), dtype=attention.dtype, device=attention.device)
41
+ return {
42
+ 'input_ids': torch.cat([prompt_ids, target], dim=1),
43
+ 'attention_mask': torch.cat([attention, target_mask], dim=1),
44
+ }
45
+
46
+
47
+
48
+ @torch.inference_mode()
49
+ def main() -> None:
50
+ args = parse_args()
51
+ set_seed(args.seed)
52
+ sizes = sorted({int(size) for size in args.sizes if int(size) > 0})
53
+ if not sizes:
54
+ raise ValueError('At least one positive feature-set size is required.')
55
+
56
+ tasks = load_jsonl(args.tasks)
57
+ selected = load_feature_sets(args.catalog, max(sizes))
58
+ missing = sorted({task['concept'] for task in tasks}.difference(selected))
59
+ if missing:
60
+ raise RuntimeError(f'No feature sets for concepts: {missing}')
61
+
62
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
63
+ model_dtype = torch.float16 if device.type == 'cuda' else torch.float32
64
+ tokenizer = AutoTokenizer.from_pretrained(SETTINGS.model_id)
65
+ if tokenizer.pad_token_id is None:
66
+ tokenizer.pad_token = tokenizer.eos_token
67
+ model = AutoModelForCausalLM.from_pretrained(
68
+ SETTINGS.model_id,
69
+ torch_dtype=model_dtype,
70
+ low_cpu_mem_usage=True,
71
+ ).to(device)
72
+ model.eval()
73
+ layers = sorted({int(item['layer']) for item in selected.values()})
74
+ sae_store = SAEStore(
75
+ SETTINGS.sae_repo_id,
76
+ layers=layers,
77
+ device=device,
78
+ dtype=torch.float32,
79
+ top_k=SETTINGS.sae_top_k,
80
+ )
81
+
82
+ results: list[dict] = []
83
+ for task_idx, task in enumerate(tasks):
84
+ concept = task['concept']
85
+ layer = int(selected[concept]['layer'])
86
+ candidate_ids = [int(x) for x in selected[concept]['feature_ids']]
87
+ sae = sae_store.get(layer)
88
+ prompt_inputs = tokenizer(task['prompt'], return_tensors='pt', truncation=True, max_length=192)
89
+ prompt_inputs = {key: value.to(device) for key, value in prompt_inputs.items()}
90
+ prompt_len = int(prompt_inputs['input_ids'].shape[1])
91
+ target_ids = tokenizer(task['target'], add_special_tokens=False)['input_ids']
92
+ if not target_ids:
93
+ raise RuntimeError(f"Target tokenization empty for task {task['id']}")
94
+ target_ids = [int(x) for x in target_ids]
95
+ full_inputs = append_target(prompt_inputs, target_ids)
96
+
97
+ capture: dict = {}
98
+
99
+ def capture_hook(_module, _inp, output):
100
+ if 'hidden' not in capture:
101
+ capture['hidden'] = hidden_from_output(output).detach()
102
+
103
+ handle = model.model.layers[layer].register_forward_hook(capture_hook)
104
+ baseline_out = model(**full_inputs, use_cache=False)
105
+ handle.remove()
106
+ baseline_logits = baseline_out.logits[0]
107
+ baseline_next = baseline_logits[prompt_len - 1]
108
+ baseline_seq, baseline_mean, _ = sequence_logprob_summary(
109
+ baseline_logits,
110
+ prompt_length=prompt_len,
111
+ target_ids=target_ids,
112
+ )
113
+ residual = capture['hidden'][0, prompt_len - 1]
114
+ encoding = sae.encode(residual)
115
+
116
+ valid_sizes = [size for size in sizes if size <= len(candidate_ids)]
117
+ condition_meta: list[tuple[int, str, list[int], torch.Tensor]] = []
118
+ for size in valid_sizes:
119
+ feature_ids = candidate_ids[:size]
120
+ activations = [encoding.activation_for(feature_id) for feature_id in feature_ids]
121
+ directions = torch.stack([sae.decoder_direction(feature_id) for feature_id in feature_ids])
122
+ delta, _ = joint_residual_delta(
123
+ directions,
124
+ activations,
125
+ InterventionSpec('ablate', 0.0),
126
+ )
127
+ control = normalized_random_control(
128
+ delta,
129
+ seed=args.seed + task_idx * 101 + size,
130
+ )
131
+ condition_meta.extend(
132
+ [
133
+ (size, 'sae_feature_set', feature_ids, delta),
134
+ (size, 'random_norm_matched', feature_ids, control),
135
+ ]
136
+ )
137
+
138
+ repeated = {key: value.repeat(len(condition_meta), 1) for key, value in full_inputs.items()}
139
+ deltas = torch.stack([item[3] for item in condition_meta])
140
+ applied = {'done': False}
141
+
142
+ def edit_hook(_module, _inp, output):
143
+ if applied['done']:
144
+ return output
145
+ hidden = hidden_from_output(output)
146
+ modified = hidden.clone()
147
+ modified[:, prompt_len - 1, :] = (
148
+ modified[:, prompt_len - 1, :] + deltas.to(hidden.device, hidden.dtype)
149
+ )
150
+ applied['done'] = True
151
+ return replace_hidden(output, modified)
152
+
153
+ hook = model.model.layers[layer].register_forward_hook(edit_hook)
154
+ edited_out = model(**repeated, use_cache=False)
155
+ hook.remove()
156
+
157
+ for row_idx, (size, condition, feature_ids, applied_delta) in enumerate(condition_meta):
158
+ logits = edited_out.logits[row_idx]
159
+ seq_logp, mean_logp, _ = sequence_logprob_summary(
160
+ logits,
161
+ prompt_length=prompt_len,
162
+ target_ids=target_ids,
163
+ )
164
+ active_count = sum(encoding.activation_for(feature_id) > 0 for feature_id in feature_ids)
165
+ results.append(
166
+ {
167
+ 'task_id': task['id'],
168
+ 'concept': concept,
169
+ 'prompt': task['prompt'],
170
+ 'target_text': task['target'],
171
+ 'target_token_count': len(target_ids),
172
+ 'layer': layer,
173
+ 'set_size': int(size),
174
+ 'feature_ids': ','.join(str(x) for x in feature_ids),
175
+ 'active_selected_features': int(active_count),
176
+ 'condition': condition,
177
+ 'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
178
+ 'baseline_target_sequence_logprob': baseline_seq,
179
+ 'modified_target_sequence_logprob': seq_logp,
180
+ 'target_sequence_logprob_delta': seq_logp - baseline_seq,
181
+ 'baseline_target_mean_logprob': baseline_mean,
182
+ 'modified_target_mean_logprob': mean_logp,
183
+ 'target_mean_logprob_delta': mean_logp - baseline_mean,
184
+ 'js_divergence': js_divergence_from_logits(
185
+ baseline_next,
186
+ logits[prompt_len - 1],
187
+ ),
188
+ }
189
+ )
190
+ print(f"Feature-set task {task_idx + 1}/{len(tasks)}: {concept}", flush=True)
191
+
192
+ args.output.parent.mkdir(parents=True, exist_ok=True)
193
+ with args.output.open('w', newline='', encoding='utf-8') as handle:
194
+ writer = csv.DictWriter(handle, fieldnames=list(results[0].keys()))
195
+ writer.writeheader()
196
+ writer.writerows(results)
197
+ print(f'Wrote {len(results)} feature-set rows to {args.output}')
198
+
199
+
200
+ if __name__ == '__main__':
201
+ main()
featurelens/interventions.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from dataclasses import dataclass
 
4
 
5
  import torch
6
 
@@ -32,6 +33,47 @@ def residual_delta(
32
  return decoder_direction * spec.delta_activation(original_activation)
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def normalized_random_control(delta: torch.Tensor, seed: int) -> torch.Tensor:
36
  """Generate a deterministic random residual perturbation with identical L2 norm."""
37
  norm = torch.linalg.vector_norm(delta.float())
 
1
  from __future__ import annotations
2
 
3
  from dataclasses import dataclass
4
+ from collections.abc import Sequence
5
 
6
  import torch
7
 
 
33
  return decoder_direction * spec.delta_activation(original_activation)
34
 
35
 
36
+ def joint_residual_delta(
37
+ decoder_directions: torch.Tensor,
38
+ original_activations: Sequence[float] | torch.Tensor,
39
+ spec: InterventionSpec,
40
+ ) -> tuple[torch.Tensor, list[float]]:
41
+ """
42
+ Sum reconstruction-preserving deltas for a set of SAE features.
43
+
44
+ ``decoder_directions`` must have shape ``[n_features, d_model]``. The same
45
+ ablation/scale intervention is applied to every selected feature. ``inject``
46
+ is intentionally rejected for feature sets because a shared additive
47
+ coefficient has ambiguous semantics across unrelated decoder directions.
48
+ """
49
+ mode = spec.mode.lower().strip()
50
+ if mode not in {'ablate', 'scale'}:
51
+ raise ValueError("Feature-set interventions support only 'ablate' or 'scale'.")
52
+
53
+ directions = decoder_directions
54
+ if directions.ndim != 2:
55
+ raise ValueError('decoder_directions must have shape [n_features, d_model].')
56
+
57
+ if isinstance(original_activations, torch.Tensor):
58
+ activations = original_activations.detach().float().reshape(-1).tolist()
59
+ else:
60
+ activations = [float(x) for x in original_activations]
61
+
62
+ if len(activations) != directions.shape[0]:
63
+ raise ValueError('Number of activations must match decoder directions.')
64
+ if not activations:
65
+ raise ValueError('Select at least one feature for a feature-set intervention.')
66
+
67
+ coefficient_deltas = [spec.delta_activation(value) for value in activations]
68
+ coeff = torch.tensor(
69
+ coefficient_deltas,
70
+ device=directions.device,
71
+ dtype=directions.dtype,
72
+ )
73
+ delta = torch.sum(directions * coeff[:, None], dim=0)
74
+ return delta, coefficient_deltas
75
+
76
+
77
  def normalized_random_control(delta: torch.Tensor, seed: int) -> torch.Tensor:
78
  """Generate a deterministic random residual perturbation with identical L2 norm."""
79
  norm = torch.linalg.vector_norm(delta.float())
featurelens/metrics.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import math
 
4
 
5
  import numpy as np
6
  import torch
@@ -34,10 +35,85 @@ def safe_log_probability(probability: float) -> float:
34
  return math.log(max(float(probability), 1e-12))
35
 
36
 
37
- def sparse_jaccard(indices_a: np.ndarray, values_a: np.ndarray, indices_b: np.ndarray, values_b: np.ndarray) -> float:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  a = set(indices_a[np.asarray(values_a) > 0].tolist())
39
  b = set(indices_b[np.asarray(values_b) > 0].tolist())
40
  union = a | b
41
  if not union:
42
  return 1.0
43
  return len(a & b) / len(union)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import math
4
+ from collections.abc import Sequence
5
 
6
  import numpy as np
7
  import torch
 
35
  return math.log(max(float(probability), 1e-12))
36
 
37
 
38
+ def target_token_logprobs(
39
+ logits: torch.Tensor,
40
+ *,
41
+ prompt_length: int,
42
+ target_ids: Sequence[int] | torch.Tensor,
43
+ ) -> torch.Tensor:
44
+ """
45
+ Return teacher-forced log probabilities for an exact target continuation.
46
+
47
+ ``logits`` must be ``[sequence, vocab]`` for the concatenated prompt + target
48
+ sequence. The token at target position ``j`` is predicted by the logit row
49
+ immediately before that token.
50
+ """
51
+ if logits.ndim != 2:
52
+ raise ValueError('logits must have shape [sequence, vocab].')
53
+ ids = torch.as_tensor(target_ids, device=logits.device, dtype=torch.long).reshape(-1)
54
+ if ids.numel() == 0:
55
+ raise ValueError('target_ids must contain at least one token.')
56
+ start = int(prompt_length) - 1
57
+ stop = start + int(ids.numel())
58
+ if start < 0 or stop > logits.shape[0]:
59
+ raise ValueError('Prompt/target lengths are incompatible with logits sequence length.')
60
+ rows = logits[start:stop].float()
61
+ return torch.log_softmax(rows, dim=-1).gather(1, ids[:, None]).squeeze(1)
62
+
63
+
64
+ def sequence_logprob_summary(
65
+ logits: torch.Tensor,
66
+ *,
67
+ prompt_length: int,
68
+ target_ids: Sequence[int] | torch.Tensor,
69
+ ) -> tuple[float, float, list[float]]:
70
+ """Return total log p, mean log p/token, and token-level log probabilities."""
71
+ token_values = target_token_logprobs(
72
+ logits,
73
+ prompt_length=prompt_length,
74
+ target_ids=target_ids,
75
+ )
76
+ total = float(token_values.sum().item())
77
+ mean = float(token_values.mean().item())
78
+ return total, mean, [float(x) for x in token_values.detach().cpu().tolist()]
79
+
80
+
81
+ def sparse_jaccard(
82
+ indices_a: np.ndarray,
83
+ values_a: np.ndarray,
84
+ indices_b: np.ndarray,
85
+ values_b: np.ndarray,
86
+ ) -> float:
87
  a = set(indices_a[np.asarray(values_a) > 0].tolist())
88
  b = set(indices_b[np.asarray(values_b) > 0].tolist())
89
  union = a | b
90
  if not union:
91
  return 1.0
92
  return len(a & b) / len(union)
93
+
94
+
95
+ def sparse_topk_cosine(
96
+ indices_a: Sequence[int] | torch.Tensor,
97
+ values_a: Sequence[float] | torch.Tensor,
98
+ indices_b: Sequence[int] | torch.Tensor,
99
+ values_b: Sequence[float] | torch.Tensor,
100
+ ) -> float:
101
+ """Cosine similarity between two sparse TopK vectors without densifying SAE width."""
102
+ idx_a = torch.as_tensor(indices_a, dtype=torch.long).reshape(-1).cpu().tolist()
103
+ val_a = torch.as_tensor(values_a, dtype=torch.float64).reshape(-1).cpu().tolist()
104
+ idx_b = torch.as_tensor(indices_b, dtype=torch.long).reshape(-1).cpu().tolist()
105
+ val_b = torch.as_tensor(values_b, dtype=torch.float64).reshape(-1).cpu().tolist()
106
+
107
+ a = {int(i): float(v) for i, v in zip(idx_a, val_a, strict=True) if float(v) > 0}
108
+ b = {int(i): float(v) for i, v in zip(idx_b, val_b, strict=True) if float(v) > 0}
109
+ if not a and not b:
110
+ return 1.0
111
+ if not a or not b:
112
+ return 0.0
113
+
114
+ dot = sum(value * b.get(feature_id, 0.0) for feature_id, value in a.items())
115
+ norm_a = math.sqrt(sum(value * value for value in a.values()))
116
+ norm_b = math.sqrt(sum(value * value for value in b.values()))
117
+ if norm_a == 0.0 or norm_b == 0.0:
118
+ return 0.0
119
+ return float(dot / (norm_a * norm_b))
featurelens/runtime.py CHANGED
@@ -15,10 +15,16 @@ from .catalog import FeatureCatalog
15
  from .config import SETTINGS, Settings
16
  from .interventions import (
17
  InterventionSpec,
 
18
  normalized_random_control,
19
  residual_delta,
20
  )
21
- from .metrics import js_divergence_from_logits, reconstruction_metrics, safe_log_probability
 
 
 
 
 
22
  from .sae import SAEStore, SparseEncoding
23
 
24
 
@@ -64,14 +70,20 @@ class InterventionResult:
64
  random_js_divergence: float
65
  js_specificity_ratio: float
66
  target_text: str
67
- target_token: str
68
  target_token_count: int
 
69
  baseline_target_prob: float | None
70
  modified_target_prob: float | None
71
  random_target_prob: float | None
72
- target_logprob_delta: float | None
73
- random_target_logprob_delta: float | None
 
 
 
 
 
74
  target_specificity_ratio: float | None
 
75
  top_token_rows: list[list[object]]
76
 
77
 
@@ -85,9 +97,48 @@ class LayerSweepResult:
85
  @dataclass
86
  class DoseResponseResult:
87
  feature_activation: float
88
- target_token: str
89
- target_token_count: int
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  rows: list[list[object]]
 
91
 
92
 
93
  class FeatureLensRuntime:
@@ -153,6 +204,40 @@ class FeatureLensRuntime:
153
  )
154
  return {key: value.to(self.device) for key, value in batch.items()}
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  @staticmethod
157
  def _hidden_from_output(output):
158
  return output[0] if isinstance(output, tuple) else output
@@ -209,11 +294,7 @@ class FeatureLensRuntime:
209
  if hidden.ndim != 3:
210
  return output
211
  seq_len = hidden.shape[1]
212
- idx = int(token_index)
213
- if idx < 0:
214
- idx = seq_len + idx
215
- if idx < 0 or idx >= seq_len:
216
- raise IndexError(f'Token index {token_index} outside prompt length {seq_len}.')
217
  modified = hidden.clone()
218
  modified[:, idx, :] = modified[:, idx, :] + delta.to(hidden.device, hidden.dtype)
219
  applied['done'] = True
@@ -225,6 +306,39 @@ class FeatureLensRuntime:
225
  finally:
226
  handle.remove()
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  @staticmethod
229
  def _resolve_index(token_index: int, seq_len: int) -> int:
230
  idx = int(token_index)
@@ -235,10 +349,22 @@ class FeatureLensRuntime:
235
  return idx
236
 
237
  @staticmethod
238
- def _control_seed(text: str, layer: int, feature_id: int, mode: str, coefficient: float) -> int:
239
- payload = f'{text}\0{layer}\0{feature_id}\0{mode}\0{coefficient:.8g}'.encode('utf-8')
240
  return int.from_bytes(hashlib.sha256(payload).digest()[:4], 'big', signed=False)
241
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  @torch.inference_mode()
243
  def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
244
  self.ensure_ready(preload_saes=False)
@@ -306,7 +432,9 @@ class FeatureLensRuntime:
306
  entropy = 0.0
307
  else:
308
  probs = positive / total
309
- entropy = float((-(probs * torch.log(probs)).sum() / math.log(positive.numel())).item())
 
 
310
  top5_fraction = (
311
  float(values[: min(5, values.numel())].sum().item() / total.item())
312
  if float(total.item()) > 0
@@ -350,6 +478,37 @@ class FeatureLensRuntime:
350
  rows.sort(key=lambda row: max(row[1], row[2]), reverse=True)
351
  return rows[: min(len(rows), 12)]
352
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  @torch.inference_mode()
354
  def intervene(
355
  self,
@@ -364,70 +523,130 @@ class FeatureLensRuntime:
364
  ) -> InterventionResult:
365
  self.ensure_ready(preload_saes=False)
366
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
367
- inputs = self._inputs(text)
368
- prompt_len = int(inputs['input_ids'].shape[1])
369
  idx = self._resolve_index(int(token_index), prompt_len)
370
-
371
  sae = self.sae_store.get(int(layer))
 
 
 
 
 
372
  capture: dict = {}
373
- generation_kwargs = {
374
- 'max_new_tokens': min(int(max_new_tokens), self.settings.max_new_tokens),
375
- 'do_sample': False,
376
- 'return_dict_in_generate': True,
377
- 'output_scores': True,
378
- 'pad_token_id': self.tokenizer.eos_token_id,
379
- }
380
- with self._capture_hook(int(layer), capture):
381
- baseline = self.model.generate(**inputs, **generation_kwargs)
 
 
 
 
 
 
 
 
382
  residual = capture['hidden'][0, idx]
383
  encoding = sae.encode(residual)
384
  original_activation = encoding.activation_for(int(feature_id))
385
  spec = InterventionSpec(mode=mode, coefficient=float(coefficient))
386
  delta = residual_delta(sae.decoder_direction(int(feature_id)), original_activation, spec)
387
-
388
- with self._delta_hook(int(layer), idx, delta):
389
- modified = self.model.generate(**inputs, **generation_kwargs)
390
-
391
- # Live negative control: one extra forward pass, not another full generation.
392
  control_delta = normalized_random_control(
393
  delta,
394
- seed=self._control_seed(text, int(layer), int(feature_id), mode, float(coefficient)),
 
 
 
 
 
 
395
  )
396
- with self._delta_hook(int(layer), idx, control_delta):
397
- random_out = self.model(**inputs, use_cache=False)
398
- random_logits = random_out.logits[0, -1]
399
 
400
- baseline_ids = baseline.sequences[0, prompt_len:]
401
- modified_ids = modified.sequences[0, prompt_len:]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
403
  modified_text = self.tokenizer.decode(modified_ids, skip_special_tokens=True)
404
 
405
- if not baseline.scores or not modified.scores:
406
- raise RuntimeError('Generation returned no score tensors.')
407
- baseline_logits = baseline.scores[0][0]
408
- modified_logits = modified.scores[0][0]
409
- js = js_divergence_from_logits(baseline_logits, modified_logits)
410
- random_js = js_divergence_from_logits(baseline_logits, random_logits)
411
  js_ratio = abs(js) / max(abs(random_js), 1e-12)
412
 
413
- target_token = ''
414
- target_count = 0
415
- bp = mp = rp = log_delta = random_log_delta = specificity = None
416
- if target_text.strip():
417
- target_ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
418
- target_count = len(target_ids)
419
- if target_ids:
420
- target_id = int(target_ids[0])
421
- target_token = self.tokenizer.decode([target_id])
422
- p = torch.softmax(baseline_logits.float(), dim=-1)
423
- q = torch.softmax(modified_logits.float(), dim=-1)
424
- r = torch.softmax(random_logits.float(), dim=-1)
425
- bp = float(p[target_id].item())
426
- mp = float(q[target_id].item())
427
- rp = float(r[target_id].item())
428
- log_delta = safe_log_probability(mp) - safe_log_probability(bp)
429
- random_log_delta = safe_log_probability(rp) - safe_log_probability(bp)
430
- specificity = abs(log_delta) / max(abs(random_log_delta), 1e-12)
 
 
 
 
 
 
 
 
431
 
432
  return InterventionResult(
433
  baseline_text=baseline_text,
@@ -439,16 +658,25 @@ class FeatureLensRuntime:
439
  random_js_divergence=float(random_js),
440
  js_specificity_ratio=float(js_ratio),
441
  target_text=target_text,
442
- target_token=target_token,
443
- target_token_count=target_count,
444
  baseline_target_prob=bp,
445
  modified_target_prob=mp,
446
  random_target_prob=rp,
447
- target_logprob_delta=log_delta,
448
- random_target_logprob_delta=random_log_delta,
 
 
 
 
 
449
  target_specificity_ratio=specificity,
 
450
  top_token_rows=self._top_token_rows(
451
- self.tokenizer, baseline_logits, modified_logits, k=8
 
 
 
452
  ),
453
  )
454
 
@@ -466,55 +694,368 @@ class FeatureLensRuntime:
466
  raise ValueError('Dose-response requires a target continuation.')
467
  self.ensure_ready(preload_saes=False)
468
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
469
- inputs = self._inputs(text)
470
- prompt_len = int(inputs['input_ids'].shape[1])
471
  idx = self._resolve_index(int(token_index), prompt_len)
 
 
472
  sae = self.sae_store.get(int(layer))
473
 
474
  capture: dict = {}
475
  with self._capture_hook(int(layer), capture):
476
- baseline_out = self.model(**inputs, use_cache=False)
477
- baseline_logits = baseline_out.logits[0, -1]
 
 
 
 
 
 
478
  residual = capture['hidden'][0, idx]
479
  encoding = sae.encode(residual)
480
  original_activation = encoding.activation_for(int(feature_id))
481
  direction = sae.decoder_direction(int(feature_id))
482
 
483
- target_ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
484
- if not target_ids:
485
- raise ValueError('Target continuation tokenized to an empty sequence.')
486
- target_id = int(target_ids[0])
487
- target_token = self.tokenizer.decode([target_id])
488
- baseline_prob = float(torch.softmax(baseline_logits.float(), dim=-1)[target_id].item())
489
-
490
- rows: list[list[object]] = []
491
  for multiplier in multipliers:
492
  spec = InterventionSpec('scale', float(multiplier))
493
  delta = residual_delta(direction, original_activation, spec)
494
- if float(torch.linalg.vector_norm(delta.float()).item()) == 0.0:
495
- modified_logits = baseline_logits
496
- else:
497
- with self._delta_hook(int(layer), idx, delta):
498
- output = self.model(**inputs, use_cache=False)
499
- modified_logits = output.logits[0, -1]
500
- modified_prob = float(torch.softmax(modified_logits.float(), dim=-1)[target_id].item())
501
- log_delta = safe_log_probability(modified_prob) - safe_log_probability(baseline_prob)
 
 
 
 
 
 
 
 
502
  rows.append(
503
  [
504
  float(multiplier),
505
- float(spec.delta_activation(original_activation)),
506
- float(torch.linalg.vector_norm(delta.float()).item()),
507
- baseline_prob,
508
- modified_prob,
509
- float(log_delta),
510
- float(js_divergence_from_logits(baseline_logits, modified_logits)),
 
 
 
 
 
 
511
  ]
512
  )
513
  return DoseResponseResult(
514
  feature_activation=float(original_activation),
515
- target_token=target_token,
516
- target_token_count=len(target_ids),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  rows=rows,
 
518
  )
519
 
520
 
 
15
  from .config import SETTINGS, Settings
16
  from .interventions import (
17
  InterventionSpec,
18
+ joint_residual_delta,
19
  normalized_random_control,
20
  residual_delta,
21
  )
22
+ from .metrics import (
23
+ js_divergence_from_logits,
24
+ reconstruction_metrics,
25
+ sequence_logprob_summary,
26
+ sparse_topk_cosine,
27
+ )
28
  from .sae import SAEStore, SparseEncoding
29
 
30
 
 
70
  random_js_divergence: float
71
  js_specificity_ratio: float
72
  target_text: str
 
73
  target_token_count: int
74
+ target_tokens: list[str]
75
  baseline_target_prob: float | None
76
  modified_target_prob: float | None
77
  random_target_prob: float | None
78
+ baseline_sequence_logprob: float | None
79
+ modified_sequence_logprob: float | None
80
+ random_sequence_logprob: float | None
81
+ sequence_logprob_delta: float | None
82
+ random_sequence_logprob_delta: float | None
83
+ mean_logprob_delta: float | None
84
+ random_mean_logprob_delta: float | None
85
  target_specificity_ratio: float | None
86
+ target_token_rows: list[list[object]]
87
  top_token_rows: list[list[object]]
88
 
89
 
 
97
  @dataclass
98
  class DoseResponseResult:
99
  feature_activation: float
100
+ target_tokens: list[str]
101
+ rows: list[list[object]]
102
+
103
+
104
+ @dataclass
105
+ class FeatureSetResult:
106
+ feature_ids: list[int]
107
+ feature_rows: list[list[object]]
108
+ perturbation_norm: float
109
+ js_divergence: float
110
+ random_js_divergence: float
111
+ js_specificity_ratio: float
112
+ baseline_sequence_logprob: float
113
+ modified_sequence_logprob: float
114
+ random_sequence_logprob: float
115
+ sequence_logprob_delta: float
116
+ random_sequence_logprob_delta: float
117
+ mean_logprob_delta: float
118
+ random_mean_logprob_delta: float
119
+ target_specificity_ratio: float
120
+ target_tokens: list[str]
121
+ target_token_rows: list[list[object]]
122
+
123
+
124
+ @dataclass
125
+ class FeatureSetSweepResult:
126
+ target_tokens: list[str]
127
+ rows: list[list[object]]
128
+
129
+
130
+ @dataclass
131
+ class ParaphraseResult:
132
+ tokens_a: list[str]
133
+ token_index_a: int
134
+ tokens_b: list[str]
135
+ token_index_b: int
136
+ topk_jaccard: float
137
+ sparse_cosine: float
138
+ shared_top_n: int
139
+ top_n: int
140
  rows: list[list[object]]
141
+ chart_rows: list[list[object]]
142
 
143
 
144
  class FeatureLensRuntime:
 
204
  )
205
  return {key: value.to(self.device) for key, value in batch.items()}
206
 
207
+ def _target_ids(self, target_text: str) -> list[int]:
208
+ assert self.tokenizer is not None
209
+ ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
210
+ if not ids:
211
+ raise ValueError('Target continuation tokenized to an empty sequence.')
212
+ return [int(x) for x in ids]
213
+
214
+ def _append_target(
215
+ self,
216
+ prompt_inputs: dict[str, torch.Tensor],
217
+ target_ids: Sequence[int],
218
+ ) -> dict[str, torch.Tensor]:
219
+ prompt_ids = prompt_inputs['input_ids']
220
+ target = torch.tensor(
221
+ list(target_ids),
222
+ dtype=prompt_ids.dtype,
223
+ device=prompt_ids.device,
224
+ ).unsqueeze(0)
225
+ full_ids = torch.cat([prompt_ids, target], dim=1)
226
+ if 'attention_mask' in prompt_inputs:
227
+ target_mask = torch.ones(
228
+ (prompt_ids.shape[0], len(target_ids)),
229
+ dtype=prompt_inputs['attention_mask'].dtype,
230
+ device=prompt_ids.device,
231
+ )
232
+ attention = torch.cat([prompt_inputs['attention_mask'], target_mask], dim=1)
233
+ else:
234
+ attention = torch.ones_like(full_ids)
235
+ return {'input_ids': full_ids, 'attention_mask': attention}
236
+
237
+ @staticmethod
238
+ def _repeat_inputs(inputs: dict[str, torch.Tensor], repeats: int) -> dict[str, torch.Tensor]:
239
+ return {key: value.repeat(int(repeats), 1) for key, value in inputs.items()}
240
+
241
  @staticmethod
242
  def _hidden_from_output(output):
243
  return output[0] if isinstance(output, tuple) else output
 
294
  if hidden.ndim != 3:
295
  return output
296
  seq_len = hidden.shape[1]
297
+ idx = self._resolve_index(int(token_index), seq_len)
 
 
 
 
298
  modified = hidden.clone()
299
  modified[:, idx, :] = modified[:, idx, :] + delta.to(hidden.device, hidden.dtype)
300
  applied['done'] = True
 
306
  finally:
307
  handle.remove()
308
 
309
+ @contextmanager
310
+ def _batch_delta_hook(
311
+ self,
312
+ layer: int,
313
+ token_index: int,
314
+ deltas: torch.Tensor,
315
+ ) -> Iterator[None]:
316
+ """Apply one residual delta per batch row in a single model forward."""
317
+ assert self.model is not None
318
+ if deltas.ndim != 2:
319
+ raise ValueError('deltas must have shape [batch, d_model].')
320
+ applied = {'done': False}
321
+
322
+ def hook(_module, _inputs, output):
323
+ if applied['done']:
324
+ return output
325
+ hidden = self._hidden_from_output(output)
326
+ if hidden.ndim != 3:
327
+ return output
328
+ if hidden.shape[0] != deltas.shape[0]:
329
+ raise ValueError('Delta batch size does not match model batch size.')
330
+ idx = self._resolve_index(int(token_index), hidden.shape[1])
331
+ modified = hidden.clone()
332
+ modified[:, idx, :] = modified[:, idx, :] + deltas.to(hidden.device, hidden.dtype)
333
+ applied['done'] = True
334
+ return self._replace_hidden_in_output(output, modified)
335
+
336
+ handle = self.model.model.layers[int(layer)].register_forward_hook(hook)
337
+ try:
338
+ yield
339
+ finally:
340
+ handle.remove()
341
+
342
  @staticmethod
343
  def _resolve_index(token_index: int, seq_len: int) -> int:
344
  idx = int(token_index)
 
349
  return idx
350
 
351
  @staticmethod
352
+ def _control_seed(text: str, layer: int, key: str, mode: str, coefficient: float) -> int:
353
+ payload = f'{text}\0{layer}\0{key}\0{mode}\0{coefficient:.8g}'.encode('utf-8')
354
  return int.from_bytes(hashlib.sha256(payload).digest()[:4], 'big', signed=False)
355
 
356
+ @staticmethod
357
+ def _encoding_map(encoding: SparseEncoding) -> dict[int, float]:
358
+ return {
359
+ int(feature_id): float(value)
360
+ for feature_id, value in zip(
361
+ encoding.indices.detach().cpu().tolist(),
362
+ encoding.values.detach().float().cpu().tolist(),
363
+ strict=True,
364
+ )
365
+ if float(value) > 0
366
+ }
367
+
368
  @torch.inference_mode()
369
  def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
370
  self.ensure_ready(preload_saes=False)
 
432
  entropy = 0.0
433
  else:
434
  probs = positive / total
435
+ entropy = float(
436
+ (-(probs * torch.log(probs)).sum() / math.log(positive.numel())).item()
437
+ )
438
  top5_fraction = (
439
  float(values[: min(5, values.numel())].sum().item() / total.item())
440
  if float(total.item()) > 0
 
478
  rows.sort(key=lambda row: max(row[1], row[2]), reverse=True)
479
  return rows[: min(len(rows), 12)]
480
 
481
+ def _target_rows(
482
+ self,
483
+ target_ids: Sequence[int],
484
+ baseline_token_logps: Sequence[float],
485
+ modified_token_logps: Sequence[float],
486
+ random_token_logps: Sequence[float],
487
+ ) -> list[list[object]]:
488
+ assert self.tokenizer is not None
489
+ rows = []
490
+ for idx, (token_id, bp, mp, rp) in enumerate(
491
+ zip(
492
+ target_ids,
493
+ baseline_token_logps,
494
+ modified_token_logps,
495
+ random_token_logps,
496
+ strict=True,
497
+ )
498
+ ):
499
+ rows.append(
500
+ [
501
+ idx,
502
+ repr(self.tokenizer.decode([int(token_id)])),
503
+ float(bp),
504
+ float(mp),
505
+ float(rp),
506
+ float(mp - bp),
507
+ float(rp - bp),
508
+ ]
509
+ )
510
+ return rows
511
+
512
  @torch.inference_mode()
513
  def intervene(
514
  self,
 
523
  ) -> InterventionResult:
524
  self.ensure_ready(preload_saes=False)
525
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
526
+ prompt_inputs = self._inputs(text)
527
+ prompt_len = int(prompt_inputs['input_ids'].shape[1])
528
  idx = self._resolve_index(int(token_index), prompt_len)
 
529
  sae = self.sae_store.get(int(layer))
530
+
531
+ target_ids: list[int] = []
532
+ baseline_seq = baseline_mean = None
533
+ baseline_token_logps: list[float] = []
534
+ baseline_next_logits: torch.Tensor | None = None
535
  capture: dict = {}
536
+
537
+ if target_text.strip():
538
+ target_ids = self._target_ids(target_text)
539
+ full_inputs = self._append_target(prompt_inputs, target_ids)
540
+ with self._capture_hook(int(layer), capture):
541
+ baseline_full = self.model(**full_inputs, use_cache=False)
542
+ baseline_next_logits = baseline_full.logits[0, prompt_len - 1]
543
+ baseline_seq, baseline_mean, baseline_token_logps = sequence_logprob_summary(
544
+ baseline_full.logits[0],
545
+ prompt_length=prompt_len,
546
+ target_ids=target_ids,
547
+ )
548
+ else:
549
+ with self._capture_hook(int(layer), capture):
550
+ baseline_prompt = self.model(**prompt_inputs, use_cache=False)
551
+ baseline_next_logits = baseline_prompt.logits[0, -1]
552
+
553
  residual = capture['hidden'][0, idx]
554
  encoding = sae.encode(residual)
555
  original_activation = encoding.activation_for(int(feature_id))
556
  spec = InterventionSpec(mode=mode, coefficient=float(coefficient))
557
  delta = residual_delta(sae.decoder_direction(int(feature_id)), original_activation, spec)
 
 
 
 
 
558
  control_delta = normalized_random_control(
559
  delta,
560
+ seed=self._control_seed(
561
+ text,
562
+ int(layer),
563
+ str(int(feature_id)),
564
+ mode,
565
+ float(coefficient),
566
+ ),
567
  )
 
 
 
568
 
569
+ modified_next_logits: torch.Tensor
570
+ random_next_logits: torch.Tensor
571
+ modified_seq = modified_mean = random_seq = random_mean = None
572
+ modified_token_logps: list[float] = []
573
+ random_token_logps: list[float] = []
574
+
575
+ if target_ids:
576
+ full_inputs = self._append_target(prompt_inputs, target_ids)
577
+ repeated = self._repeat_inputs(full_inputs, 2)
578
+ deltas = torch.stack([delta, control_delta], dim=0)
579
+ with self._batch_delta_hook(int(layer), idx, deltas):
580
+ edited_full = self.model(**repeated, use_cache=False)
581
+ modified_logits_all = edited_full.logits[0]
582
+ random_logits_all = edited_full.logits[1]
583
+ modified_next_logits = modified_logits_all[prompt_len - 1]
584
+ random_next_logits = random_logits_all[prompt_len - 1]
585
+ modified_seq, modified_mean, modified_token_logps = sequence_logprob_summary(
586
+ modified_logits_all,
587
+ prompt_length=prompt_len,
588
+ target_ids=target_ids,
589
+ )
590
+ random_seq, random_mean, random_token_logps = sequence_logprob_summary(
591
+ random_logits_all,
592
+ prompt_length=prompt_len,
593
+ target_ids=target_ids,
594
+ )
595
+ else:
596
+ with self._delta_hook(int(layer), idx, delta):
597
+ modified_prompt = self.model(**prompt_inputs, use_cache=False)
598
+ with self._delta_hook(int(layer), idx, control_delta):
599
+ random_prompt = self.model(**prompt_inputs, use_cache=False)
600
+ modified_next_logits = modified_prompt.logits[0, -1]
601
+ random_next_logits = random_prompt.logits[0, -1]
602
+
603
+ generation_kwargs = {
604
+ 'max_new_tokens': min(int(max_new_tokens), self.settings.max_new_tokens),
605
+ 'do_sample': False,
606
+ 'return_dict_in_generate': True,
607
+ 'output_scores': False,
608
+ 'pad_token_id': self.tokenizer.eos_token_id,
609
+ }
610
+ baseline_generation = self.model.generate(**prompt_inputs, **generation_kwargs)
611
+ with self._delta_hook(int(layer), idx, delta):
612
+ modified_generation = self.model.generate(**prompt_inputs, **generation_kwargs)
613
+
614
+ baseline_ids = baseline_generation.sequences[0, prompt_len:]
615
+ modified_ids = modified_generation.sequences[0, prompt_len:]
616
  baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
617
  modified_text = self.tokenizer.decode(modified_ids, skip_special_tokens=True)
618
 
619
+ assert baseline_next_logits is not None
620
+ js = js_divergence_from_logits(baseline_next_logits, modified_next_logits)
621
+ random_js = js_divergence_from_logits(baseline_next_logits, random_next_logits)
 
 
 
622
  js_ratio = abs(js) / max(abs(random_js), 1e-12)
623
 
624
+ bp = mp = rp = None
625
+ sequence_delta = random_sequence_delta = mean_delta = random_mean_delta = specificity = None
626
+ target_rows: list[list[object]] = []
627
+ target_tokens: list[str] = []
628
+ if target_ids:
629
+ p = torch.softmax(baseline_next_logits.float(), dim=-1)
630
+ q = torch.softmax(modified_next_logits.float(), dim=-1)
631
+ r = torch.softmax(random_next_logits.float(), dim=-1)
632
+ first_id = int(target_ids[0])
633
+ bp = float(p[first_id].item())
634
+ mp = float(q[first_id].item())
635
+ rp = float(r[first_id].item())
636
+ assert baseline_seq is not None and modified_seq is not None and random_seq is not None
637
+ assert baseline_mean is not None and modified_mean is not None and random_mean is not None
638
+ sequence_delta = float(modified_seq - baseline_seq)
639
+ random_sequence_delta = float(random_seq - baseline_seq)
640
+ mean_delta = float(modified_mean - baseline_mean)
641
+ random_mean_delta = float(random_mean - baseline_mean)
642
+ specificity = abs(mean_delta) / max(abs(random_mean_delta), 1e-12)
643
+ target_tokens = [self.tokenizer.decode([int(token_id)]) for token_id in target_ids]
644
+ target_rows = self._target_rows(
645
+ target_ids,
646
+ baseline_token_logps,
647
+ modified_token_logps,
648
+ random_token_logps,
649
+ )
650
 
651
  return InterventionResult(
652
  baseline_text=baseline_text,
 
658
  random_js_divergence=float(random_js),
659
  js_specificity_ratio=float(js_ratio),
660
  target_text=target_text,
661
+ target_token_count=len(target_ids),
662
+ target_tokens=target_tokens,
663
  baseline_target_prob=bp,
664
  modified_target_prob=mp,
665
  random_target_prob=rp,
666
+ baseline_sequence_logprob=baseline_seq,
667
+ modified_sequence_logprob=modified_seq,
668
+ random_sequence_logprob=random_seq,
669
+ sequence_logprob_delta=sequence_delta,
670
+ random_sequence_logprob_delta=random_sequence_delta,
671
+ mean_logprob_delta=mean_delta,
672
+ random_mean_logprob_delta=random_mean_delta,
673
  target_specificity_ratio=specificity,
674
+ target_token_rows=target_rows,
675
  top_token_rows=self._top_token_rows(
676
+ self.tokenizer,
677
+ baseline_next_logits,
678
+ modified_next_logits,
679
+ k=8,
680
  ),
681
  )
682
 
 
694
  raise ValueError('Dose-response requires a target continuation.')
695
  self.ensure_ready(preload_saes=False)
696
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
697
+ prompt_inputs = self._inputs(text)
698
+ prompt_len = int(prompt_inputs['input_ids'].shape[1])
699
  idx = self._resolve_index(int(token_index), prompt_len)
700
+ target_ids = self._target_ids(target_text)
701
+ full_inputs = self._append_target(prompt_inputs, target_ids)
702
  sae = self.sae_store.get(int(layer))
703
 
704
  capture: dict = {}
705
  with self._capture_hook(int(layer), capture):
706
+ baseline_out = self.model(**full_inputs, use_cache=False)
707
+ baseline_logits = baseline_out.logits[0]
708
+ baseline_next = baseline_logits[prompt_len - 1]
709
+ baseline_seq, baseline_mean, _ = sequence_logprob_summary(
710
+ baseline_logits,
711
+ prompt_length=prompt_len,
712
+ target_ids=target_ids,
713
+ )
714
  residual = capture['hidden'][0, idx]
715
  encoding = sae.encode(residual)
716
  original_activation = encoding.activation_for(int(feature_id))
717
  direction = sae.decoder_direction(int(feature_id))
718
 
719
+ deltas = []
720
+ delta_coefficients = []
721
+ norms = []
 
 
 
 
 
722
  for multiplier in multipliers:
723
  spec = InterventionSpec('scale', float(multiplier))
724
  delta = residual_delta(direction, original_activation, spec)
725
+ deltas.append(delta)
726
+ delta_coefficients.append(float(spec.delta_activation(original_activation)))
727
+ norms.append(float(torch.linalg.vector_norm(delta.float()).item()))
728
+
729
+ repeated = self._repeat_inputs(full_inputs, len(deltas))
730
+ with self._batch_delta_hook(int(layer), idx, torch.stack(deltas, dim=0)):
731
+ outputs = self.model(**repeated, use_cache=False)
732
+
733
+ rows: list[list[object]] = []
734
+ for row_idx, multiplier in enumerate(multipliers):
735
+ modified_logits = outputs.logits[row_idx]
736
+ modified_seq, modified_mean, _ = sequence_logprob_summary(
737
+ modified_logits,
738
+ prompt_length=prompt_len,
739
+ target_ids=target_ids,
740
+ )
741
  rows.append(
742
  [
743
  float(multiplier),
744
+ delta_coefficients[row_idx],
745
+ norms[row_idx],
746
+ float(baseline_mean),
747
+ float(modified_mean),
748
+ float(modified_mean - baseline_mean),
749
+ float(modified_seq - baseline_seq),
750
+ float(
751
+ js_divergence_from_logits(
752
+ baseline_next,
753
+ modified_logits[prompt_len - 1],
754
+ )
755
+ ),
756
  ]
757
  )
758
  return DoseResponseResult(
759
  feature_activation=float(original_activation),
760
+ target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
761
+ rows=rows,
762
+ )
763
+
764
+ @torch.inference_mode()
765
+ def intervene_feature_set(
766
+ self,
767
+ text: str,
768
+ layer: int,
769
+ token_index: int,
770
+ feature_ids: Sequence[int],
771
+ mode: str,
772
+ coefficient: float,
773
+ target_text: str,
774
+ ) -> FeatureSetResult:
775
+ if not target_text.strip():
776
+ raise ValueError('Feature-set causal testing requires a target continuation.')
777
+ ids = list(dict.fromkeys(int(x) for x in feature_ids))
778
+ if not ids:
779
+ raise ValueError('Select at least one feature.')
780
+ if len(ids) > 12:
781
+ raise ValueError('Select at most 12 features for a live feature-set intervention.')
782
+ if mode not in {'ablate', 'scale'}:
783
+ raise ValueError("Feature-set mode must be 'ablate' or 'scale'.")
784
+
785
+ self.ensure_ready(preload_saes=False)
786
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
787
+ prompt_inputs = self._inputs(text)
788
+ prompt_len = int(prompt_inputs['input_ids'].shape[1])
789
+ idx = self._resolve_index(int(token_index), prompt_len)
790
+ target_ids = self._target_ids(target_text)
791
+ full_inputs = self._append_target(prompt_inputs, target_ids)
792
+ sae = self.sae_store.get(int(layer))
793
+
794
+ capture: dict = {}
795
+ with self._capture_hook(int(layer), capture):
796
+ baseline_out = self.model(**full_inputs, use_cache=False)
797
+ residual = capture['hidden'][0, idx]
798
+ encoding = sae.encode(residual)
799
+ activations = [encoding.activation_for(feature_id) for feature_id in ids]
800
+ directions = torch.stack([sae.decoder_direction(feature_id) for feature_id in ids], dim=0)
801
+ spec = InterventionSpec(mode, float(coefficient))
802
+ delta, coefficient_deltas = joint_residual_delta(directions, activations, spec)
803
+ control = normalized_random_control(
804
+ delta,
805
+ seed=self._control_seed(
806
+ text,
807
+ int(layer),
808
+ ','.join(str(x) for x in ids),
809
+ mode,
810
+ float(coefficient),
811
+ ),
812
+ )
813
+
814
+ repeated = self._repeat_inputs(full_inputs, 2)
815
+ with self._batch_delta_hook(int(layer), idx, torch.stack([delta, control], dim=0)):
816
+ outputs = self.model(**repeated, use_cache=False)
817
+
818
+ baseline_logits = baseline_out.logits[0]
819
+ modified_logits = outputs.logits[0]
820
+ random_logits = outputs.logits[1]
821
+ baseline_seq, baseline_mean, baseline_tokens = sequence_logprob_summary(
822
+ baseline_logits,
823
+ prompt_length=prompt_len,
824
+ target_ids=target_ids,
825
+ )
826
+ modified_seq, modified_mean, modified_tokens = sequence_logprob_summary(
827
+ modified_logits,
828
+ prompt_length=prompt_len,
829
+ target_ids=target_ids,
830
+ )
831
+ random_seq, random_mean, random_tokens = sequence_logprob_summary(
832
+ random_logits,
833
+ prompt_length=prompt_len,
834
+ target_ids=target_ids,
835
+ )
836
+ mean_delta = float(modified_mean - baseline_mean)
837
+ random_mean_delta = float(random_mean - baseline_mean)
838
+ next_idx = prompt_len - 1
839
+ js = js_divergence_from_logits(baseline_logits[next_idx], modified_logits[next_idx])
840
+ random_js = js_divergence_from_logits(baseline_logits[next_idx], random_logits[next_idx])
841
+
842
+ feature_rows = [
843
+ [
844
+ feature_id,
845
+ float(activation),
846
+ float(delta_coefficient),
847
+ self.catalog.hint(int(layer), feature_id),
848
+ ]
849
+ for feature_id, activation, delta_coefficient in zip(
850
+ ids,
851
+ activations,
852
+ coefficient_deltas,
853
+ strict=True,
854
+ )
855
+ ]
856
+ return FeatureSetResult(
857
+ feature_ids=ids,
858
+ feature_rows=feature_rows,
859
+ perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
860
+ js_divergence=float(js),
861
+ random_js_divergence=float(random_js),
862
+ js_specificity_ratio=float(abs(js) / max(abs(random_js), 1e-12)),
863
+ baseline_sequence_logprob=float(baseline_seq),
864
+ modified_sequence_logprob=float(modified_seq),
865
+ random_sequence_logprob=float(random_seq),
866
+ sequence_logprob_delta=float(modified_seq - baseline_seq),
867
+ random_sequence_logprob_delta=float(random_seq - baseline_seq),
868
+ mean_logprob_delta=mean_delta,
869
+ random_mean_logprob_delta=random_mean_delta,
870
+ target_specificity_ratio=float(abs(mean_delta) / max(abs(random_mean_delta), 1e-12)),
871
+ target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
872
+ target_token_rows=self._target_rows(
873
+ target_ids,
874
+ baseline_tokens,
875
+ modified_tokens,
876
+ random_tokens,
877
+ ),
878
+ )
879
+
880
+ @torch.inference_mode()
881
+ def feature_set_size_sweep(
882
+ self,
883
+ text: str,
884
+ layer: int,
885
+ token_index: int,
886
+ target_text: str,
887
+ sizes: Sequence[int] = (1, 3, 5),
888
+ ) -> FeatureSetSweepResult:
889
+ """Jointly ablate the strongest k active features for k in ``sizes``."""
890
+ if not target_text.strip():
891
+ raise ValueError('Feature-set size sweep requires a target continuation.')
892
+ self.ensure_ready(preload_saes=False)
893
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
894
+ prompt_inputs = self._inputs(text)
895
+ prompt_len = int(prompt_inputs['input_ids'].shape[1])
896
+ idx = self._resolve_index(int(token_index), prompt_len)
897
+ target_ids = self._target_ids(target_text)
898
+ full_inputs = self._append_target(prompt_inputs, target_ids)
899
+ sae = self.sae_store.get(int(layer))
900
+
901
+ capture: dict = {}
902
+ with self._capture_hook(int(layer), capture):
903
+ baseline_out = self.model(**full_inputs, use_cache=False)
904
+ residual = capture['hidden'][0, idx]
905
+ encoding = sae.encode(residual)
906
+ active_ids = [
907
+ int(feature_id)
908
+ for feature_id, value in zip(
909
+ encoding.indices.detach().cpu().tolist(),
910
+ encoding.values.detach().float().cpu().tolist(),
911
+ strict=True,
912
+ )
913
+ if float(value) > 0
914
+ ]
915
+ valid_sizes = [int(size) for size in sizes if int(size) > 0 and int(size) <= len(active_ids)]
916
+ if not valid_sizes:
917
+ raise ValueError('Not enough active features for the requested set sizes.')
918
+
919
+ deltas: list[torch.Tensor] = []
920
+ controls: list[torch.Tensor] = []
921
+ feature_lists: list[list[int]] = []
922
+ norms: list[float] = []
923
+ for size in valid_sizes:
924
+ selected = active_ids[:size]
925
+ activations = [encoding.activation_for(feature_id) for feature_id in selected]
926
+ directions = torch.stack([sae.decoder_direction(feature_id) for feature_id in selected])
927
+ delta, _ = joint_residual_delta(
928
+ directions,
929
+ activations,
930
+ InterventionSpec('ablate', 0.0),
931
+ )
932
+ control = normalized_random_control(
933
+ delta,
934
+ seed=self._control_seed(
935
+ text,
936
+ int(layer),
937
+ f'top-{size}',
938
+ 'ablate_set',
939
+ 0.0,
940
+ ),
941
+ )
942
+ deltas.append(delta)
943
+ controls.append(control)
944
+ feature_lists.append(selected)
945
+ norms.append(float(torch.linalg.vector_norm(delta.float()).item()))
946
+
947
+ all_deltas = torch.stack([item for pair in zip(deltas, controls, strict=True) for item in pair])
948
+ repeated = self._repeat_inputs(full_inputs, all_deltas.shape[0])
949
+ with self._batch_delta_hook(int(layer), idx, all_deltas):
950
+ outputs = self.model(**repeated, use_cache=False)
951
+
952
+ baseline_logits = baseline_out.logits[0]
953
+ baseline_seq, baseline_mean, _ = sequence_logprob_summary(
954
+ baseline_logits,
955
+ prompt_length=prompt_len,
956
+ target_ids=target_ids,
957
+ )
958
+ baseline_next = baseline_logits[prompt_len - 1]
959
+ rows: list[list[object]] = []
960
+ for sweep_idx, size in enumerate(valid_sizes):
961
+ sae_logits = outputs.logits[2 * sweep_idx]
962
+ random_logits = outputs.logits[2 * sweep_idx + 1]
963
+ sae_seq, sae_mean, _ = sequence_logprob_summary(
964
+ sae_logits,
965
+ prompt_length=prompt_len,
966
+ target_ids=target_ids,
967
+ )
968
+ random_seq, random_mean, _ = sequence_logprob_summary(
969
+ random_logits,
970
+ prompt_length=prompt_len,
971
+ target_ids=target_ids,
972
+ )
973
+ sae_delta = float(sae_mean - baseline_mean)
974
+ random_delta = float(random_mean - baseline_mean)
975
+ sae_js = js_divergence_from_logits(baseline_next, sae_logits[prompt_len - 1])
976
+ random_js = js_divergence_from_logits(baseline_next, random_logits[prompt_len - 1])
977
+ rows.append(
978
+ [
979
+ int(size),
980
+ ', '.join(str(x) for x in feature_lists[sweep_idx]),
981
+ norms[sweep_idx],
982
+ float(baseline_mean),
983
+ float(sae_mean),
984
+ sae_delta,
985
+ random_delta,
986
+ float(abs(sae_delta) / max(abs(random_delta), 1e-12)),
987
+ float(sae_seq - baseline_seq),
988
+ float(sae_js),
989
+ float(random_js),
990
+ ]
991
+ )
992
+ return FeatureSetSweepResult(
993
+ target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
994
+ rows=rows,
995
+ )
996
+
997
+ @torch.inference_mode()
998
+ def compare_paraphrases(
999
+ self,
1000
+ text_a: str,
1001
+ text_b: str,
1002
+ layer: int,
1003
+ token_index_a: int = -1,
1004
+ token_index_b: int = -1,
1005
+ top_n: int = 12,
1006
+ ) -> ParaphraseResult:
1007
+ if not text_a.strip() or not text_b.strip():
1008
+ raise ValueError('Enter both the original prompt and a paraphrase.')
1009
+ a = self.analyze(text_a, int(layer), int(token_index_a), max(int(top_n), 12))
1010
+ b = self.analyze(text_b, int(layer), int(token_index_b), max(int(top_n), 12))
1011
+
1012
+ map_a = self._encoding_map(a.features)
1013
+ map_b = self._encoding_map(b.features)
1014
+ set_a = set(map_a)
1015
+ set_b = set(map_b)
1016
+ union = set_a | set_b
1017
+ jaccard = len(set_a & set_b) / len(union) if union else 1.0
1018
+ cosine = sparse_topk_cosine(
1019
+ a.features.indices,
1020
+ a.features.values,
1021
+ b.features.indices,
1022
+ b.features.values,
1023
+ )
1024
+
1025
+ top_ids_a = [int(row[1]) for row in a.rows[: int(top_n)]]
1026
+ top_ids_b = [int(row[1]) for row in b.rows[: int(top_n)]]
1027
+ top_union = list(dict.fromkeys(top_ids_a + top_ids_b))
1028
+ shared_top_n = len(set(top_ids_a) & set(top_ids_b))
1029
+ rows: list[list[object]] = []
1030
+ chart_rows: list[list[object]] = []
1031
+ for feature_id in top_union:
1032
+ va = float(map_a.get(feature_id, 0.0))
1033
+ vb = float(map_b.get(feature_id, 0.0))
1034
+ status = 'shared' if va > 0 and vb > 0 else ('original only' if va > 0 else 'paraphrase only')
1035
+ rows.append(
1036
+ [
1037
+ feature_id,
1038
+ va,
1039
+ vb,
1040
+ status,
1041
+ self.catalog.hint(int(layer), feature_id),
1042
+ ]
1043
+ )
1044
+ chart_rows.append([str(feature_id), 'Original', va])
1045
+ chart_rows.append([str(feature_id), 'Paraphrase', vb])
1046
+
1047
+ rows.sort(key=lambda row: max(float(row[1]), float(row[2])), reverse=True)
1048
+ return ParaphraseResult(
1049
+ tokens_a=a.tokens,
1050
+ token_index_a=a.token_index,
1051
+ tokens_b=b.tokens,
1052
+ token_index_b=b.token_index,
1053
+ topk_jaccard=float(jaccard),
1054
+ sparse_cosine=float(cosine),
1055
+ shared_top_n=int(shared_top_n),
1056
+ top_n=int(top_n),
1057
  rows=rows,
1058
+ chart_rows=chart_rows,
1059
  )
1060
 
1061
 
featurelens/selection.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from collections import defaultdict
5
+ from pathlib import Path
6
+
7
+
8
+ def load_feature_sets(path: str | Path, max_size: int) -> dict[str, dict]:
9
+ """
10
+ Select a same-layer feature set for each concept from a feature catalog.
11
+
12
+ The layer whose best training feature is strongest is selected first. The top
13
+ distinct features from that same SAE dictionary are then returned. Keeping a
14
+ set within one layer is essential because decoder directions from different
15
+ residual spaces should not be summed into one intervention.
16
+ """
17
+ with Path(path).open(newline='', encoding='utf-8') as handle:
18
+ rows = list(csv.DictReader(handle))
19
+
20
+ grouped: dict[str, list[dict]] = defaultdict(list)
21
+ for row in rows:
22
+ item = dict(row)
23
+ item['layer'] = int(row['layer'])
24
+ item['feature_id'] = int(row['feature_id'])
25
+ item['train_auroc'] = float(row['train_auroc'])
26
+ item['activation_contrast'] = (
27
+ float(row['activation_rate_pos']) - float(row['activation_rate_neg'])
28
+ )
29
+ grouped[row['concept']].append(item)
30
+
31
+ result: dict[str, dict] = {}
32
+ for concept, concept_rows in grouped.items():
33
+ best_by_layer: dict[int, tuple[float, float]] = {}
34
+ for row in concept_rows:
35
+ key = (row['train_auroc'], row['activation_contrast'])
36
+ best_by_layer[row['layer']] = max(
37
+ best_by_layer.get(row['layer'], (-1.0, -1.0)),
38
+ key,
39
+ )
40
+ chosen_layer = max(best_by_layer, key=best_by_layer.get)
41
+ layer_rows = [row for row in concept_rows if row['layer'] == chosen_layer]
42
+ layer_rows.sort(
43
+ key=lambda row: (row['train_auroc'], row['activation_contrast']),
44
+ reverse=True,
45
+ )
46
+
47
+ seen: set[int] = set()
48
+ feature_ids: list[int] = []
49
+ for row in layer_rows:
50
+ feature_id = int(row['feature_id'])
51
+ if feature_id not in seen:
52
+ seen.add(feature_id)
53
+ feature_ids.append(feature_id)
54
+ if len(feature_ids) >= int(max_size):
55
+ break
56
+
57
+ result[concept] = {
58
+ 'layer': int(chosen_layer),
59
+ 'feature_ids': feature_ids,
60
+ }
61
+ return result
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "featurelens"
3
- version = "0.2.0"
4
  description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
5
  requires-python = ">=3.10"
6
 
 
1
  [project]
2
  name = "featurelens"
3
+ version = "0.3.0"
4
  description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
5
  requires-python = ">=3.10"
6
 
research_config.json CHANGED
@@ -32,7 +32,7 @@
32
  "scale_2x"
33
  ],
34
  "negative_control": "norm-matched random residual direction",
35
- "primary_causal_metric": "target first-token log-probability delta",
36
  "live_causal_controls": "norm-matched random residual direction",
37
  "dose_response_multipliers": [
38
  0.0,
@@ -45,5 +45,23 @@
45
  "statistical_inference": [
46
  "bootstrap_95_ci",
47
  "paired_sign_flip_test"
48
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  }
 
32
  "scale_2x"
33
  ],
34
  "negative_control": "norm-matched random residual direction",
35
+ "primary_causal_metric": "full target continuation mean log-probability delta per token (teacher-forced)",
36
  "live_causal_controls": "norm-matched random residual direction",
37
  "dose_response_multipliers": [
38
  0.0,
 
45
  "statistical_inference": [
46
  "bootstrap_95_ci",
47
  "paired_sign_flip_test"
48
+ ],
49
+ "live_features_v0_3": [
50
+ "full_continuation_scoring",
51
+ "joint_multi_feature_intervention",
52
+ "topk_feature_set_size_sweep",
53
+ "paraphrase_robustness_explorer"
54
+ ],
55
+ "feature_set_sizes": [
56
+ 1,
57
+ 3,
58
+ 5
59
+ ],
60
+ "feature_set_interventions": [
61
+ "ablate",
62
+ "scale"
63
+ ],
64
+ "feature_set_negative_control": "norm-matched random residual direction",
65
+ "dose_response_execution": "batched residual edits in one model forward after baseline",
66
+ "feature_set_sweep_execution": "all SAE and random-control residual edits batched in one model forward after baseline"
67
  }
scripts/release_check.py CHANGED
@@ -7,256 +7,179 @@ from pathlib import Path
7
 
8
 
9
  ROOT = Path(__file__).resolve().parents[1]
10
-
11
- # Any non-ignored repository file larger than this is suspicious.
12
- # FeatureLens should not contain model weights, SAE checkpoints,
13
- # activation dumps, virtual environments, etc.
14
  MAX_FILE_SIZE_BYTES = 5_000_000 # 5 MB
15
 
16
  REQUIRED = [
17
- "README.md",
18
- "app.py",
19
- "requirements.txt",
20
- "research_config.json",
21
- "featurelens/runtime.py",
22
- "featurelens/sae.py",
23
- "featurelens/interventions.py",
24
- "featurelens/stats.py",
25
- "experiments/run_all.py",
26
- "data/prompts.jsonl",
27
- "data/causal_tasks.jsonl",
 
 
 
 
28
  ]
29
 
30
 
31
  def load_jsonl(path: Path) -> list[dict]:
32
- """Load a JSONL file into a list of dictionaries."""
33
  return [
34
  json.loads(line)
35
- for line in path.read_text(encoding="utf-8").splitlines()
36
  if line.strip()
37
  ]
38
 
39
 
40
  def repository_candidates() -> list[Path]:
41
- """
42
- Return files that are either:
43
-
44
- - already tracked by Git, or
45
- - untracked but not ignored by .gitignore.
46
-
47
- This deliberately excludes files such as .venv contents when .venv/
48
- is correctly listed in .gitignore.
49
-
50
- Including untracked, non-ignored files is useful because it catches
51
- accidental large files before someone runs `git add .`.
52
- """
53
  try:
54
  result = subprocess.run(
55
- [
56
- "git",
57
- "ls-files",
58
- "--cached",
59
- "--others",
60
- "--exclude-standard",
61
- ],
62
  cwd=ROOT,
63
  capture_output=True,
64
  text=True,
65
  check=True,
66
  )
67
  except FileNotFoundError as exc:
68
- raise SystemExit(
69
- "Git is required to run the FeatureLens release check."
70
- ) from exc
71
  except subprocess.CalledProcessError as exc:
72
- stderr = exc.stderr.strip()
73
  raise SystemExit(
74
- f"Could not inspect repository files with Git: {stderr}"
75
  ) from exc
76
 
77
- candidates: list[Path] = []
78
-
79
  for relative_path in result.stdout.splitlines():
80
  relative_path = relative_path.strip()
81
-
82
  if not relative_path:
83
  continue
84
-
85
  path = ROOT / relative_path
86
-
87
- # A tracked file may have been deleted locally but not yet committed.
88
- # Such a path should not be size-checked.
89
  if path.is_file():
90
- candidates.append(path)
91
-
92
- return candidates
93
 
94
 
95
  def check_required_files() -> None:
96
- """Ensure the repository contains all files required for a release."""
97
  missing = [path for path in REQUIRED if not (ROOT / path).exists()]
98
-
99
  if missing:
100
- raise SystemExit(f"Missing required files: {missing}")
101
 
102
 
103
  def check_config(config: dict) -> None:
104
- """Validate the research configuration expected by FeatureLens v0.2."""
105
- if config.get("layers") != [4, 14, 26]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  raise SystemExit(
107
- f'Unexpected layers: {config.get("layers")}. '
108
- "Expected [4, 14, 26]."
109
- )
110
-
111
- if config.get("model_id") != "Qwen/Qwen3-1.7B-Base":
112
- raise SystemExit(
113
- f'Unexpected model_id: {config.get("model_id")}.'
114
- )
115
-
116
- if config.get("sae_width") != 32768:
117
- raise SystemExit(
118
- f'Unexpected sae_width: {config.get("sae_width")}. '
119
- "Expected 32768."
120
- )
121
-
122
- expected_multipliers = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0]
123
-
124
- if config.get("dose_response_multipliers") != expected_multipliers:
125
- raise SystemExit(
126
- "Unexpected dose_response_multipliers: "
127
- f'{config.get("dose_response_multipliers")}. '
128
- f"Expected {expected_multipliers}."
129
  )
130
 
131
 
132
  def check_datasets(config: dict) -> tuple[list[dict], list[dict]]:
133
- """Validate discovery and causal benchmark datasets."""
134
- prompts = load_jsonl(ROOT / "data" / "prompts.jsonl")
135
- causal = load_jsonl(ROOT / "data" / "causal_tasks.jsonl")
136
-
137
- expected_prompt_count = config.get("discovery_prompts")
138
- expected_causal_count = config.get("causal_tasks")
139
 
140
- if len(prompts) != expected_prompt_count:
141
  raise SystemExit(
142
- "Discovery prompt count mismatch: "
143
- f"found {len(prompts)}, expected {expected_prompt_count}."
144
  )
145
-
146
- if len(causal) != expected_causal_count:
147
  raise SystemExit(
148
- "Causal task count mismatch: "
149
- f"found {len(causal)}, expected {expected_causal_count}."
150
  )
151
 
152
- concept_counts = Counter(row["concept"] for row in prompts)
153
-
154
- expected_concepts = config.get("concepts", [])
155
-
156
- if set(concept_counts) != set(expected_concepts):
157
- raise SystemExit(
158
- "Discovery dataset concepts do not match research_config.json.\n"
159
- f"Dataset concepts: {sorted(concept_counts)}\n"
160
- f"Config concepts: {sorted(expected_concepts)}"
161
- )
162
-
163
- # The controlled discovery benchmark is intentionally balanced.
164
  if len(set(concept_counts.values())) != 1:
165
- raise SystemExit(
166
- f"Discovery concepts are not balanced: {dict(concept_counts)}"
167
- )
 
 
168
 
169
  return prompts, causal
170
 
171
 
172
  def check_oversized_files() -> None:
173
- """
174
- Reject unexpectedly large files that could be committed/pushed.
175
-
176
- Importantly, this does NOT recursively scan .venv or other ignored
177
- directories. Git decides what counts as a repository candidate.
178
- """
179
  oversized: list[str] = []
180
-
181
  for path in repository_candidates():
182
  size_bytes = path.stat().st_size
183
-
184
  if size_bytes > MAX_FILE_SIZE_BYTES:
185
- relative_path = path.relative_to(ROOT)
186
- size_mb = size_bytes / 1_000_000
187
-
188
- oversized.append(
189
- f"{relative_path} ({size_mb:.1f} MB)"
190
- )
191
 
192
  if oversized:
193
- formatted = "\n - ".join(oversized)
194
-
195
  raise SystemExit(
196
- "Repository contains unexpectedly large tracked/unignored "
197
- "candidates:\n"
198
- f" - {formatted}\n\n"
199
- "If a file is a legitimate local artifact, add it to .gitignore. "
200
- "Model weights, SAE checkpoints, activation dumps, virtual "
201
- "environments, and caches should not be committed."
202
  )
203
 
204
 
205
  def check_readme() -> None:
206
- """Validate important Hugging Face Space/release metadata."""
207
- readme = (ROOT / "README.md").read_text(encoding="utf-8")
208
-
209
  required_strings = [
210
- "sdk: gradio",
211
  'sdk_version: "6.24.0"',
212
- "Qwen/Qwen3-1.7B-Base",
213
- ]
214
-
215
- missing = [
216
- value
217
- for value in required_strings
218
- if value not in readme
219
  ]
220
-
221
  if missing:
222
- raise SystemExit(
223
- f"README.md is missing required metadata/content: {missing}"
224
- )
225
 
226
- readme_lower = readme.lower()
227
 
228
- if "norm-matched random" not in readme_lower:
229
- raise SystemExit(
230
- 'README.md should describe the "norm-matched random" control.'
231
- )
232
-
233
- if "dose" not in readme_lower:
234
- raise SystemExit(
235
- "README.md should describe the dose-response experiment."
236
- )
237
 
238
 
239
  def main() -> None:
240
  check_required_files()
241
-
242
- config = json.loads(
243
- (ROOT / "research_config.json").read_text(encoding="utf-8")
244
- )
245
-
246
  check_config(config)
247
-
248
  prompts, causal = check_datasets(config)
249
-
250
  check_oversized_files()
251
-
252
  check_readme()
 
253
 
254
- print("FeatureLens release check: PASS")
255
- print(f" discovery prompts: {len(prompts)}")
256
- print(f" causal tasks: {len(causal)}")
257
  print(f' layers: {config["layers"]}')
258
- print(" release: v0.2.0")
 
259
 
260
 
261
- if __name__ == "__main__":
262
- main()
 
7
 
8
 
9
  ROOT = Path(__file__).resolve().parents[1]
 
 
 
 
10
  MAX_FILE_SIZE_BYTES = 5_000_000 # 5 MB
11
 
12
  REQUIRED = [
13
+ 'README.md',
14
+ 'app.py',
15
+ 'requirements.txt',
16
+ 'research_config.json',
17
+ 'featurelens/runtime.py',
18
+ 'featurelens/sae.py',
19
+ 'featurelens/interventions.py',
20
+ 'featurelens/metrics.py',
21
+ 'featurelens/stats.py',
22
+ 'experiments/run_all.py',
23
+ 'experiments/run_causal.py',
24
+ 'experiments/run_feature_sets.py',
25
+ 'data/prompts.jsonl',
26
+ 'data/causal_tasks.jsonl',
27
+ 'docs/VALIDATION.md',
28
  ]
29
 
30
 
31
  def load_jsonl(path: Path) -> list[dict]:
 
32
  return [
33
  json.loads(line)
34
+ for line in path.read_text(encoding='utf-8').splitlines()
35
  if line.strip()
36
  ]
37
 
38
 
39
  def repository_candidates() -> list[Path]:
40
+ """Return tracked files plus untracked files that are not ignored by Git."""
 
 
 
 
 
 
 
 
 
 
 
41
  try:
42
  result = subprocess.run(
43
+ ['git', 'ls-files', '--cached', '--others', '--exclude-standard'],
 
 
 
 
 
 
44
  cwd=ROOT,
45
  capture_output=True,
46
  text=True,
47
  check=True,
48
  )
49
  except FileNotFoundError as exc:
50
+ raise SystemExit('Git is required to run the FeatureLens release check.') from exc
 
 
51
  except subprocess.CalledProcessError as exc:
 
52
  raise SystemExit(
53
+ f'Could not inspect repository files with Git: {exc.stderr.strip()}'
54
  ) from exc
55
 
56
+ paths: list[Path] = []
 
57
  for relative_path in result.stdout.splitlines():
58
  relative_path = relative_path.strip()
 
59
  if not relative_path:
60
  continue
 
61
  path = ROOT / relative_path
 
 
 
62
  if path.is_file():
63
+ paths.append(path)
64
+ return paths
 
65
 
66
 
67
  def check_required_files() -> None:
 
68
  missing = [path for path in REQUIRED if not (ROOT / path).exists()]
 
69
  if missing:
70
+ raise SystemExit(f'Missing required files: {missing}')
71
 
72
 
73
  def check_config(config: dict) -> None:
74
+ expected = {
75
+ 'layers': [4, 14, 26],
76
+ 'model_id': 'Qwen/Qwen3-1.7B-Base',
77
+ 'sae_width': 32768,
78
+ 'dose_response_multipliers': [0.0, 0.5, 1.0, 1.5, 2.0, 3.0],
79
+ 'feature_set_sizes': [1, 3, 5],
80
+ }
81
+ for key, value in expected.items():
82
+ if config.get(key) != value:
83
+ raise SystemExit(f'Unexpected {key}: {config.get(key)!r}. Expected {value!r}.')
84
+
85
+ required_live = {
86
+ 'full_continuation_scoring',
87
+ 'joint_multi_feature_intervention',
88
+ 'topk_feature_set_size_sweep',
89
+ 'paraphrase_robustness_explorer',
90
+ }
91
+ actual_live = set(config.get('live_features_v0_3', []))
92
+ if actual_live != required_live:
93
  raise SystemExit(
94
+ 'research_config.json live_features_v0_3 mismatch: '
95
+ f'{sorted(actual_live)}'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  )
97
 
98
 
99
  def check_datasets(config: dict) -> tuple[list[dict], list[dict]]:
100
+ prompts = load_jsonl(ROOT / 'data' / 'prompts.jsonl')
101
+ causal = load_jsonl(ROOT / 'data' / 'causal_tasks.jsonl')
 
 
 
 
102
 
103
+ if len(prompts) != config.get('discovery_prompts'):
104
  raise SystemExit(
105
+ f'Discovery prompt count mismatch: found {len(prompts)}, '
106
+ f'expected {config.get("discovery_prompts")}.'
107
  )
108
+ if len(causal) != config.get('causal_tasks'):
 
109
  raise SystemExit(
110
+ f'Causal task count mismatch: found {len(causal)}, '
111
+ f'expected {config.get("causal_tasks")}.'
112
  )
113
 
114
+ concept_counts = Counter(row['concept'] for row in prompts)
115
+ if set(concept_counts) != set(config.get('concepts', [])):
116
+ raise SystemExit('Discovery dataset concepts do not match research_config.json.')
 
 
 
 
 
 
 
 
 
117
  if len(set(concept_counts.values())) != 1:
118
+ raise SystemExit(f'Discovery concepts are not balanced: {dict(concept_counts)}')
119
+
120
+ pair_counts = Counter(row['pair_id'] for row in prompts)
121
+ if set(pair_counts.values()) != {2}:
122
+ raise SystemExit('Every discovery paraphrase pair must contain exactly two prompts.')
123
 
124
  return prompts, causal
125
 
126
 
127
  def check_oversized_files() -> None:
 
 
 
 
 
 
128
  oversized: list[str] = []
 
129
  for path in repository_candidates():
130
  size_bytes = path.stat().st_size
 
131
  if size_bytes > MAX_FILE_SIZE_BYTES:
132
+ relative = path.relative_to(ROOT)
133
+ oversized.append(f'{relative} ({size_bytes / 1_000_000:.1f} MB)')
 
 
 
 
134
 
135
  if oversized:
136
+ formatted = '\n - '.join(oversized)
 
137
  raise SystemExit(
138
+ 'Repository contains unexpectedly large tracked/unignored candidates:\n'
139
+ f' - {formatted}\n\n'
140
+ 'If a file is a legitimate local artifact, add it to .gitignore. Model weights, '
141
+ 'SAE checkpoints, activation dumps, virtual environments, and caches should not be committed.'
 
 
142
  )
143
 
144
 
145
  def check_readme() -> None:
146
+ readme = (ROOT / 'README.md').read_text(encoding='utf-8')
 
 
147
  required_strings = [
148
+ 'sdk: gradio',
149
  'sdk_version: "6.24.0"',
150
+ 'Qwen/Qwen3-1.7B-Base',
151
+ 'full-continuation',
152
+ 'feature-set',
153
+ 'paraphrase',
154
+ 'norm-matched',
 
 
155
  ]
156
+ missing = [value for value in required_strings if value.lower() not in readme.lower()]
157
  if missing:
158
+ raise SystemExit(f'README.md is missing required v0.3 content: {missing}')
 
 
159
 
 
160
 
161
+ def check_pyproject() -> None:
162
+ text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
163
+ if 'version = "0.3.0"' not in text:
164
+ raise SystemExit('pyproject.toml must declare version 0.3.0.')
 
 
 
 
 
165
 
166
 
167
  def main() -> None:
168
  check_required_files()
169
+ config = json.loads((ROOT / 'research_config.json').read_text(encoding='utf-8'))
 
 
 
 
170
  check_config(config)
 
171
  prompts, causal = check_datasets(config)
 
172
  check_oversized_files()
 
173
  check_readme()
174
+ check_pyproject()
175
 
176
+ print('FeatureLens release check: PASS')
177
+ print(f' discovery prompts: {len(prompts)}')
178
+ print(f' causal tasks: {len(causal)}')
179
  print(f' layers: {config["layers"]}')
180
+ print(f' feature-set sizes: {config["feature_set_sizes"]}')
181
+ print(' release: v0.3.0')
182
 
183
 
184
+ if __name__ == '__main__':
185
+ main()
tests/test_feature_sets.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from featurelens.selection import load_feature_sets
6
+
7
+
8
+ def test_feature_set_selection_stays_within_one_best_layer(tmp_path: Path) -> None:
9
+ catalog = tmp_path / 'feature_catalog.csv'
10
+ catalog.write_text(
11
+ 'layer,concept,feature_id,train_auroc,auroc,f1,activation_rate_pos,activation_rate_neg\n'
12
+ '4,math,10,0.80,0.75,0.70,0.8,0.2\n'
13
+ '14,math,20,0.92,0.82,0.80,0.9,0.1\n'
14
+ '14,math,21,0.90,0.81,0.79,0.8,0.2\n'
15
+ '14,math,22,0.88,0.80,0.78,0.7,0.2\n'
16
+ '26,math,30,0.85,0.79,0.76,0.8,0.3\n',
17
+ encoding='utf-8',
18
+ )
19
+ selected = load_feature_sets(catalog, max_size=3)
20
+ assert selected['math']['layer'] == 14
21
+ assert selected['math']['feature_ids'] == [20, 21, 22]
tests/test_interventions.py CHANGED
@@ -1,8 +1,14 @@
1
  from __future__ import annotations
2
 
 
3
  import torch
4
 
5
- from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
 
 
 
 
 
6
 
7
 
8
  def test_ablation_delta() -> None:
@@ -27,6 +33,42 @@ def test_residual_delta_is_decoder_direction_times_coefficient_delta() -> None:
27
  assert torch.allclose(delta, torch.tensor([-3.0, -6.0, 3.0]))
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  def test_random_control_matches_norm_and_is_deterministic() -> None:
31
  delta = torch.tensor([3.0, 4.0, 0.0])
32
  a = normalized_random_control(delta, seed=7)
 
1
  from __future__ import annotations
2
 
3
+ import pytest
4
  import torch
5
 
6
+ from featurelens.interventions import (
7
+ InterventionSpec,
8
+ joint_residual_delta,
9
+ normalized_random_control,
10
+ residual_delta,
11
+ )
12
 
13
 
14
  def test_ablation_delta() -> None:
 
33
  assert torch.allclose(delta, torch.tensor([-3.0, -6.0, 3.0]))
34
 
35
 
36
+ def test_joint_ablation_sums_feature_deltas() -> None:
37
+ directions = torch.tensor(
38
+ [
39
+ [1.0, 0.0, 2.0],
40
+ [0.0, 1.0, -1.0],
41
+ ]
42
+ )
43
+ delta, coefficient_deltas = joint_residual_delta(
44
+ directions,
45
+ [2.0, 3.0],
46
+ InterventionSpec('ablate', 0.0),
47
+ )
48
+ assert coefficient_deltas == [-2.0, -3.0]
49
+ assert torch.allclose(delta, torch.tensor([-2.0, -3.0, -1.0]))
50
+
51
+
52
+ def test_joint_scale_uses_same_multiplier_per_feature() -> None:
53
+ directions = torch.eye(2)
54
+ delta, coefficient_deltas = joint_residual_delta(
55
+ directions,
56
+ [2.0, 4.0],
57
+ InterventionSpec('scale', 1.5),
58
+ )
59
+ assert coefficient_deltas == [1.0, 2.0]
60
+ assert torch.allclose(delta, torch.tensor([1.0, 2.0]))
61
+
62
+
63
+ def test_joint_injection_is_rejected() -> None:
64
+ with pytest.raises(ValueError, match='only'):
65
+ joint_residual_delta(
66
+ torch.eye(2),
67
+ [1.0, 1.0],
68
+ InterventionSpec('inject', 2.0),
69
+ )
70
+
71
+
72
  def test_random_control_matches_norm_and_is_deterministic() -> None:
73
  delta = torch.tensor([3.0, 4.0, 0.0])
74
  a = normalized_random_control(delta, seed=7)
tests/test_metrics.py CHANGED
@@ -1,8 +1,17 @@
1
  from __future__ import annotations
2
 
 
 
3
  import torch
4
 
5
- from featurelens.metrics import js_divergence_from_logits, reconstruction_metrics, safe_log_probability
 
 
 
 
 
 
 
6
 
7
 
8
  def test_reconstruction_metrics_perfect_match() -> None:
@@ -20,3 +29,49 @@ def test_js_divergence_zero_for_identical_logits() -> None:
20
 
21
  def test_safe_log_probability_is_finite_at_zero() -> None:
22
  assert safe_log_probability(0.0) < 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import math
4
+
5
  import torch
6
 
7
+ from featurelens.metrics import (
8
+ js_divergence_from_logits,
9
+ reconstruction_metrics,
10
+ safe_log_probability,
11
+ sequence_logprob_summary,
12
+ sparse_topk_cosine,
13
+ target_token_logprobs,
14
+ )
15
 
16
 
17
  def test_reconstruction_metrics_perfect_match() -> None:
 
29
 
30
  def test_safe_log_probability_is_finite_at_zero() -> None:
31
  assert safe_log_probability(0.0) < 0.0
32
+
33
+
34
+ def test_target_logprobs_score_every_continuation_token() -> None:
35
+ # prompt length = 2, target ids = [1, 0].
36
+ # Target token 0 is predicted from logits row 1; token 1 from row 2.
37
+ logits = torch.tensor(
38
+ [
39
+ [0.0, 0.0],
40
+ [0.0, 2.0],
41
+ [3.0, 0.0],
42
+ [0.0, 0.0],
43
+ ]
44
+ )
45
+ values = target_token_logprobs(logits, prompt_length=2, target_ids=[1, 0])
46
+ expected_0 = torch.log_softmax(logits[1], dim=-1)[1]
47
+ expected_1 = torch.log_softmax(logits[2], dim=-1)[0]
48
+ assert torch.allclose(values, torch.stack([expected_0, expected_1]))
49
+
50
+
51
+ def test_sequence_summary_total_and_mean_are_consistent() -> None:
52
+ logits = torch.tensor(
53
+ [
54
+ [0.0, 0.0],
55
+ [0.0, 2.0],
56
+ [3.0, 0.0],
57
+ [0.0, 0.0],
58
+ ]
59
+ )
60
+ total, mean, token_values = sequence_logprob_summary(
61
+ logits,
62
+ prompt_length=2,
63
+ target_ids=[1, 0],
64
+ )
65
+ assert len(token_values) == 2
66
+ assert math.isclose(total, sum(token_values), rel_tol=1e-6)
67
+ assert math.isclose(mean, total / 2.0, rel_tol=1e-6)
68
+
69
+
70
+ def test_sparse_topk_cosine_is_one_for_identical_sparse_vectors() -> None:
71
+ cosine = sparse_topk_cosine([1, 3], [2.0, 1.0], [1, 3], [2.0, 1.0])
72
+ assert abs(cosine - 1.0) < 1e-9
73
+
74
+
75
+ def test_sparse_topk_cosine_is_zero_for_disjoint_support() -> None:
76
+ cosine = sparse_topk_cosine([1, 3], [2.0, 1.0], [2, 4], [5.0, 7.0])
77
+ assert cosine == 0.0