ArchitSharma commited on
Commit
6d68f94
·
1 Parent(s): b3d11b8

Release FeatureLens v0.4.0

Browse files
CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ## v0.3.0
4
 
5
  ### Causal measurement
 
1
  # Changelog
2
 
3
+ ## v0.4.0
4
+
5
+ ### Causal correctness
6
+ - Added an explicit **batched zero-edit reference** to live causal batches so intervention effects are measured against the same execution context as edited rows.
7
+ - Changed the scale dose-response reference to the batched `1×` row. The `1×` row is therefore an exact causal no-op by construction rather than a separately executed baseline comparison.
8
+ - Added execution-context drift diagnostics so any remaining single-forward vs batched-forward numerical difference is reported as instrumentation drift, not causal signal.
9
+
10
+ ### Stronger negative controls
11
+ - Replaced the single live random residual direction with an **8-direction norm-matched random ensemble**.
12
+ - Single-feature, joint feature-set, and 1/3/5 set-size experiments now report random signed mean, mean absolute effect, standard deviation, targeted/random magnitude ratio, and a small-sample empirical tail probability.
13
+ - Updated offline causal and feature-set runners to use the same zero-edit reference and configurable random-control ensembles.
14
+ - Updated report pairing so each targeted intervention is compared with the mean absolute effect of its complete random-control ensemble rather than an arbitrary first control.
15
+
16
+ ### Distributed causality
17
+ - Added **individual-vs-joint ablation decomposition** for 2–5 features.
18
+ - Reports the individual effects, additive expectation, observed joint effect, interaction excess, and normalized non-additivity.
19
+ - The UI explicitly treats non-additivity as a diagnostic, not proof of a direct feature-feature circuit.
20
+
21
+ ### Representation robustness and evidence
22
+ - Added **prompt-wide paraphrase robustness** using max activation per SAE feature across all prompt tokens, alongside the stricter selected-token comparison.
23
+ - Added a live **controlled concept contrast scan** over the seven balanced discovery concepts for one selected feature.
24
+ - Concept contrast results remain exploratory and never overwrite `Offline concept hint` or claim a semantic label from the live scan.
25
+
26
+ ### UI and export
27
+ - Reworked the visual language toward a restrained, print-inspired interface with serif typography, flatter controls, thin rules, and muted chart colors.
28
+ - Replaced intervention radio pills with conventional dropdown controls and realigned the Feature Sets form.
29
+ - Added extra bottom spacing plus an explicit end-of-workbench footer to avoid an app-controlled abrupt cutoff in embedded Spaces.
30
+ - Added **Copy table with headers** actions for every major output table; copied text is tab-separated and begins with the column names.
31
+ - Added a dedicated **Feature evidence** tab for controlled feature-concept contrast tests.
32
+
33
+ ### Validation
34
+ - Expanded the software suite to **29 tests**.
35
+ - Added report tests that verify random-control ensembles are aggregated correctly before paired causal statistics are computed.
36
+ - Rewrote `docs/VALIDATION.md` around exact v0.4 UI labels, including explicit numerical-null, table-copy, adversarial, responsive-layout, and queue tests.
37
+
38
  ## v0.3.0
39
 
40
  ### Causal measurement
README.md CHANGED
@@ -13,149 +13,202 @@ license: mit
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
81
- scale α: h' = h + (α - 1) 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
 
134
- The repository ships a controlled benchmark with:
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
 
@@ -166,7 +219,7 @@ pip install -r requirements.txt
166
  python experiments/run_all.py
167
  ```
168
 
169
- The pipeline runs:
170
 
171
  ```text
172
  build_dataset
@@ -177,15 +230,11 @@ build_dataset
177
  → make_report
178
  ```
179
 
180
- Outputs are materialized under `artifacts/`:
181
 
182
  ```text
183
  artifacts/
184
  ├── activations/
185
- │ ├── metadata.json
186
- │ ├── residuals_layer4.npy
187
- │ ├── features_layer4.npz
188
- │ └── ...
189
  ├── feature_catalog.csv
190
  ├── layer_metrics.csv
191
  ├── stability.csv
@@ -194,19 +243,15 @@ artifacts/
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
 
211
  ```text
212
  FEATURELENS_MODEL_ID=Qwen/Qwen3-1.7B-Base
@@ -215,9 +260,22 @@ FEATURELENS_LAYERS=4,14,26
215
  FEATURELENS_EAGER_LOAD=1
216
  FEATURELENS_SAE_DTYPE=float16
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
 
@@ -257,21 +315,22 @@ python -m compileall -q app.py featurelens experiments 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
 
 
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
+ > **v0.4:** batch-context causal baselines, 8-direction norm-matched random ensembles, individual-vs-joint interaction decomposition, prompt-wide paraphrase robustness, a controlled live concept contrast, copy-ready tables with headers, and a more restrained research-instrument UI.
17
 
18
+ **Research question:**
19
 
20
  > Do sparse features that predict a concept also causally influence model behaviour?
21
 
22
+ FeatureLens uses **Qwen3-1.7B-Base** with **Qwen-Scope residual-stream sparse autoencoders (SAEs)**. It is not an SAE viewer clone: the project separates representation quality, held-out predictiveness, robustness, and causal intervention evidence.
23
 
24
+ FeatureLens is independent of thesis code and thesis datasets.
25
 
26
  ## Evidence ladder
27
 
 
 
28
  1. **Reconstruction** — does the SAE represent the residual stream reasonably well?
29
  2. **Prediction** — does a feature distinguish a controlled concept on held-out paraphrase groups?
30
+ 3. **Robustness** — does the representation survive rewording locally and prompt-wide?
31
+ 4. **Controlled contrast** — does a selected feature prefer one concept group in a balanced live mini-batch?
32
+ 5. **Single-feature causality** — does ablation/scaling change downstream probability?
33
+ 6. **Dose-response** — does effect size vary coherently with feature coefficient?
34
+ 7. **Feature-set causality** — do small sparse subspaces matter more than one feature?
35
+ 8. **Non-additivity** — does a joint edit differ from the sum of individual effects?
36
+ 9. **Specificity** — are targeted effects larger than norm-matched random residual perturbations?
37
 
38
+ A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
39
 
40
+ ## Why v0.4 changed the causal baseline
41
 
42
+ v0.3 batched multiple residual edits for efficiency, but compared them with a **separately executed baseline**. On real Qwen3 inference this produced a small numerical discrepancy in the nominal `1×` no-edit dose-response row.
43
 
44
+ v0.4 therefore includes an explicit **batched zero-edit reference** in causal batches. Every effect inside that batch is measured against the zero-edit row from the **same execution context**.
45
+
46
+ For the scale dose-response, the `1×` row itself is the no-edit reference:
47
+
48
+ ```text
49
+ 0× = full ablation
50
+ 0.5× = half native coefficient
51
+ 1× = batched no-edit reference
52
+ 1.5× = 1.5 times native coefficient
53
+ 2× = double native coefficient
54
+ 3× = triple native coefficient
55
+ ```
56
+
57
+ The UI also reports single-forward batched-null execution drift as a diagnostic instead of silently treating it as a causal effect.
58
+
59
+ ## Random-control ensemble
60
+
61
+ A single random residual direction can be unusually weak or unusually disruptive by chance. v0.4 replaces the live single control with **8 deterministic norm-matched random directions**.
62
 
63
+ For a targeted residual delta `Δh`, each control satisfies:
64
 
65
+ ```text
66
+ ||Δh_random||₂ = ||Δh_SAE||₂
67
+ ```
68
+
69
+ The app reports:
70
 
71
+ - random signed mean effect;
72
+ - random mean absolute effect;
73
+ - random-effect standard deviation;
74
+ - targeted/random magnitude ratio;
75
+ - an exploratory empirical tail probability;
76
+ - equivalent next-token Jensen-Shannon statistics.
77
 
78
+ The live empirical probability is intentionally coarse with only eight controls. The offline benchmark aggregates across held-out tasks and control ensembles.
 
 
 
 
79
 
80
+ ## Live application
81
 
82
+ ### Workbench
83
 
84
+ The main workflow supports:
85
+
86
+ - prompt entry with explicit **Prompt tokens**;
87
+ - layers **4, 14, 26**;
88
+ - token-local SAE inspection;
89
+ - TopK feature activations;
90
+ - reconstruction cosine / NMSE / Top-5 activation mass;
91
+ - feature ablation, scaling, and injection;
92
+ - baseline vs SAE-edited greedy generation;
93
+ - next-token distribution shifts;
94
+ - exact **full-continuation teacher-forced scoring**;
95
+ - per-target-token log-probability decomposition;
96
+ - 8-direction norm-matched random controls;
97
+ - batched scale dose-response.
98
 
99
+ ### Feature sets
100
+
101
+ FeatureLens can jointly ablate or scale selected active features:
102
 
103
  ```text
104
+ h' = h + Σᵢ Δzᵢ dᵢ
 
 
105
  ```
106
 
107
+ The tab includes:
108
 
109
+ - manual joint feature-set intervention;
110
+ - automatic **1 / 3 / 5 strongest-feature ablation sweep**;
111
+ - random-ensemble specificity for each set size;
112
+ - **individual-vs-joint non-additivity decomposition** for 2–5 selected features.
113
+
114
+ For the interaction decomposition, FeatureLens compares:
115
 
116
  ```text
117
+ observed joint effect
118
+ vs
119
+ sum of individual feature effects
120
  ```
121
 
122
+ The difference is reported as an **interaction excess**. A non-zero excess means the downstream response is non-additive under these edits; it does not by itself prove a direct feature-feature circuit.
123
 
124
+ ### Feature evidence
125
 
126
+ The **Controlled concept contrast** takes a selected feature and evaluates it on a small balanced batch drawn from the repository's seven controlled concept groups:
127
 
128
+ - code;
129
+ - mathematics;
130
+ - positive sentiment;
131
+ - negative sentiment;
132
+ - French language;
133
+ - factual entities;
134
+ - uncertainty.
135
 
136
+ One wording per paraphrase pair is used to reduce near-duplicate inflation. The live scan reports mean activation, median activation, activation rate, and maximum activation by concept.
137
 
138
+ This is an exploratory live feature-evidence tool, **not an automatic semantic label**. The held-out offline AUROC/F1 benchmark remains the stronger claim.
139
+
140
+ ### Paraphrase robustness
141
+
142
+ v0.4 reports two complementary views:
143
+
144
+ 1. **Selected-token robustness** — TopK Jaccard and sparse cosine at the two manually chosen token positions.
145
+ 2. **Prompt-wide robustness** — for each SAE feature, take its maximum activation across all prompt tokens, then compare the two sparse prompt profiles.
146
+
147
+ The prompt-wide view reduces sensitivity to accidentally comparing semantically different final tokens.
148
+
149
+ ### Layer trajectory
150
+
151
+ The trajectory view compares SAE reconstruction and sparsity/concentration diagnostics at the same prompt token across layers 4, 14 and 26. It does **not** equate feature IDs across layer-specific SAE dictionaries.
152
 
153
+ ## Copy tables with headers
154
 
155
+ Every result table that is intended for analysis has an explicit **Copy table with headers** button. The app serializes the displayed data as TSV before copying, so column names are preserved.
156
 
157
+ The native Dataframe fullscreen control is retained, while the custom copy action avoids relying on browser text selection.
158
+
159
+ ## Reconstruction-preserving edits
160
+
161
+ Let `h` be the original residual vector, `z_i` the SAE coefficient, and `d_i` the decoder direction.
162
 
163
  ```text
164
+ ablate: h' = h - z_i d_i
165
+ scale α: h' = h + (α - 1) z_i d_i
166
+ inject δ: h' = h + δ d_i
167
  ```
168
 
169
+ FeatureLens patches only the decoder-direction **delta** into the original residual. It never replaces the residual with the full SAE reconstruction, avoiding reconstruction error as a causal confound.
170
 
171
+ ## Full-continuation scoring
172
 
173
+ A target such as `2x` can tokenize into more than one token. FeatureLens concatenates the exact target token IDs to the prompt and scores every target token teacher-forced.
174
 
175
+ It reports:
176
 
177
+ - target sequence log probability;
178
+ - mean target log probability per token;
179
+ - per-token log probabilities;
180
+ - targeted edit deltas;
181
+ - random-ensemble comparison.
182
 
183
+ Greedy generations can remain identical while these probability-level metrics move. A deterministic text change only occurs after an edit crosses an argmax boundary.
184
 
185
  ## Offline experiment
186
 
187
+ The repository ships:
188
 
189
+ - **224 discovery prompts**;
190
+ - **112 paraphrase pairs**;
191
+ - 7 controlled concepts;
192
  - **28 separate causal completion tasks**;
193
+ - residual/SAE activation collection at layers 4, 14 and 26.
194
 
195
+ The offline pipeline computes:
196
 
197
  - SAE reconstruction cosine / NMSE;
198
  - held-out feature/concept AUROC and F1;
199
+ - paraphrase TopK Jaccard and sparse activation cosine;
200
+ - dense residual multinomial linear-probe baselines;
201
  - selected-feature ablation and 2× amplification;
202
+ - exact full-target mean log probability per token;
203
+ - next-token JS divergence and top-1 changes;
204
+ - **random-control ensembles** rather than one arbitrary random direction;
205
+ - top-1 / top-3 / top-5 joint feature-set ablations;
206
  - bootstrap 95% confidence intervals;
207
+ - paired sign-flip tests.
208
 
209
+ Feature selection is performed using the **training split only**. Paraphrase pairs never cross the train/test boundary.
210
 
211
+ ## Run the offline benchmark
212
 
213
  A CUDA machine is strongly recommended.
214
 
 
219
  python experiments/run_all.py
220
  ```
221
 
222
+ The pipeline is:
223
 
224
  ```text
225
  build_dataset
 
230
  → make_report
231
  ```
232
 
233
+ Outputs are generated under `artifacts/`:
234
 
235
  ```text
236
  artifacts/
237
  ├── activations/
 
 
 
 
238
  ├── feature_catalog.csv
239
  ├── layer_metrics.csv
240
  ├── stability.csv
 
243
  ├── summary.json
244
  ├── report.md
245
  └── figures/
 
 
 
 
246
  ```
247
 
248
+ `summary.json` and `report.md` are generated from measured artifacts. No scientific benchmark numbers are fabricated in the repository.
249
 
250
+ ## Hugging Face deployment
251
 
252
+ FeatureLens is a **Gradio SDK Space**. The app keeps SSR disabled and loads only SAE layers 4, 14 and 26 for the live workbench.
253
 
254
+ Useful environment variables:
255
 
256
  ```text
257
  FEATURELENS_MODEL_ID=Qwen/Qwen3-1.7B-Base
 
260
  FEATURELENS_EAGER_LOAD=1
261
  FEATURELENS_SAE_DTYPE=float16
262
  FEATURELENS_MAX_NEW_TOKENS=32
263
+ FEATURELENS_LIVE_RANDOM_CONTROLS=8
264
+ FEATURELENS_CONTRAST_PROMPTS_PER_CONCEPT=4
265
  ```
266
 
267
+ ## UI design
268
+
269
+ v0.4 intentionally moves away from a rounded, saturated dashboard aesthetic:
270
+
271
+ - Times / Liberation Serif-style formal typography;
272
+ - restrained teal, umber, stone, plum, and muted red chart accents;
273
+ - flatter controls and smaller corner radii;
274
+ - aligned intervention forms;
275
+ - explicit section rules instead of pill badges;
276
+ - extra bottom padding and a visible end-of-workbench footer to avoid an abrupt embedded-page cutoff.
277
+
278
+ The extra bottom spacing cannot control Hugging Face's outer iframe resizing, but it prevents the app itself from ending flush against its final component.
279
 
280
  ## Repository layout
281
 
 
315
  python scripts/release_check.py
316
  ```
317
 
318
+ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The validation guide uses the **exact labels shown in the v0.4 UI** and defines expected outcomes for normal, edge, and adversarial cases.
319
 
320
+ ## Limitations
321
 
322
+ - SAE features are sparse directions, not guaranteed monosemantic concepts.
323
+ - The live concept contrast is a small controlled diagnostic, not a universal ontology.
324
+ - Prompt-wide max pooling measures feature presence anywhere in the prompt and discards token order.
325
+ - Joint residual edits can be non-additive downstream without implying direct feature-feature interaction.
326
+ - Eight live random controls give only a coarse empirical tail probability.
327
+ - Teacher-forced target scoring measures probability assigned to a specified continuation, not sampled free-running sequence probability.
328
+ - All causal effects remain prompt-, token-, layer-, feature-, and intervention-scale dependent.
329
 
330
  ## Resume-ready description
331
 
332
  > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
333
+ > Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, local/prompt-wide paraphrase robustness, reconstruction-preserving single and multi-feature interventions, full-continuation scoring, dose-response analysis, non-additivity tests, and norm-matched random-control ensembles.
334
 
335
  ## Acknowledgements
336
 
app.py CHANGED
@@ -7,69 +7,179 @@ from featurelens.config import SETTINGS
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:
70
  raise gr.Error(f"{type(exc).__name__}: {exc}") from exc
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  def _analysis_metrics_markdown(result) -> str:
74
  return (
75
  "#### Analysis metrics\n"
@@ -82,6 +192,10 @@ def _analysis_metrics_markdown(result) -> str:
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."
@@ -92,29 +206,31 @@ def _intervention_metrics_markdown(result) -> str:
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 (
109
  f"Original feature activation: **{result.feature_activation:.4f}** · "
110
  f"Δ coefficient: **{result.delta_activation:+.4f}** \n"
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
 
@@ -127,7 +243,10 @@ def _dose_metrics_markdown(result) -> str:
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
 
@@ -135,8 +254,7 @@ 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
  )
@@ -145,25 +263,58 @@ def _feature_set_metrics_markdown(result) -> str:
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
 
@@ -173,12 +324,12 @@ def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
173
  if not prompt.strip():
174
  raise ValueError("Enter a prompt first.")
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],
@@ -186,13 +337,22 @@ def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
186
  "Series": ["Activation"] * len(result.rows),
187
  }
188
  )
 
 
 
 
189
  return (
190
  RUNTIME.token_html(result.tokens, result.token_index),
191
- result.rows,
192
  chart_df,
193
  feature_update,
194
  feature_set_update,
 
 
195
  _analysis_metrics_markdown(result),
 
 
 
196
  )
197
  except Exception as exc:
198
  _raise_ui_error(exc)
@@ -214,36 +374,43 @@ def run_intervention(
214
  raise ValueError("Enter a prompt first.")
215
  if feature_id is None or str(feature_id).strip() == "":
216
  raise ValueError("Choose or enter a feature id.")
217
- fid = int(float(feature_id))
218
  result = RUNTIME.intervene(
219
  text=prompt,
220
  layer=int(layer),
221
  token_index=int(token_index),
222
- feature_id=fid,
223
  mode=mode,
224
  coefficient=float(coefficient),
225
  target_text=target_text,
226
  max_new_tokens=int(max_new_tokens),
227
  )
 
 
 
 
 
 
 
 
 
 
 
 
228
  return (
229
  result.baseline_text,
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,
243
- token_index: int,
244
- feature_id: str,
245
- target_text: str,
246
- ):
247
  try:
248
  if not prompt.strip():
249
  raise ValueError("Enter a prompt first.")
@@ -262,7 +429,7 @@ def run_dose_response(
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",
@@ -271,7 +438,7 @@ def run_dose_response(
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)
277
 
@@ -298,12 +465,12 @@ def run_layer_sweep(prompt: str, token_index: int):
298
  var_name="Metric",
299
  value_name="Value",
300
  )
301
- return RUNTIME.token_html(result.tokens, result.token_index), table, long
302
  except Exception as exc:
303
  _raise_ui_error(exc)
304
 
305
 
306
- @gpu(duration=35)
307
  def run_feature_set(
308
  prompt: str,
309
  layer: int,
@@ -315,7 +482,7 @@ def run_feature_set(
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'.")
@@ -330,21 +497,34 @@ def run_feature_set(
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(
@@ -357,28 +537,78 @@ def run_feature_set_sweep(
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
 
@@ -401,72 +631,75 @@ def run_paraphrase_compare(
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(
419
- value=0.0,
420
- interactive=False,
421
- label="Coefficient (unused for ablation)",
422
- info="Sets the selected active feature coefficient to zero.",
423
- )
424
  if mode == "scale":
425
- return gr.update(
426
- value=2.0,
427
- interactive=True,
428
- label="Feature multiplier",
429
- info="1 = unchanged, 0 = ablate, 2 = double the original coefficient.",
430
- )
431
- return gr.update(
432
- value=5.0,
433
- interactive=True,
434
- label="Additive feature coefficient",
435
- info="Adds this amount along the decoder direction, even when the feature is inactive.",
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(
@@ -490,47 +723,50 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
490
  choices=list(SETTINGS.layers),
491
  value=SETTINGS.layers[1] if len(SETTINGS.layers) > 1 else SETTINGS.layers[0],
492
  label="Residual layer",
493
- info="Early / middle / late checkpoints are intentionally sampled.",
494
  )
495
  token_index = gr.Number(
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):
@@ -538,9 +774,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
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",
@@ -553,10 +788,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
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,
@@ -572,55 +804,60 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
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×, 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=[],
@@ -628,86 +865,180 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
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(
@@ -723,7 +1054,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
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")
@@ -738,33 +1069,33 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
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(
@@ -783,32 +1114,39 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
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)
788
- trajectory_plot = gr.LinePlot(
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"):
@@ -819,42 +1157,41 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
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
 
852
- gr.Markdown(
853
- "<small>Built with PyTorch, Transformers, Qwen3-1.7B-Base and Qwen-Scope residual-stream SAEs. "
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],
@@ -864,7 +1201,12 @@ A high AUROC or high paraphrase overlap alone remains correlational evidence.
864
  feature_plot,
865
  feature_id,
866
  feature_set_ids,
 
 
867
  analysis_metrics,
 
 
 
868
  ],
869
  )
870
  mode.change(mode_help, inputs=[mode], outputs=[coefficient])
@@ -877,37 +1219,64 @@ A high AUROC or high paraphrase overlap alone remains correlational evidence.
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,
 
7
  from featurelens.hf_runtime import gpu
8
  from featurelens.runtime import RUNTIME
9
 
10
+ # Restrained, print-inspired palette. The app deliberately avoids saturated dashboard colors.
11
+ INK_TEAL = "#708B86"
12
+ INK_UMBER = "#94785D"
13
+ INK_RED = "#986D68"
14
+ INK_PLUM = "#82768F"
15
+ INK_STONE = "#8E8A83"
16
+ INK_BLUEGREY = "#71808A"
17
+
18
+ CSS = r"""
19
+ .gradio-container {
20
+ max-width: 1280px !important;
21
+ padding-bottom: 96px !important;
22
+ font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
23
+ line-height: 1.45;
24
+ }
25
+ .gradio-container input,
26
+ .gradio-container textarea,
27
+ .gradio-container button,
28
+ .gradio-container select,
29
+ .gradio-container label,
30
+ .gradio-container table {
31
+ font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
32
+ }
33
+ .hero {
34
+ padding: 12px 2px 4px;
35
+ border-bottom: 1px solid var(--border-color-primary);
36
+ margin-bottom: 14px;
37
+ }
38
+ .hero h1 {
39
+ margin: 0;
40
+ font-size: 2.15rem;
41
+ font-weight: 600;
42
+ letter-spacing: 0;
43
+ }
44
+ .hero .subtitle {
45
+ margin-top: 3px;
46
+ font-size: 1.02rem;
47
+ opacity: .76;
48
+ }
49
+ .hero .metadata {
50
+ margin-top: 8px;
51
+ font-size: .82rem;
52
+ opacity: .62;
53
+ letter-spacing: .015em;
54
+ }
55
  .research-q {
56
+ margin: 12px 0 16px;
57
  padding: 10px 14px;
58
+ border-left: 3px solid #708B86;
59
+ border-top: 1px solid var(--border-color-primary);
60
+ border-bottom: 1px solid var(--border-color-primary);
61
  background: var(--background-fill-secondary);
62
  }
63
+ .section-rule {
64
+ margin: 18px 0 10px;
65
+ padding-top: 8px;
66
+ border-top: 1px solid var(--border-color-primary);
67
+ font-variant: small-caps;
68
+ letter-spacing: .055em;
69
+ font-size: .88rem;
70
+ opacity: .72;
71
  }
72
+ .token-wrap {
73
+ display: flex;
74
+ flex-wrap: wrap;
75
+ gap: 5px;
76
+ padding: 7px 2px 13px;
77
+ line-height: 1.9;
 
78
  }
 
79
  .token {
80
+ background: var(--background-fill-secondary);
81
+ border: 1px solid var(--border-color-primary);
82
+ border-radius: 3px;
83
+ padding: 2px 7px;
84
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
85
+ font-size: 12px;
86
+ }
87
+ .token.selected {
88
+ border: 2px solid #708B86;
89
+ font-weight: 700;
90
+ }
91
+ .token sup { opacity: .55; margin-right: 4px; }
92
+ .small-note { opacity: .70; font-size: 12px; }
93
+ .instrument-note {
94
+ border: 1px solid var(--border-color-primary);
95
+ border-radius: 4px;
96
+ padding: 9px 11px;
97
+ background: var(--background-fill-secondary);
98
+ margin: 5px 0 10px;
99
+ }
100
+ .copy-row { margin-top: -5px; }
101
+ .copy-row button { min-height: 31px !important; }
102
+ .gradio-container button {
103
+ border-radius: 4px !important;
104
+ font-weight: 600 !important;
105
+ }
106
+ .gradio-container .form,
107
+ .gradio-container .block {
108
+ border-radius: 4px !important;
109
+ }
110
+ .gradio-container textarea,
111
+ .gradio-container input {
112
+ border-radius: 3px !important;
113
  }
114
+ .form-note {
115
+ margin-top: -3px;
116
+ margin-bottom: 8px;
117
+ font-size: .84rem;
118
+ opacity: .70;
 
 
 
119
  }
120
+ .classic-footer {
121
+ margin-top: 36px;
122
+ padding: 18px 2px 42px;
123
+ border-top: 1px solid var(--border-color-primary);
124
+ opacity: .62;
125
+ font-size: .82rem;
126
+ min-height: 92px;
127
+ }
128
+ /* Add breathing room below tabs in embedded Hugging Face iframes. */
129
+ .tabs, .tabitem { padding-bottom: 18px !important; }
130
  """
131
 
132
+ THEME = gr.themes.Base(
133
+ primary_hue="teal",
134
+ secondary_hue="stone",
135
+ neutral_hue="stone",
136
+ radius_size="sm",
137
+ font=("Times New Roman", "Times", "Liberation Serif", "serif"),
138
+ font_mono=("Liberation Mono", "Consolas", "monospace"),
139
+ )
140
+
141
+ COPY_JS = r"""
142
+ (text) => {
143
+ const value = text || "";
144
+ const fallback = () => {
145
+ const node = document.createElement("textarea");
146
+ node.value = value;
147
+ node.style.position = "fixed";
148
+ node.style.opacity = "0";
149
+ document.body.appendChild(node);
150
+ node.focus();
151
+ node.select();
152
+ document.execCommand("copy");
153
+ document.body.removeChild(node);
154
+ };
155
+ if (navigator.clipboard && navigator.clipboard.writeText) {
156
+ navigator.clipboard.writeText(value).catch(fallback);
157
+ } else {
158
+ fallback();
159
+ }
160
+ return [];
161
+ }
162
+ """
163
 
164
 
165
  def _raise_ui_error(exc: Exception) -> None:
166
  raise gr.Error(f"{type(exc).__name__}: {exc}") from exc
167
 
168
 
169
+ def _tsv(frame: pd.DataFrame) -> str:
170
+ if frame is None or frame.empty:
171
+ return ""
172
+ return frame.to_csv(sep="\t", index=False, lineterminator="\n")
173
+
174
+
175
+ def _copy_button(label: str = "Copy table with headers") -> gr.Button:
176
+ return gr.Button(label, size="sm", variant="secondary")
177
+
178
+
179
+ def _bind_copy(button: gr.Button, source: gr.Textbox) -> None:
180
+ button.click(fn=None, inputs=[source], outputs=None, js=COPY_JS, queue=False)
181
+
182
+
183
  def _analysis_metrics_markdown(result) -> str:
184
  return (
185
  "#### Analysis metrics\n"
 
192
 
193
 
194
  def _intervention_metrics_markdown(result) -> str:
195
+ drift = f"Execution-context null drift — JS: **{result.execution_drift_js:.2e}**"
196
+ if result.execution_drift_mean_logprob is not None:
197
+ drift += f" · mean log p/token: **{result.execution_drift_mean_logprob:+.2e}**"
198
+
199
  target = (
200
  "No target continuation supplied. The causal comparison therefore uses only "
201
  "next-token Jensen-Shannon divergence."
 
206
  f"**Target continuation:** {result.target_token_count} token(s): {tokens} \n"
207
  f"Sequence log p — baseline: **{result.baseline_sequence_logprob:.4f}** · "
208
  f"SAE edit: **{result.modified_sequence_logprob:.4f}** · "
209
+ f"random-ensemble mean: **{result.random_sequence_logprob:.4f}** \n"
210
  f"SAE Δ sequence log p: **{result.sequence_logprob_delta:+.4f}** · "
211
+ f"random signed mean Δ: **{result.random_sequence_logprob_delta:+.4f}** \n"
212
  f"SAE Δ mean log p/token: **{result.mean_logprob_delta:+.4f}** · "
213
+ f"random mean |Δ|: **{result.random_abs_mean_logprob_delta:.4f}** ± **{result.random_mean_logprob_std:.4f}** · "
214
+ f"magnitude ratio: **{result.target_specificity_ratio:.2f}×** · "
215
+ f"empirical tail p: **{result.target_empirical_p:.3f}**"
216
  )
217
  inactive = ""
218
  if abs(result.feature_activation) < 1e-12:
219
  inactive = (
220
+ " \n**Inactive feature:** ablate/scale has zero coefficient delta here; "
221
+ "use `inject` only when you intentionally want a decoder-direction steering test."
222
  )
223
  return (
224
  f"Original feature activation: **{result.feature_activation:.4f}** · "
225
  f"Δ coefficient: **{result.delta_activation:+.4f}** \n"
226
  f"Perturbation L2: **{result.perturbation_norm:.4f}** \n"
227
  f"Next-token JS: **{result.js_divergence:.6f}** · "
228
+ f"random ensemble ({result.random_control_count}) mean JS: **{result.random_js_divergence:.6f}** "
229
+ f"± **{result.random_js_std:.6f}** · ratio: **{result.js_specificity_ratio:.2f}×** · "
230
+ f"empirical tail p: **{result.js_empirical_p:.3f}** \n"
231
+ f"{drift} \n\n{target}{inactive} \n\n"
232
+ "_Greedy text may remain unchanged even when probability-level causal metrics move; "
233
+ "deterministic generation changes only after an argmax boundary is crossed._"
234
  )
235
 
236
 
 
243
  f"Feature activation at baseline: **{result.feature_activation:.4f}** · "
244
  f"target continuation: {len(result.target_tokens)} token(s): {tokens}.{inactive} \n\n"
245
  "This panel is **always a scale intervention**: 0× = ablation, 1× = no edit, "
246
+ "2× = double the native coefficient. All rows are compared with the **1× row from the same batched "
247
+ "execution context**, so 1× is the numerical null reference. \n"
248
+ f"Single-forward → batched-null execution drift: mean log p/token "
249
+ f"**{result.execution_drift_mean_logprob:+.2e}**, JS **{result.execution_drift_js:.2e}**."
250
  )
251
 
252
 
 
254
  tokens = " ".join(repr(token) for token in result.target_tokens)
255
  inactive_count = sum(abs(float(row[1])) < 1e-12 for row in result.feature_rows)
256
  inactive_note = (
257
+ f" \n{inactive_count} selected feature(s) were inactive and contributed zero delta."
 
258
  if inactive_count
259
  else ""
260
  )
 
263
  f"perturbation L2: **{result.perturbation_norm:.4f}** \n"
264
  f"Target continuation: {len(result.target_tokens)} token(s): {tokens} \n"
265
  f"SAE Δ mean log p/token: **{result.mean_logprob_delta:+.4f}** · "
266
+ f"random ensemble ({result.random_control_count}) mean |Δ|: "
267
+ f"**{result.random_abs_mean_logprob_delta:.4f}** ± **{result.random_mean_logprob_std:.4f}** · ratio: **{result.target_specificity_ratio:.2f}×** · "
268
+ f"empirical tail p: **{result.target_empirical_p:.3f}** \n"
269
  f"SAE Δ sequence log p: **{result.sequence_logprob_delta:+.4f}** · "
270
+ f"random signed mean Δ: **{result.random_sequence_logprob_delta:+.4f}** \n"
271
+ f"Next-token JS: **{result.js_divergence:.6f}** · random mean JS: "
272
+ f"**{result.random_js_divergence:.6f}** ± **{result.random_js_std:.6f}** · "
273
+ f"ratio: **{result.js_specificity_ratio:.2f}×** · empirical tail p: **{result.js_empirical_p:.3f}** \n"
274
+ f"Execution-context null drift: mean log p/token **{result.execution_drift_mean_logprob:+.2e}**, "
275
+ f"JS **{result.execution_drift_js:.2e}**{inactive_note}"
276
+ )
277
+
278
+
279
+ def _interaction_metrics_markdown(result) -> str:
280
+ tokens = " ".join(repr(token) for token in result.target_tokens)
281
+ return (
282
+ f"Target continuation: {len(result.target_tokens)} token(s): {tokens} \n"
283
+ f"Additive expectation (sum of individual Δ mean log p/token): "
284
+ f"**{result.additive_expected_mean_delta:+.4f}** \n"
285
+ f"Observed joint Δ mean log p/token: **{result.joint_mean_delta:+.4f}** \n"
286
+ f"Interaction excess (joint − additive): **{result.interaction_excess_mean_delta:+.4f}** · "
287
+ f"normalized: **{result.normalized_interaction:+.3f}** \n"
288
+ f"Execution-context null drift: **{result.execution_drift_mean_logprob:+.2e}** mean log p/token. \n\n"
289
+ "A non-zero excess indicates **non-additive downstream response under these edits**; it is not, by itself, "
290
+ "proof that the SAE features form a direct mechanistic circuit."
291
  )
292
 
293
 
294
  def _paraphrase_metrics_markdown(result) -> str:
295
  return (
296
  "#### Robustness metrics\n"
297
+ "**Selected-token comparison** \n"
298
+ f"TopK feature-set Jaccard: **{result.topk_jaccard:.3f}** · "
299
+ f"sparse activation cosine: **{result.sparse_cosine:.3f}** \n"
300
+ f"Shared features among displayed top-{result.top_n}: **{result.shared_top_n}/{result.top_n}** \n\n"
301
+ "**Prompt-wide comparison** max activation of each feature across all prompt tokens \n"
302
+ f"Feature-set Jaccard: **{result.promptwide_jaccard:.3f}** · "
303
+ f"activation cosine: **{result.promptwide_cosine:.3f}** \n\n"
304
+ "The prompt-wide view reduces sensitivity to choosing semantically different final tokens, while the "
305
+ "selected-token view remains the stricter local representation test. Neither metric establishes feature semantics."
306
+ )
307
+
308
+
309
+ def _concept_metrics_markdown(result) -> str:
310
+ ratio_text = "undefined" if result.leading_ratio == 0 else f"{result.leading_ratio:.2f}×"
311
+ return (
312
+ f"Feature **{result.feature_id}**, layer **{result.layer}** · "
313
+ f"{result.prompts_per_concept} controlled prompts/concept. \n"
314
+ f"Highest mean activation in this small live contrast: **{result.leading_concept}** "
315
+ f"({ratio_text} the runner-up mean). \n\n"
316
+ "This is an **exploratory contrast scan**, not an automatic semantic label. The offline held-out AUROC/F1 "
317
+ "benchmark remains the stronger feature/concept evidence."
318
  )
319
 
320
 
 
324
  if not prompt.strip():
325
  raise ValueError("Enter a prompt first.")
326
  result = RUNTIME.analyze(prompt, int(layer), int(token_index), int(top_n))
327
+ columns = ["Rank", "Feature id", "Activation", "Offline concept hint"]
328
+ table = pd.DataFrame(result.rows, columns=columns)
329
  choices = [str(int(row[1])) for row in result.rows]
330
  feature_update = gr.update(choices=choices, value=choices[0] if choices else None)
331
+ feature_set_update = gr.update(choices=choices, value=choices[: min(3, len(choices))])
332
+ contrast_update = gr.update(choices=choices, value=choices[0] if choices else None)
 
 
333
  chart_df = pd.DataFrame(
334
  {
335
  "Feature": [str(int(row[1])) for row in result.rows],
 
337
  "Series": ["Activation"] * len(result.rows),
338
  }
339
  )
340
+ location = (
341
+ f"Current Workbench location — **layer {int(layer)}**, **prompt token {result.token_index}**; "
342
+ f"prompt: `{prompt[:90]}{'…' if len(prompt) > 90 else ''}`"
343
+ )
344
  return (
345
  RUNTIME.token_html(result.tokens, result.token_index),
346
+ table,
347
  chart_df,
348
  feature_update,
349
  feature_set_update,
350
+ contrast_update,
351
+ gr.update(value=int(layer)),
352
  _analysis_metrics_markdown(result),
353
+ location,
354
+ location,
355
+ _tsv(table),
356
  )
357
  except Exception as exc:
358
  _raise_ui_error(exc)
 
374
  raise ValueError("Enter a prompt first.")
375
  if feature_id is None or str(feature_id).strip() == "":
376
  raise ValueError("Choose or enter a feature id.")
 
377
  result = RUNTIME.intervene(
378
  text=prompt,
379
  layer=int(layer),
380
  token_index=int(token_index),
381
+ feature_id=int(float(feature_id)),
382
  mode=mode,
383
  coefficient=float(coefficient),
384
  target_text=target_text,
385
  max_new_tokens=int(max_new_tokens),
386
  )
387
+ token_columns = ["Token", "Baseline p", "SAE-edit p", "Δ probability"]
388
+ target_columns = [
389
+ "Target position",
390
+ "Target token",
391
+ "Baseline log p",
392
+ "SAE-edit log p",
393
+ "Random-ensemble mean log p",
394
+ "SAE Δ log p",
395
+ "Random-ensemble mean Δ log p",
396
+ ]
397
+ token_df = pd.DataFrame(result.top_token_rows, columns=token_columns)
398
+ target_df = pd.DataFrame(result.target_token_rows, columns=target_columns)
399
  return (
400
  result.baseline_text,
401
  result.modified_text,
402
  _intervention_metrics_markdown(result),
403
+ token_df,
404
+ target_df,
405
+ _tsv(token_df),
406
+ _tsv(target_df),
407
  )
408
  except Exception as exc:
409
  _raise_ui_error(exc)
410
 
411
 
412
  @gpu(duration=35)
413
+ def run_dose_response(prompt: str, layer: int, token_index: int, feature_id: str, target_text: str):
 
 
 
 
 
 
414
  try:
415
  if not prompt.strip():
416
  raise ValueError("Enter a prompt first.")
 
429
  "Multiplier",
430
  "Δ feature coefficient",
431
  "Perturbation L2",
432
+ "Batched-null mean log p/token",
433
  "Modified mean log p/token",
434
  "Δ mean log p/token",
435
  "Δ sequence log p",
 
438
  table = pd.DataFrame(result.rows, columns=columns)
439
  plot = table[["Multiplier", "Δ mean log p/token"]].copy()
440
  plot["Series"] = "SAE feature"
441
+ return table, plot, _dose_metrics_markdown(result), _tsv(table)
442
  except Exception as exc:
443
  _raise_ui_error(exc)
444
 
 
465
  var_name="Metric",
466
  value_name="Value",
467
  )
468
+ return RUNTIME.token_html(result.tokens, result.token_index), table, long, _tsv(table)
469
  except Exception as exc:
470
  _raise_ui_error(exc)
471
 
472
 
473
+ @gpu(duration=40)
474
  def run_feature_set(
475
  prompt: str,
476
  layer: int,
 
482
  ):
483
  try:
484
  if not prompt.strip():
485
+ raise ValueError("Enter and inspect a prompt in the Workbench first.")
486
  selected = [int(float(value)) for value in (feature_ids or [])]
487
  if not selected:
488
  raise ValueError("Select at least one feature in 'Feature set'.")
 
497
  coefficient=float(coefficient),
498
  target_text=target_text,
499
  )
500
+ feature_columns = ["Feature id", "Original activation", "Δ coefficient", "Offline concept hint"]
501
+ target_columns = [
502
+ "Target position",
503
+ "Target token",
504
+ "Baseline log p",
505
+ "SAE-edit log p",
506
+ "Random-ensemble mean log p",
507
+ "SAE Δ log p",
508
+ "Random-ensemble mean Δ log p",
509
+ ]
510
+ feature_df = pd.DataFrame(result.feature_rows, columns=feature_columns)
511
+ target_df = pd.DataFrame(result.target_token_rows, columns=target_columns)
512
+ return (
513
+ feature_df,
514
+ _feature_set_metrics_markdown(result),
515
+ target_df,
516
+ _tsv(feature_df),
517
+ _tsv(target_df),
518
+ )
519
  except Exception as exc:
520
  _raise_ui_error(exc)
521
 
522
 
523
+ @gpu(duration=45)
524
+ def run_feature_set_sweep(prompt: str, layer: int, token_index: int, target_text: str):
 
 
 
 
 
525
  try:
526
  if not prompt.strip():
527
+ raise ValueError("Enter and inspect a prompt in the Workbench first.")
528
  if not target_text.strip():
529
  raise ValueError("Enter a target continuation before running the set-size sweep.")
530
  result = RUNTIME.feature_set_size_sweep(
 
537
  "Set size k",
538
  "Feature ids",
539
  "Perturbation L2",
540
+ "Batched-null mean log p/token",
541
  "SAE mean log p/token",
542
  "SAE Δ mean log p/token",
543
+ "Random signed mean Δ",
544
+ "Random mean |Δ|",
545
+ "Random |Δ| std",
546
+ "SAE/random magnitude ratio",
547
+ "Empirical tail p",
548
  "SAE Δ sequence log p",
549
  "SAE next-token JS",
550
+ "Random mean JS",
551
+ "Random JS std",
552
+ "JS empirical tail p",
553
  ]
554
  table = pd.DataFrame(result.rows, columns=columns)
555
+ plot_rows: list[list[object]] = []
556
  for _, row in table.iterrows():
557
  plot_rows.append([row["Set size k"], "Top-k SAE ablation", row["SAE Δ mean log p/token"]])
558
+ plot_rows.append([row["Set size k"], "Random signed mean", row["Random signed mean Δ"]])
559
  plot = pd.DataFrame(plot_rows, columns=["Set size k", "Condition", "Δ mean log p/token"])
560
  tokens = " ".join(repr(token) for token in result.target_tokens)
561
  note = (
562
+ f"Target continuation: {len(result.target_tokens)} token(s): {tokens}. For each k, FeatureLens "
563
+ f"ablates the k strongest active features and compares the effect with **{result.random_control_count} "
564
+ f"norm-matched random directions**. All conditions share one batched zero-edit reference. \n"
565
+ f"Execution-context null drift: mean log p/token **{result.execution_drift_mean_logprob:+.2e}**, "
566
+ f"JS **{result.execution_drift_js:.2e}**. The live empirical p-value is intentionally coarse because "
567
+ f"it uses only {result.random_control_count} controls; the offline experiment should use more."
568
  )
569
+ return table, plot, note, _tsv(table)
570
+ except Exception as exc:
571
+ _raise_ui_error(exc)
572
+
573
+
574
+ @gpu(duration=40)
575
+ def run_feature_interaction(
576
+ prompt: str,
577
+ layer: int,
578
+ token_index: int,
579
+ feature_ids: list[str] | None,
580
+ target_text: str,
581
+ ):
582
+ try:
583
+ selected = [int(float(value)) for value in (feature_ids or [])]
584
+ if not prompt.strip():
585
+ raise ValueError("Enter and inspect a prompt in the Workbench first.")
586
+ if len(selected) < 2:
587
+ raise ValueError("Select at least two features in 'Feature set'.")
588
+ if len(selected) > 5:
589
+ raise ValueError("Select at most five features for the interaction decomposition.")
590
+ if not target_text.strip():
591
+ raise ValueError("Enter a target continuation for the interaction decomposition.")
592
+ result = RUNTIME.feature_interaction_test(
593
+ text=prompt,
594
+ layer=int(layer),
595
+ token_index=int(token_index),
596
+ feature_ids=selected,
597
+ target_text=target_text,
598
+ )
599
+ columns = [
600
+ "Condition",
601
+ "Feature ids",
602
+ "Activation summary",
603
+ "Perturbation L2",
604
+ "Δ mean log p/token",
605
+ "Δ sequence log p",
606
+ "Next-token JS",
607
+ ]
608
+ table = pd.DataFrame(result.rows, columns=columns)
609
+ plot = table[["Condition", "Δ mean log p/token"]].copy()
610
+ plot["Series"] = "Ablation effect"
611
+ return table, _interaction_metrics_markdown(result), plot, _tsv(table)
612
  except Exception as exc:
613
  _raise_ui_error(exc)
614
 
 
631
  token_index_b=int(token_index_b),
632
  top_n=int(top_n),
633
  )
634
+ columns = ["Feature id", "Original activation", "Paraphrase activation", "Status", "Offline concept hint"]
635
+ table = pd.DataFrame(result.rows, columns=columns)
636
  chart = pd.DataFrame(result.chart_rows, columns=["Feature", "Prompt", "Activation"])
637
  return (
638
  RUNTIME.token_html(result.tokens_a, result.token_index_a),
639
  RUNTIME.token_html(result.tokens_b, result.token_index_b),
640
  _paraphrase_metrics_markdown(result),
641
+ table,
642
  chart,
643
+ _tsv(table),
644
+ )
645
+ except Exception as exc:
646
+ _raise_ui_error(exc)
647
+
648
+
649
+ @gpu(duration=35)
650
+ def run_concept_contrast(feature_id: str, layer: int, prompts_per_concept: int):
651
+ try:
652
+ if feature_id is None or str(feature_id).strip() == "":
653
+ raise ValueError("Choose a feature id first. Run Workbench inspection if the selector is empty.")
654
+ result = RUNTIME.concept_contrast_scan(
655
+ feature_id=int(float(feature_id)),
656
+ layer=int(layer),
657
+ prompts_per_concept=int(prompts_per_concept),
658
  )
659
+ columns = [
660
+ "Concept",
661
+ "Prompts",
662
+ "Mean activation",
663
+ "Median activation",
664
+ "Activation rate",
665
+ "Max activation",
666
+ ]
667
+ table = pd.DataFrame(result.rows, columns=columns)
668
+ chart = pd.DataFrame(result.chart_rows, columns=["Concept", "Mean activation"])
669
+ chart["Series"] = "Mean activation"
670
+ return _concept_metrics_markdown(result), table, chart, _tsv(table)
671
  except Exception as exc:
672
  _raise_ui_error(exc)
673
 
674
 
675
  def mode_help(mode: str):
676
  if mode == "ablate":
677
+ return gr.update(value=0.0, interactive=False, label="Coefficient (unused for ablation)")
 
 
 
 
 
678
  if mode == "scale":
679
+ return gr.update(value=2.0, interactive=True, label="Feature multiplier")
680
+ return gr.update(value=5.0, interactive=True, label="Additive feature coefficient")
 
 
 
 
 
 
 
 
 
 
681
 
682
 
683
  def set_mode_help(mode: str):
684
  if mode == "ablate":
685
+ return gr.update(value=0.0, interactive=False, label="Multiplier (unused for ablation)")
686
+ return gr.update(value=2.0, interactive=True, label="Shared feature multiplier")
 
 
 
 
 
 
 
 
 
 
687
 
688
 
689
  with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as demo:
690
  gr.HTML(
691
+ '<header class="hero">'
692
+ '<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.4</span></h1>'
693
+ '<div class="subtitle">Causal Interpretability Workbench</div>'
694
+ '<div class="metadata">Qwen3-1.7B-Base · Qwen-Scope residual SAEs · layers 4 / 14 / 26 · '
695
+ '32,768 features · TopK=50 · ZeroGPU</div>'
696
+ '</header>'
697
+ '<div class="research-q"><strong>Research question.</strong> Do sparse features that predict a concept '
698
+ 'also causally influence the model’s behaviour?</div>'
 
699
  )
700
 
701
  with gr.Tab("Workbench"):
702
+ gr.HTML('<div class="section-rule">I. Inspect a prompt location</div>')
703
  with gr.Row(equal_height=False):
704
  with gr.Column(scale=5):
705
  prompt = gr.Textbox(
 
723
  choices=list(SETTINGS.layers),
724
  value=SETTINGS.layers[1] if len(SETTINGS.layers) > 1 else SETTINGS.layers[0],
725
  label="Residual layer",
 
726
  )
727
  token_index = gr.Number(
728
  value=-1,
729
  precision=0,
730
  label="Prompt token index",
731
+ info="-1 = final prompt token.",
732
  )
733
  top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
734
  analyze_btn = gr.Button("Inspect sparse features", variant="primary")
735
 
736
  gr.Markdown("#### Prompt tokens\nThe selected prompt token is outlined more strongly.")
737
+ token_view = gr.HTML(
738
+ '<div class="small-note">Prompt tokens appear here after clicking <b>Inspect sparse features</b>.</div>'
739
+ )
740
  analysis_metrics = gr.Markdown()
741
  with gr.Row(equal_height=False):
742
+ with gr.Column(scale=3):
743
+ feature_table = gr.Dataframe(
744
+ headers=["Rank", "Feature id", "Activation", "Offline concept hint"],
745
+ datatype=["number", "number", "number", "str"],
746
+ interactive=False,
747
+ label="Strongest active SAE features",
748
+ wrap=True,
749
+ buttons=["fullscreen"],
750
+ )
751
+ feature_tsv = gr.Textbox(visible="hidden")
752
+ feature_copy = _copy_button()
753
+ with gr.Column(scale=2):
754
+ feature_plot = gr.BarPlot(
755
+ x="Feature",
756
+ y="Activation",
757
+ color="Series",
758
+ color_map={"Activation": INK_TEAL},
759
+ title="Activation profile",
760
+ x_title="Feature id",
761
+ y_title="Activation",
762
+ x_label_angle=-35,
763
+ height=330,
764
+ )
765
 
766
+ gr.HTML('<div class="section-rule">II. Single-feature causal test</div>')
767
  gr.Markdown(
768
+ "The edit is compared with an **8-direction norm-matched random ensemble** in the same batched "
769
+ "execution context. If a target continuation is supplied, the complete continuation is scored teacher-forced."
 
770
  )
771
  with gr.Row(equal_height=False):
772
  with gr.Column(scale=2):
 
774
  choices=[],
775
  allow_custom_value=True,
776
  label="Single feature id",
 
777
  )
778
+ mode = gr.Dropdown(
779
  choices=["ablate", "scale", "inject"],
780
  value="ablate",
781
  label="Single-feature intervention",
 
788
  target_text = gr.Textbox(
789
  label="Target continuation (optional)",
790
  placeholder="e.g. 2x",
791
+ info="Exact text appended to the prompt for teacher-forced scoring.",
 
 
 
792
  )
793
  max_new = gr.Slider(
794
  4,
 
804
  baseline_out = gr.Textbox(label="Baseline greedy generation", lines=6, interactive=False)
805
  modified_out = gr.Textbox(label="SAE-edited greedy generation", lines=6, interactive=False)
806
  token_prob_table = gr.Dataframe(
 
 
807
  interactive=False,
808
  label="Next-token distribution shift",
809
+ buttons=["fullscreen"],
810
+ wrap=True,
811
  )
812
+ token_prob_tsv = gr.Textbox(visible="hidden")
813
+ token_prob_copy = _copy_button()
814
  target_token_table = gr.Dataframe(
 
 
 
 
 
 
 
 
 
 
815
  interactive=False,
816
  label="Target continuation token-by-token score",
817
+ buttons=["fullscreen"],
818
+ wrap=True,
819
  )
820
+ target_token_tsv = gr.Textbox(visible="hidden")
821
+ target_token_copy = _copy_button()
822
 
823
+ gr.HTML('<div class="section-rule">III. Single-feature scale dose-response</div>')
824
+ with gr.Accordion("Scale dose-response", open=False):
825
  gr.Markdown(
826
+ "Always a **scale** experiment: 0× = ablation, = numerical no-edit reference, = double "
827
+ "the native coefficient. The six conditions are evaluated together, and all deltas are measured "
828
+ "against the 1× row from that same batch."
829
  )
830
  dose_btn = gr.Button("Run scale dose-response")
831
  dose_metrics = gr.Markdown()
832
+ with gr.Row(equal_height=False):
833
+ with gr.Column(scale=3):
834
+ dose_table = gr.Dataframe(
835
+ interactive=False,
836
+ label="Scale dose-response measurements",
837
+ buttons=["fullscreen"],
838
+ wrap=True,
839
+ )
840
+ dose_tsv = gr.Textbox(visible="hidden")
841
+ dose_copy = _copy_button()
842
+ with gr.Column(scale=2):
843
+ dose_plot = gr.LinePlot(
844
+ x="Multiplier",
845
+ y="Δ mean log p/token",
846
+ color="Series",
847
+ color_map={"SAE feature": INK_TEAL},
848
+ title="Scale dose-response",
849
+ x_title="Feature multiplier",
850
+ y_title="Δ mean log p/token",
851
+ height=330,
852
+ )
853
 
854
  with gr.Tab("Feature sets"):
855
  gr.Markdown(
856
+ "### Distributed sparse representations\n"
857
+ "Run **Inspect sparse features** in the Workbench first. The prompt, layer, token index, and active "
858
+ "feature selector below reuse that Workbench state."
859
  )
860
+ feature_set_location = gr.Markdown("No Workbench inspection has been run yet.")
861
  feature_set_ids = gr.Dropdown(
862
  choices=[],
863
  value=[],
 
865
  allow_custom_value=True,
866
  max_choices=12,
867
  label="Feature set",
 
868
  )
869
+
870
+ gr.Markdown("#### Joint feature-set causal test")
871
+ gr.HTML(
872
+ '<div class="instrument-note">Ablation ignores the multiplier. Scale applies one shared multiplier '
873
+ 'to every selected feature before decoder deltas are summed. Additive injection is deliberately omitted '
874
+ 'because one coefficient is not comparable across unrelated directions.</div>'
875
+ )
876
+ with gr.Row(equal_height=True):
877
+ set_mode = gr.Dropdown(
878
  choices=["ablate", "scale"],
879
  value="ablate",
880
+ label="Intervention",
881
+ scale=1,
882
  )
883
  set_coefficient = gr.Number(
884
  value=0.0,
885
  interactive=False,
886
+ label="Multiplier (unused for ablation)",
887
+ scale=1,
888
  )
889
  set_target = gr.Textbox(
890
  label="Target continuation",
891
  value="2x",
892
+ lines=1,
893
+ scale=2,
894
  )
895
  set_btn = gr.Button("Run joint feature-set causal test", variant="primary")
896
  set_metrics = gr.Markdown()
897
+ with gr.Row(equal_height=False):
898
+ with gr.Column(scale=2):
899
+ set_feature_table = gr.Dataframe(
900
+ interactive=False,
901
+ label="Joint intervention features",
902
+ buttons=["fullscreen"],
903
+ wrap=True,
904
+ )
905
+ set_feature_tsv = gr.Textbox(visible="hidden")
906
+ set_feature_copy = _copy_button()
907
+ with gr.Column(scale=3):
908
+ set_target_table = gr.Dataframe(
909
+ interactive=False,
910
+ label="Target continuation token-by-token score",
911
+ buttons=["fullscreen"],
912
+ wrap=True,
913
+ )
914
+ set_target_tsv = gr.Textbox(visible="hidden")
915
+ set_target_copy = _copy_button()
 
 
 
 
916
 
917
+ gr.HTML('<div class="section-rule">Set-size sensitivity</div>')
918
  gr.Markdown(
919
+ "Ablates the **1, 3, and 5 strongest active features**. Each targeted edit is compared with eight "
920
+ "norm-matched random directions, all sharing one batched no-edit baseline."
 
 
 
 
 
921
  )
922
+ set_sweep_target = gr.Textbox(label="Target continuation for set-size sweep", value="2x")
923
  set_sweep_btn = gr.Button("Run 1/3/5-feature ablation sweep")
924
  set_sweep_note = gr.Markdown()
925
+ with gr.Row(equal_height=False):
926
+ with gr.Column(scale=3):
927
+ set_sweep_table = gr.Dataframe(
928
+ interactive=False,
929
+ label="Feature-set size measurements",
930
+ buttons=["fullscreen"],
931
+ wrap=True,
932
+ )
933
+ set_sweep_tsv = gr.Textbox(visible="hidden")
934
+ set_sweep_copy = _copy_button()
935
+ with gr.Column(scale=2):
936
+ set_sweep_plot = gr.LinePlot(
937
+ x="Set size k",
938
+ y="Δ mean log p/token",
939
+ color="Condition",
940
+ color_map={
941
+ "Top-k SAE ablation": INK_TEAL,
942
+ "Random signed mean": INK_STONE,
943
+ },
944
+ title="Effect vs feature-set size",
945
+ x_title="Number of jointly ablated features",
946
+ y_title="Δ mean log p/token",
947
+ height=330,
948
+ )
949
+
950
+ gr.HTML('<div class="section-rule">Non-additivity / interaction decomposition</div>')
951
+ gr.Markdown(
952
+ "For the selected 2–5 features, FeatureLens batches each **individual ablation** plus the **joint "
953
+ "ablation**. It compares the observed joint effect with the sum of individual effects. This diagnoses "
954
+ "non-additivity without claiming that the features form a direct circuit."
955
+ )
956
+ interaction_target = gr.Textbox(label="Target continuation for interaction test", value="2x")
957
+ interaction_btn = gr.Button("Run individual-vs-joint decomposition")
958
+ interaction_metrics = gr.Markdown()
959
+ with gr.Row(equal_height=False):
960
+ with gr.Column(scale=3):
961
+ interaction_table = gr.Dataframe(
962
+ interactive=False,
963
+ label="Individual and joint ablation measurements",
964
+ buttons=["fullscreen"],
965
+ wrap=True,
966
+ )
967
+ interaction_tsv = gr.Textbox(visible="hidden")
968
+ interaction_copy = _copy_button()
969
+ with gr.Column(scale=2):
970
+ interaction_plot = gr.BarPlot(
971
+ x="Condition",
972
+ y="Δ mean log p/token",
973
+ color="Series",
974
+ color_map={"Ablation effect": INK_UMBER},
975
+ title="Individual vs joint effect",
976
+ x_title="Intervention condition",
977
+ y_title="Δ mean log p/token",
978
+ x_label_angle=-25,
979
+ height=330,
980
+ )
981
+
982
+ with gr.Tab("Feature evidence"):
983
+ gr.Markdown(
984
+ "### Controlled concept contrast scan\n"
985
+ "This lightweight live test asks whether the selected SAE feature activates preferentially on one of "
986
+ "FeatureLens's seven controlled concept groups. It uses one wording per paraphrase pair and evaluates "
987
+ "the **final prompt token** for every contrast prompt in one batch."
988
+ )
989
+ contrast_location = gr.Markdown("No Workbench inspection has been run yet.")
990
+ with gr.Row(equal_height=True):
991
+ contrast_feature_id = gr.Dropdown(
992
+ choices=[],
993
+ allow_custom_value=True,
994
+ label="Feature id",
995
+ scale=2,
996
+ )
997
+ contrast_layer = gr.Dropdown(
998
+ choices=list(SETTINGS.layers),
999
+ value=SETTINGS.layers[1],
1000
+ label="Residual layer",
1001
+ scale=1,
1002
+ )
1003
+ contrast_n = gr.Slider(
1004
+ 2,
1005
+ 6,
1006
+ value=SETTINGS.contrast_prompts_per_concept,
1007
+ step=1,
1008
+ label="Prompts per concept",
1009
  scale=2,
1010
  )
1011
+ contrast_btn = gr.Button("Run controlled concept contrast", variant="primary")
1012
+ contrast_metrics = gr.Markdown()
1013
+ with gr.Row(equal_height=False):
1014
+ with gr.Column(scale=3):
1015
+ contrast_table = gr.Dataframe(
1016
+ interactive=False,
1017
+ label="Feature activation by controlled concept",
1018
+ buttons=["fullscreen"],
1019
+ wrap=True,
1020
+ )
1021
+ contrast_tsv = gr.Textbox(visible="hidden")
1022
+ contrast_copy = _copy_button()
1023
+ with gr.Column(scale=2):
1024
+ contrast_plot = gr.BarPlot(
1025
+ x="Concept",
1026
+ y="Mean activation",
1027
+ color="Series",
1028
+ color_map={"Mean activation": INK_BLUEGREY},
1029
+ title="Controlled concept contrast",
1030
+ x_title="Concept",
1031
+ y_title="Mean feature activation",
1032
+ x_label_angle=-25,
1033
+ height=330,
1034
+ )
1035
 
1036
  with gr.Tab("Paraphrase robustness"):
1037
  gr.Markdown(
1038
+ "### Local and prompt-wide robustness\n"
1039
+ "The selected-token comparison is strict but can be misleading if the two chosen tokens play different "
1040
+ "semantic roles. v0.4 therefore also reports a **prompt-wide max-pooled feature profile**: for every SAE "
1041
+ "feature, take its maximum activation across all prompt tokens, then compare the two sparse profiles."
1042
  )
1043
  with gr.Row():
1044
  para_a = gr.Textbox(
 
1054
  with gr.Row():
1055
  para_layer = gr.Dropdown(
1056
  choices=list(SETTINGS.layers),
1057
+ value=SETTINGS.layers[1],
1058
  label="Residual layer",
1059
  )
1060
  para_idx_a = gr.Number(value=-1, precision=0, label="Original prompt token index")
 
1069
  gr.Markdown("#### Paraphrase tokens")
1070
  para_tokens_b = gr.HTML()
1071
  para_metrics = gr.Markdown()
1072
+ with gr.Row(equal_height=False):
1073
+ with gr.Column(scale=3):
1074
+ para_table = gr.Dataframe(
1075
+ interactive=False,
1076
+ label="Top-feature overlap at selected tokens",
1077
+ buttons=["fullscreen"],
1078
+ wrap=True,
1079
+ )
1080
+ para_tsv = gr.Textbox(visible="hidden")
1081
+ para_copy = _copy_button()
1082
+ with gr.Column(scale=2):
1083
+ para_plot = gr.BarPlot(
1084
+ x="Feature",
1085
+ y="Activation",
1086
+ color="Prompt",
1087
+ color_map={"Original": INK_TEAL, "Paraphrase": INK_PLUM},
1088
+ title="Selected-token feature activations",
1089
+ x_title="Feature id",
1090
+ y_title="Activation",
1091
+ x_label_angle=-35,
1092
+ height=330,
1093
+ )
1094
 
1095
  with gr.Tab("Layer trajectory"):
1096
  gr.Markdown(
1097
+ "### Representation structure across early, middle and late residual streams\n"
1098
+ "This is not a cross-layer feature-ID comparison; the SAE dictionaries are layer-specific."
 
 
1099
  )
1100
  with gr.Row():
1101
  trajectory_prompt = gr.Textbox(
 
1114
  trajectory_btn = gr.Button("Compare layers", variant="primary")
1115
  gr.Markdown("#### Prompt tokens")
1116
  trajectory_tokens = gr.HTML()
1117
+ with gr.Row(equal_height=False):
1118
+ with gr.Column(scale=3):
1119
+ trajectory_table = gr.Dataframe(
1120
+ interactive=False,
1121
+ label="Layer diagnostics",
1122
+ buttons=["fullscreen"],
1123
+ wrap=True,
1124
+ )
1125
+ trajectory_tsv = gr.Textbox(visible="hidden")
1126
+ trajectory_copy = _copy_button()
1127
+ with gr.Column(scale=2):
1128
+ trajectory_plot = gr.LinePlot(
1129
+ x="Layer",
1130
+ y="Value",
1131
+ color="Metric",
1132
+ color_map={
1133
+ "Reconstruction cosine": INK_TEAL,
1134
+ "Top-5 mass": INK_UMBER,
1135
+ "Activation entropy": INK_RED,
1136
+ },
1137
+ title="Representation trajectory",
1138
+ x_title="Layer",
1139
+ y_title="Normalized value",
1140
+ height=330,
1141
+ )
1142
 
1143
  with gr.Tab("Offline benchmark"):
1144
  gr.Markdown(RUNTIME.catalog.benchmark_markdown())
1145
  gr.Markdown(
1146
+ "The offline pipeline remains the source of held-out feature/concept AUROC + F1, reconstruction "
1147
+ "quality, paraphrase stability, dense residual linear probes, and causal intervention conclusions. "
1148
+ "v0.4 strengthens the live workbench with batch-context null references and random-control ensembles; "
1149
+ "the saved report should be generated only from real experiment artifacts."
 
1150
  )
1151
 
1152
  with gr.Tab("Method"):
 
1157
  For residual vector $h$, sparse coefficient $z_i$, decoder direction $d_i$, and scale $\alpha$:
1158
 
1159
  - **Ablate:** $h' = h - z_i d_i$
1160
+ - **Scale:** $h' = h + (\alpha - 1)z_i d_i$
1161
  - **Inject:** $h' = h + \delta d_i$
1162
 
1163
+ For a feature set $S$:
1164
 
1165
+ $$h' = h + \sum_{i \in S}\Delta z_i d_i.$$
1166
 
1167
+ FeatureLens patches the delta into the **original residual**; it never replaces the residual with the complete SAE reconstruction.
 
1168
 
1169
+ ### v0.4 control discipline
1170
 
1171
+ Batched experiments include an explicit **zero-edit row**. Causal effects are measured against that row rather than a separately executed baseline, which removes batch-vs-single floating-point drift from the measured effect. Random specificity uses an ensemble of norm-matched residual directions rather than one arbitrary seed.
 
 
1172
 
1173
  ### Evidence ladder
1174
 
1175
+ 1. SAE reconstruction quality.
1176
+ 2. Held-out feature/concept prediction.
1177
+ 3. Local and prompt-wide paraphrase robustness.
1178
+ 4. Controlled live concept contrast.
1179
+ 5. Single-feature causal intervention and dose-response.
1180
+ 6. Joint feature-set intervention and set-size sensitivity.
1181
+ 7. Individual-vs-joint non-additivity.
1182
+ 8. Specificity relative to norm-matched random controls.
1183
 
1184
+ Association, robustness, and intervention evidence remain separate claims.
1185
  """
1186
  )
1187
 
1188
+ gr.HTML(
1189
+ '<footer class="classic-footer">Built with PyTorch, Transformers, Qwen3-1.7B-Base and Qwen-Scope '
1190
+ 'residual-stream SAEs. FeatureLens is independent of thesis code and thesis datasets.<br><br>'
1191
+ 'End of workbench.</footer>'
1192
  )
1193
 
1194
+ # Event wiring.
1195
  analyze_btn.click(
1196
  analyze_prompt,
1197
  inputs=[prompt, layer, token_index, top_n],
 
1201
  feature_plot,
1202
  feature_id,
1203
  feature_set_ids,
1204
+ contrast_feature_id,
1205
+ contrast_layer,
1206
  analysis_metrics,
1207
+ feature_set_location,
1208
+ contrast_location,
1209
+ feature_tsv,
1210
  ],
1211
  )
1212
  mode.change(mode_help, inputs=[mode], outputs=[coefficient])
 
1219
  intervention_metrics,
1220
  token_prob_table,
1221
  target_token_table,
1222
+ token_prob_tsv,
1223
+ target_token_tsv,
1224
  ],
1225
  )
1226
  dose_btn.click(
1227
  run_dose_response,
1228
  inputs=[prompt, layer, token_index, feature_id, target_text],
1229
+ outputs=[dose_table, dose_plot, dose_metrics, dose_tsv],
1230
  )
1231
  set_mode.change(set_mode_help, inputs=[set_mode], outputs=[set_coefficient])
1232
  set_btn.click(
1233
  run_feature_set,
1234
  inputs=[prompt, layer, token_index, feature_set_ids, set_mode, set_coefficient, set_target],
1235
+ outputs=[set_feature_table, set_metrics, set_target_table, set_feature_tsv, set_target_tsv],
1236
  )
1237
  set_sweep_btn.click(
1238
  run_feature_set_sweep,
1239
  inputs=[prompt, layer, token_index, set_sweep_target],
1240
+ outputs=[set_sweep_table, set_sweep_plot, set_sweep_note, set_sweep_tsv],
1241
+ )
1242
+ interaction_btn.click(
1243
+ run_feature_interaction,
1244
+ inputs=[prompt, layer, token_index, feature_set_ids, interaction_target],
1245
+ outputs=[interaction_table, interaction_metrics, interaction_plot, interaction_tsv],
1246
+ )
1247
+ contrast_btn.click(
1248
+ run_concept_contrast,
1249
+ inputs=[contrast_feature_id, contrast_layer, contrast_n],
1250
+ outputs=[contrast_metrics, contrast_table, contrast_plot, contrast_tsv],
1251
  )
1252
  para_btn.click(
1253
  run_paraphrase_compare,
1254
  inputs=[para_a, para_b, para_layer, para_idx_a, para_idx_b, para_top_n],
1255
+ outputs=[para_tokens_a, para_tokens_b, para_metrics, para_table, para_plot, para_tsv],
1256
  )
1257
  trajectory_btn.click(
1258
  run_layer_sweep,
1259
  inputs=[trajectory_prompt, trajectory_token],
1260
+ outputs=[trajectory_tokens, trajectory_table, trajectory_plot, trajectory_tsv],
1261
  )
1262
 
1263
+ for button, source in [
1264
+ (feature_copy, feature_tsv),
1265
+ (token_prob_copy, token_prob_tsv),
1266
+ (target_token_copy, target_token_tsv),
1267
+ (dose_copy, dose_tsv),
1268
+ (set_feature_copy, set_feature_tsv),
1269
+ (set_target_copy, set_target_tsv),
1270
+ (set_sweep_copy, set_sweep_tsv),
1271
+ (interaction_copy, interaction_tsv),
1272
+ (contrast_copy, contrast_tsv),
1273
+ (para_copy, para_tsv),
1274
+ (trajectory_copy, trajectory_tsv),
1275
+ ]:
1276
+ _bind_copy(button, source)
1277
+
1278
+
1279
  if __name__ == "__main__":
 
1280
  demo.queue(default_concurrency_limit=1, max_size=8).launch(
1281
  css=CSS,
1282
  theme=THEME,
docs/HF_DEPLOY.md CHANGED
@@ -9,46 +9,80 @@ On Hugging Face, `FEATURELENS_EAGER_LOAD` defaults to `1`. The runtime loads:
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.
 
9
  - `Qwen/Qwen3-1.7B-Base`;
10
  - Qwen-Scope SAE layers **4, 14, 26** only.
11
 
12
+ The live app does not need every SAE layer from the full repository.
13
 
14
+ `app.py` launches with `ssr_mode=False`, matching the deployment path that removed the earlier SSR/auth coroutine warning during Space testing.
15
 
16
  ## GPU-decorated actions
17
 
18
+ Current v0.4 live actions include:
19
 
20
+ - **Inspect sparse features**;
21
+ - **Run single-feature causal test**;
22
+ - **Run scale dose-response**;
23
+ - **Run joint feature-set causal test**;
24
+ - **Run 1/3/5-feature ablation sweep**;
25
+ - **Run individual-vs-joint decomposition**;
26
+ - **Run controlled concept contrast**;
27
+ - **Compare paraphrase representations**;
28
+ - **Compare layers**.
29
 
30
+ Allocation durations in `app.py` are ceilings requested from ZeroGPU, not expected wall-clock runtimes.
31
 
32
+ ## Batch-first causal execution
33
 
34
+ v0.4 deliberately batches related conditions so stronger diagnostics do not require a separate GPU callback for every condition.
35
 
36
+ Examples:
 
 
37
 
38
+ - scale dose-response stacks the six multipliers in one edited batch;
39
+ - single-feature causal tests stack zero edit, one targeted SAE edit, and eight norm-matched random controls;
40
+ - 1/3/5 feature-set sensitivity batches targeted edits and control ensembles;
41
+ - individual-vs-joint decomposition batches all individual ablations plus the joint ablation;
42
+ - controlled concept contrast evaluates its balanced prompt batch together.
43
 
44
+ The primary causal reference inside each experiment is a **batched zero-edit row**. This prevents batch-vs-single floating-point drift from being mistaken for an intervention effect.
45
 
46
+ ## Greedy generation vs probability-level scoring
47
 
48
+ The single-feature causal test retains baseline and edited greedy generation because the visible text comparison is useful in a public demo.
49
+
50
+ The primary targeted causal metric uses teacher-forced **full-continuation** log-probability scoring. Greedy text may remain unchanged while probability-level metrics move.
51
+
52
+ The heavier feature-set, set-size, interaction, and contrast panels avoid unnecessary free-running generations.
53
+
54
+ ## Clipboard export
55
+
56
+ Major output tables include a dedicated **Copy table with headers** action. The app serializes the result as tab-separated text before invoking the browser clipboard API. This makes pasted output self-describing and spreadsheet-friendly.
57
+
58
+ If browser clipboard permission is unavailable, the frontend uses a temporary-textarea fallback.
59
+
60
+ ## Embedded-Space layout
61
+
62
+ The app uses:
63
+
64
+ - restrained serif typography;
65
+ - small corner radii rather than pill-heavy controls;
66
+ - explicit bottom padding on the Gradio container and tab contents;
67
+ - a visible footer with `End of workbench.`.
68
+
69
+ These changes prevent app-owned content from ending abruptly. The outer Hugging Face embedding frame may still resize asynchronously; that behavior is outside the Python layout itself.
70
 
71
  ## Offline benchmark
72
 
73
+ Do **not** run the complete research benchmark as an interactive public-Space action.
74
+
75
+ Run on separate CUDA compute:
76
+
77
+ ```bash
78
+ python3 experiments/run_all.py
79
+ ```
80
+
81
+ Both causal runners accept a configurable random-control count. For more stable offline control estimates, increase the value if compute permits, for example:
82
 
83
  ```bash
84
+ python3 -m experiments.run_causal --random-controls 16
85
+ python3 -m experiments.run_feature_sets --random-controls 16
86
  ```
87
 
88
+ Then commit only the small report/catalog/CSV/figure artifacts intended for presentation. Large activation arrays remain gitignored.
docs/METHODOLOGY.md CHANGED
@@ -1,37 +1,83 @@
1
- # Methodology
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
@@ -39,63 +85,212 @@ 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.
 
1
+ # FeatureLens methodology
2
 
3
+ ## Primary question
4
 
5
+ FeatureLens separates **representation** from **causal influence**.
6
+
7
+ A sparse feature can predict a concept because it correlates with useful information in the residual stream. That does not imply that changing that feature direction will specifically change model behaviour. The project therefore evaluates four distinct properties:
8
+
9
+ 1. reconstruction quality;
10
+ 2. predictive association;
11
+ 3. robustness across paraphrases;
12
+ 4. causal sensitivity under controlled residual interventions.
13
+
14
+ ---
15
+
16
+ ## Model and sparse dictionaries
17
+
18
+ The live workbench uses `Qwen/Qwen3-1.7B-Base` and Qwen-Scope residual-stream SAEs at layers `4`, `14`, and `26`.
19
+
20
+ Each selected residual location is encoded with the corresponding layer-specific SAE. Feature IDs are meaningful only **within one SAE dictionary**; numerical IDs must not be compared as if they were shared semantics across layers.
21
+
22
+ ---
23
 
24
  ## Discovery data
25
 
26
+ The controlled discovery benchmark contains seven concept groups:
27
 
28
+ - code;
29
+ - mathematics;
30
+ - positive sentiment;
31
+ - negative sentiment;
32
+ - French language;
33
+ - factual entities;
34
+ - uncertainty.
35
 
36
+ Each concept contains 16 paraphrase pairs, two prompts per pair, for 224 prompts total.
37
 
38
+ Train/test splitting is grouped by `pair_id`, so the two paraphrases from the same source pair cannot leak across train and held-out test.
39
 
40
+ The offline discovery benchmark uses the final prompt-token residual. The live workbench remains token-selectable.
41
 
42
+ ---
43
 
44
+ ## Sparse feature discovery
45
 
46
+ For each configured layer, FeatureLens records the TopK SAE code at the chosen prompt location.
47
 
48
+ Candidate features are selected **using the training split only**. Training AUROC is the primary selection score, with activation-rate contrast used as a tie-break. Held-out AUROC and F1 are computed only after feature selection.
49
 
50
+ This prevents the project from selecting whichever SAE feature happened to look best on held-out data.
51
+
52
+ ---
53
+
54
+ ## Dense linear-probe baseline
55
+
56
+ A multinomial logistic-regression probe is fit to dense residual vectors from the same layers.
57
+
58
+ The comparison asks whether concept information is linearly available in the dense representation even when no individual sparse feature cleanly isolates it.
59
 
60
+ A result such as strong linear-probe performance but weak single-feature AUROC would support a more distributed representation rather than a clean monosemantic feature story.
61
 
62
+ ---
63
 
64
+ ## Reconstruction diagnostics
65
+
66
+ For residual `h` and SAE reconstruction `h_hat`, FeatureLens records:
67
+
68
+ - reconstruction cosine;
69
+ - normalized mean squared error;
70
+ - active feature count;
71
+ - Top-5 activation mass;
72
+ - normalized sparse activation entropy in the live layer trajectory.
73
+
74
+ These are diagnostics, not causal evidence. Reconstruction quality can also vary substantially by layer.
75
+
76
+ ---
77
+
78
+ ## Reconstruction-preserving residual interventions
79
+
80
+ For selected SAE activation `z_i`, decoder direction `d_i`, and scale multiplier `α`:
81
 
82
  ```text
83
  ablate: h' = h - z_i d_i
 
85
  inject: h' = h + δ d_i
86
  ```
87
 
88
+ Only the decoded **feature delta** is added to the original residual stream. FeatureLens does **not** replace the residual with the complete SAE reconstruction.
89
 
90
+ This avoids conflating the intervention with full-reconstruction error.
91
 
92
+ `inject` is intentionally interpreted differently from ablation/scaling: it tests an externally supplied decoder-direction steering coefficient even if that feature was not naturally active.
93
 
94
+ ---
95
 
96
+ ## Full-continuation teacher-forced scoring
97
+
98
+ A target such as `2x` may consist of multiple tokenizer tokens. FeatureLens therefore scores the complete target continuation, not only its first token.
99
+
100
+ The exact target token IDs are appended to the prompt. Teacher-forced model logits provide a log probability for every target token.
101
+
102
+ The live and offline causal metrics include:
103
+
104
+ - per-target-token log probability;
105
  - total target sequence log probability;
106
+ - mean target log probability per token;
107
+ - intervention-minus-reference deltas.
 
 
108
 
109
+ The primary aggregate causal metric is **Δ mean target log probability per token**, because it is less directly dependent on target length than total sequence log probability.
110
 
111
+ Next-token Jensen-Shannon divergence and greedy generation remain complementary diagnostics. Identical greedy outputs do not imply an intervention had zero probability-level effect.
112
 
113
+ ---
114
 
115
+ ## Batched zero-edit causal reference
116
 
117
+ v0.3 exposed an important numerical issue: batched and separately executed model forwards can differ slightly even when the intended residual edit is zero. A nominal `1×` dose-response condition therefore showed a small non-zero effect when compared with a separately executed baseline.
118
 
119
+ v0.4 treats this as instrumentation drift, not causal evidence.
120
 
121
+ Every batched causal experiment now includes an explicit zero-edit condition:
122
+
123
+ ```text
124
+ Δh = 0
125
+ ```
126
+
127
+ All intervention deltas in that batch are measured against this **same-execution-context reference**.
128
+
129
+ For the scale dose-response, the `1×` row is the zero-edit reference itself, so its causal deltas are exactly zero by construction.
130
+
131
+ When a separately executed capture forward is also needed, FeatureLens reports the single-vs-batch discrepancy as **execution-context null drift**. That diagnostic is kept separate from causal effect size.
132
+
133
+ ---
134
+
135
+ ## Norm-matched random-control ensemble
136
+
137
+ A single random residual direction is a fragile negative control: by chance it can be unusually weak or unusually disruptive.
138
+
139
+ For a targeted perturbation `Δh_sae`, FeatureLens generates deterministic random directions `r_j` and rescales each one so that:
140
+
141
+ ```text
142
+ ||r_j||_2 = ||Δh_sae||_2
143
+ ```
144
+
145
+ The live app uses **8 random directions** for each targeted perturbation. Targeted and control conditions are evaluated in the same batched execution context.
146
+
147
+ The live app reports:
148
+
149
+ - signed random mean effect;
150
+ - mean absolute random effect;
151
+ - random-effect standard deviation;
152
+ - `|targeted effect| / mean(|random effect|)`;
153
+ - an empirical two-sided magnitude tail quantity:
154
+
155
+ ```text
156
+ (1 + count(|random_j| >= |targeted|)) / (N + 1)
157
+ ```
158
+
159
+ With only eight live controls this empirical value is intentionally coarse: the smallest possible value is `1/9`. It is an exploratory specificity diagnostic, **not** a conventional significance test.
160
+
161
+ Offline runs can increase `--random-controls` when compute allows.
162
+
163
+ ---
164
+
165
+ ## Single-feature scale dose-response
166
+
167
+ The live dose-response experiment always evaluates:
168
 
169
  ```text
170
  0×, 0.5×, 1×, 1.5×, 2×, 3×
171
  ```
172
 
173
+ where:
174
+
175
+ - `0×` = complete ablation;
176
+ - `1×` = zero edit;
177
+ - `2×` = double the native coefficient.
178
 
179
+ All six edited residual conditions are stacked along the batch dimension and scored together. The `1×` batch row is the reference for every reported delta.
180
 
181
+ A monotonic relationship would strengthen a simple directional causal interpretation, but monotonicity is not required. Non-monotonic responses are retained as evidence about the model rather than “corrected” away.
182
 
183
+ ---
184
+
185
+ ## Joint feature-set interventions
186
+
187
+ A concept may be distributed across multiple sparse features.
188
+
189
+ For a same-layer feature set `S`, FeatureLens sums the individual decoder-direction deltas:
190
 
191
  ```text
192
  h' = h + Σ_i∈S Δz_i d_i
193
  ```
194
 
195
+ The live app supports joint ablation and shared-multiplier scaling. Cross-layer feature sets are never combined into one residual intervention.
196
+
197
+ Additive injection is deliberately omitted from the feature-set UI because one shared additive coefficient across unrelated decoder directions has no uniquely natural interpretation.
198
+
199
+ ---
200
+
201
+ ## 1 / 3 / 5 feature-set sensitivity
202
+
203
+ At the selected prompt location, FeatureLens jointly ablates the strongest active:
204
+
205
+ ```text
206
+ k = 1, 3, 5
207
+ ```
208
+
209
+ features.
210
+
211
+ Each targeted joint perturbation receives its **own 8-direction norm-matched random ensemble**. The targeted edit, random controls, and zero-edit reference are scored in a batched execution context.
212
+
213
+ Increasing `k` is not assumed to increase effect magnitude or specificity. Non-monotonic behaviour may indicate redundancy, cancellation, distributed representation, or ordinary model non-linearity.
214
+
215
+ ---
216
+
217
+ ## Individual-vs-joint non-additivity
218
+
219
+ For a selected set of 2–5 active features, FeatureLens separately evaluates:
220
+
221
+ ```text
222
+ feature 1 ablation
223
+ feature 2 ablation
224
+ ...
225
+ joint ablation
226
+ zero-edit reference
227
+ ```
228
+
229
+ Let `e_i` be the Δ mean target log probability/token for the individual ablation of feature `i`, and `e_joint` the joint effect.
230
+
231
+ FeatureLens reports:
232
+
233
+ ```text
234
+ additive expectation = Σ_i e_i
235
+ interaction excess = e_joint - Σ_i e_i
236
+ ```
237
+
238
+ and a normalized interaction value scaled by the sum of absolute individual effects.
239
+
240
+ A non-zero excess diagnoses **non-additivity under this intervention**. It does not establish that the selected SAE features directly interact with one another or form a mechanistic circuit; downstream nonlinearities can also produce non-additivity.
241
+
242
+ ---
243
+
244
+ ## Paraphrase robustness: selected-token and prompt-wide
245
+
246
+ The strict selected-token comparison measures:
247
 
248
+ - TopK feature-support Jaccard;
249
+ - sparse activation cosine.
250
+
251
+ This can be misleading when the two manually selected tokens play different roles. For example, comparing the final token `is` in one prompt with a final punctuation token in its paraphrase is not a clean semantic-anchor comparison.
252
+
253
+ v0.4 therefore adds a prompt-wide profile. For each SAE feature, FeatureLens takes its maximum activation across all prompt tokens:
254
+
255
+ ```text
256
+ profile_i(prompt) = max_t z_{t,i}
257
+ ```
258
 
259
+ The two prompt-wide sparse dictionaries are then compared by support Jaccard and cosine.
260
 
261
+ The selected-token and prompt-wide metrics answer different questions and are intentionally displayed together.
262
 
263
+ ---
264
 
265
+ ## Controlled concept contrast scan
266
+
267
+ The live **Feature evidence** tab provides an exploratory contrast for one selected feature.
268
+
269
+ It samples a small balanced batch from the same seven controlled concept groups, using only one wording from each paraphrase pair, and measures feature activation at the final prompt token.
270
+
271
+ For each concept it reports:
272
+
273
+ - mean activation;
274
+ - median activation;
275
+ - activation rate;
276
+ - maximum activation.
277
+
278
+ This is a **diagnostic**, not the offline feature-labeling procedure. The live scan never modifies `Offline concept hint` and does not claim semantic identity from a handful of prompts.
279
+
280
+ ---
281
+
282
+ ## Statistical interpretation in the offline report
283
+
284
+ The generated offline report uses paired targeted-vs-control comparisons.
285
+
286
+ For a causal condition with several random controls, the report first aggregates the control ensemble for that same task/condition rather than selecting an arbitrary random row.
287
+
288
+ It then uses:
289
 
290
  - bootstrap 95% confidence intervals;
291
+ - paired sign-flip randomization tests;
292
+ - targeted/random effect-size ratios.
293
+
294
+ A large point-estimate ratio alone is not sufficient for a strong causal-specificity narrative if paired uncertainty remains weak.
295
 
296
+ FeatureLens keeps the raw rows even when the resulting conclusion is null, mixed, or contrary to the original hypothesis.
docs/VALIDATION.md CHANGED
@@ -1,62 +1,87 @@
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
 
@@ -64,9 +89,7 @@ Pass condition: Space reaches `Running` and the UI loads.
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`
@@ -75,35 +98,50 @@ Set:
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
 
@@ -115,149 +153,161 @@ 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 `` 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
 
@@ -271,24 +321,72 @@ Expected **Feature-set size measurements** rows:
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`
@@ -300,28 +398,40 @@ 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`
@@ -331,37 +441,121 @@ Click **Compare layers**.
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
 
@@ -369,9 +563,11 @@ Inspect:
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
 
@@ -379,38 +575,81 @@ Inspect:
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
- ### J5target 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.
 
 
1
+ # FeatureLens v0.4 validation guide
2
 
3
+ This guide intentionally uses the **exact labels visible in the v0.4 Gradio UI**. Follow it in order after every Hugging Face deployment that changes runtime or UI code.
4
 
5
+ The tests are divided into four categories:
6
 
7
+ 1. local software correctness;
8
  2. Hugging Face / ZeroGPU deployment correctness;
9
+ 3. causal-instrumentation sanity checks;
10
+ 4. edge/adversarial behavior.
11
 
12
+ A successful live prompt is a software sanity check, **not** a scientific conclusion. Scientific claims should come from the saved offline benchmark artifacts and their paired controls.
13
 
14
  ---
15
 
16
+ ## A. Local release gate
17
 
18
+ From the repository root run:
19
 
20
  ```bash
21
+ python3 -m pytest -q
22
+ python3 -m compileall -q app.py featurelens experiments scripts
23
+ python3 scripts/release_check.py
24
  ```
25
 
26
  Expected:
27
 
28
+ ```text
29
+ 29 passed
30
+ FeatureLens release check: PASS
31
+ discovery prompts: 224
32
+ causal tasks: 28
33
+ layers: [4, 14, 26]
34
+ feature-set sizes: [1, 3, 5]
35
+ random controls: 8
36
+ release: v0.4.0
37
+ ```
38
+
39
+ `compileall` should exit silently with status 0.
40
 
41
  Optional lint gate:
42
 
43
  ```bash
44
+ python3 -m ruff check app.py featurelens experiments tests scripts
45
  ```
46
 
47
  ---
48
 
49
+ ## B. Hugging Face startup and layout
50
 
51
  ### Test B1 — container startup
52
 
53
+ After a fresh Space rebuild, open the container logs.
54
 
55
+ Expected launch line:
56
 
57
  ```text
58
  * Running on local URL: http://0.0.0.0:7860
59
  ```
60
 
61
+ The earlier deployment warning should not return:
62
 
63
  ```text
64
  coroutine 'App.create_app.<locals>.get_current_user' was never awaited
65
  ```
66
 
67
+ The launch output should not contain `with SSR ⚡` because FeatureLens launches with `ssr_mode=False`.
68
+
69
+ **Pass:** the Space reaches `Running`, the UI loads, and no repeated startup exception appears.
70
+
71
+ ### Test B2 — page bottom / embedded Space behavior
72
+
73
+ Open **Workbench**, scroll to the bottom of the full application, and then switch through **Feature sets**, **Feature evidence**, **Paraphrase robustness**, and **Layer trajectory**.
74
+
75
+ Verify:
76
+
77
+ - descenders such as `g`, `p`, `q`, and `y` are not clipped inside app content;
78
+ - the last interactive element is followed by visible breathing room;
79
+ - the footer containing `End of workbench.` can be reached;
80
+ - labels do not overlap controls after a tab switch.
81
 
82
+ Also resize the browser to roughly half desktop width.
83
+
84
+ **Pass:** form rows wrap rather than overlap, controls remain readable, and the footer is reachable. If the outer Hugging Face iframe itself temporarily refuses to resize and then corrects itself after loading, record that separately from an app-level clipping failure.
85
 
86
  ---
87
 
 
89
 
90
  ### Test C1 — inspect a mathematics prompt
91
 
92
+ Open **Workbench** and set:
 
 
93
 
94
  - **Prompt**: `The derivative of x squared is`
95
  - **Residual layer**: `14`
 
98
 
99
  Click **Inspect sparse features**.
100
 
101
+ Verify:
102
 
103
+ 1. **Prompt tokens** appears and one token has the stronger selected outline.
104
+ 2. **Analysis metrics** reports:
105
+ - `Layer 14`;
106
+ - the resolved prompt token index;
107
  - active SAE features;
108
  - reconstruction cosine;
109
  - NMSE;
110
  - Top-5 activation mass.
111
+ 3. **Strongest active SAE features** contains 12 rows unless fewer are available.
112
+ 4. **Activation profile** contains the same displayed feature IDs.
113
+ 5. **Single feature id** is populated.
114
+ 6. Open **Feature sets** and verify **Feature set** is populated with active feature IDs.
115
+ 7. Open **Feature evidence** and verify **Feature id** is populated.
116
+
117
+ Basic bounds:
118
 
119
+ - reconstruction cosine is finite and lies in `[-1, 1]`;
120
+ - NMSE is finite and non-negative;
121
+ - feature IDs lie in `[0, 32767]`;
122
+ - displayed TopK activations are non-negative.
123
 
124
+ ### Test C2 copy feature table with headers
125
+
126
+ Under **Strongest active SAE features**, click **Copy table with headers** and paste into a plain-text editor.
127
+
128
+ The **first pasted line must contain the column names**, tab-separated, for example:
129
+
130
+ ```text
131
+ Rank Feature ID Activation Offline concept hint
132
+ ```
133
+
134
+ The following lines must contain the table rows.
135
+
136
+ Repeat by pasting into a spreadsheet. Columns should split naturally on tabs.
137
 
138
  ---
139
 
140
  ## D. Single-feature causal test
141
 
142
+ Continue from Test C1 and choose the strongest value in **Single feature id**.
143
 
144
+ ### Test D1 active-feature ablation with a multi-token target
 
 
145
 
146
  Set:
147
 
 
153
 
154
  Verify:
155
 
156
+ 1. **Baseline greedy generation** and **SAE-edited greedy generation** are populated.
157
+ 2. **Next-token distribution shift** is populated.
158
+ 3. **Target continuation token-by-token score** has one row for every token used to encode `2x`.
159
  4. The metrics report:
160
  - original feature activation;
161
  - Δ coefficient;
162
  - perturbation L2;
163
  - next-token JS;
164
+ - random-ensemble mean JS and standard deviation;
165
+ - number of random controls (`8`);
166
+ - JS magnitude ratio and empirical tail probability;
167
+ - execution-context null drift;
168
+ - full target token count;
169
+ - sequence and mean-per-token target effects;
170
+ - random-ensemble target effect statistics.
171
+ 5. For an active feature under ablation, `Δ coefficient ≈ -original feature activation`.
172
+
173
+ The two greedy generations are **allowed to be identical**. Probability/log-probability metrics are more sensitive than deterministic argmax text.
174
 
175
+ ### Test D2 — full-continuation table copy
176
 
177
+ Click **Copy table with headers** under **Target continuation token-by-token score** and paste into a text editor.
 
 
178
 
179
+ The first line must contain all target-score headers, followed by one line per target token.
180
+
181
+ ### Test D3 — scale
182
 
183
  Set:
184
 
185
  - **Single-feature intervention**: `scale`
186
  - **Feature multiplier**: `2`
187
+ - **Target continuation (optional)**: `2x`
188
 
189
  Click **Run single-feature causal test**.
190
 
191
+ For an active feature, expect `Δ coefficient ≈ +original feature activation`.
 
 
 
 
192
 
193
+ ### Test D4 — inject
194
 
195
  Set:
196
 
197
  - **Single-feature intervention**: `inject`
198
  - **Additive feature coefficient**: `5`
199
+ - **Target continuation (optional)**: `2x`
200
+
201
+ Click **Run single-feature causal test**.
202
 
203
  Expected:
204
 
205
+ - `Δ coefficient = +5` regardless of native activation;
206
+ - perturbation L2 is finite and non-zero;
207
+ - the random ensemble remains norm-matched to the resulting residual perturbation.
208
 
209
  ---
210
 
211
+ ## E. Numerical-null / scale dose-response test
212
 
213
+ This is the **critical v0.4 regression test** for the small non-zero `` drift observed in v0.3.
214
 
215
+ Keep the Workbench prompt/location and selected active feature from Test C1. Keep **Target continuation (optional)** set to `2x`.
216
 
217
+ Open **Scale dose-response** and click **Run scale dose-response**.
 
 
218
 
219
  Expected rows in **Scale dose-response measurements**:
220
 
221
  ```text
222
+ 0
223
  0.5
224
+ 1
225
  1.5
226
+ 2
227
+ 3
228
  ```
229
 
230
+ The experiment is always a scale sweep:
231
 
232
  - `0×` = ablation;
233
+ - `0.5×` = half native activation;
234
+ - `1×` = batched no-edit reference;
235
+ - `1.5×` = 1.5 times native activation;
236
  - `2×` = double;
237
  - `3×` = triple.
238
 
239
+ ### Required `1×` row
240
 
241
+ The `1×` row must report, apart from harmless display formatting such as `-0.0`:
 
 
 
 
242
 
243
+ ```text
244
+ Δ feature coefficient = 0
245
+ Perturbation L2 = 0
246
+ Δ mean log p/token = 0
247
+ Δ sequence log p = 0
248
+ Next-token JS = 0
249
+ ```
250
 
251
+ Do **not** accept the old v0.3 behavior where the 1× row had a small non-zero causal delta. Any separately executed single-vs-batch numerical discrepancy should now appear only as an **execution-context null drift** diagnostic and must not be counted as the intervention effect.
252
 
253
+ The curve itself does not need to be monotonic.
254
 
255
+ ---
256
 
257
+ ## F. Feature-set causal tests
258
 
259
+ Run Test C1 first so **Feature set** is populated.
260
 
261
+ ### Test F1 top-three joint ablation
262
+
263
+ Open **Feature sets**. Select exactly the three strongest active values in **Feature set**.
264
 
265
  Set:
266
 
267
+ - **Intervention**: `ablate`
268
  - **Target continuation**: `2x`
269
 
270
  Click **Run joint feature-set causal test**.
271
 
272
  Verify:
273
 
274
+ - **Joint intervention features** has exactly three rows;
275
+ - every active feature has `Δ coefficient = -original activation`;
276
+ - the total joint perturbation L2 is finite;
277
+ - the target continuation contains all target tokens;
278
+ - random-control count is `8`;
279
+ - random signed mean, random mean absolute effect, random standard deviation, magnitude ratio, and empirical tail probability are shown;
280
+ - execution-context null drift is shown separately from causal effect.
281
+
282
+ Do not require the targeted effect to exceed the random ensemble on every prompt. This is an empirical question.
283
 
284
  ### Test F2 — joint scale
285
 
286
  Set:
287
 
288
+ - **Intervention**: `scale`
289
  - **Shared feature multiplier**: `2`
290
 
291
+ Run **Run joint feature-set causal test** again.
292
+
293
+ Expected: each selected active feature receives its own positive native-activation delta before decoder-direction deltas are summed.
294
+
295
+ ### Test F3 — copy joint tables
296
+
297
+ Use **Copy table with headers** under both:
298
+
299
+ - **Joint intervention features**;
300
+ - **Target continuation token-by-token score**.
301
 
302
+ For both, the first pasted line must contain the table headers.
303
 
304
  ---
305
 
306
+ ## G. 1/3/5 feature-set sensitivity
307
 
308
+ In **Feature sets**, set:
309
 
310
+ - **Target continuation for set-size sweep**: `2x`
311
 
312
  Click **Run 1/3/5-feature ablation sweep**.
313
 
 
321
 
322
  For each row verify:
323
 
324
+ - exactly `k` feature IDs are listed;
325
  - perturbation L2 is finite;
326
+ - targeted Δ mean log p/token is present;
327
+ - random-ensemble signed mean, mean absolute effect, and standard deviation are present;
328
+ - targeted/random magnitude ratio is finite or safely handled when the random effect is numerically zero;
329
+ - empirical tail probability lies in `[0, 1]`;
330
+ - SAE and random next-token JS statistics are present.
331
+
332
+ **Do not require monotonicity with k.** A k=3 edit may be less effective than k=1, and k=5 may reverse the effect. That can be a real non-linear response rather than a bug.
333
+
334
+ ---
335
+
336
+ ## H. Individual-vs-joint non-additivity
337
+
338
+ In **Feature sets**, select exactly the three strongest active values in **Feature set**.
339
+
340
+ Set:
341
+
342
+ - **Target continuation for interaction test**: `2x`
343
 
344
+ Click **Run individual-vs-joint decomposition**.
345
 
346
+ Verify:
347
+
348
+ 1. **Individual and joint ablation measurements** contains one row per selected feature plus one `Joint ablation` row.
349
+ 2. The summary reports:
350
+ - additive expectation = sum of individual Δ mean log p/token values;
351
+ - observed joint effect;
352
+ - interaction excess = joint − additive;
353
+ - normalized interaction;
354
+ - execution-context null drift.
355
+ 3. The joint row perturbation norm is finite.
356
+
357
+ No particular sign is required. A non-zero interaction excess indicates non-additivity under this intervention, **not** proof that the selected SAE features form a direct circuit.
358
 
359
  ---
360
 
361
+ ## I. Controlled concept contrast
362
 
363
+ First run Test C1. Open **Feature evidence**.
364
 
365
  Set:
366
 
367
+ - **Feature id**: the strongest feature from Test C1;
368
+ - **Residual layer**: `14`;
369
+ - **Prompts per concept**: `4`.
370
+
371
+ Click **Run controlled concept contrast**.
372
+
373
+ Expected **Feature activation by controlled concept**:
374
+
375
+ - exactly seven concept rows;
376
+ - each row reports prompt count, mean activation, median activation, activation rate, and max activation;
377
+ - the chart contains the same seven concepts;
378
+ - the summary may identify the group with the highest mean activation but must explicitly describe the scan as exploratory.
379
+
380
+ This live scan is **not allowed to rename the feature or populate `Offline concept hint`**. A semantic hint should only come from the offline train/held-out discovery pipeline.
381
+
382
+ Use **Copy table with headers** and verify the first copied line contains all columns.
383
+
384
+ ---
385
+
386
+ ## J. Paraphrase robustness
387
+
388
+ Open **Paraphrase robustness** and set:
389
+
390
  - **Original prompt**: `The derivative of x squared is`
391
  - **Paraphrase**: `Differentiate x squared with respect to x:`
392
  - **Residual layer**: `14`
 
398
 
399
  Verify:
400
 
401
+ 1. **Original prompt tokens** and **Paraphrase tokens** render.
402
+ 2. **Robustness metrics** reports two distinct comparisons:
403
+ - selected-token TopK Jaccard and sparse activation cosine;
404
+ - **prompt-wide** max-pooled Jaccard and cosine.
405
+ 3. **Top-feature overlap at selected tokens** marks features `shared`, `original only`, or `paraphrase only`.
406
+ 4. The prompt-wide metrics remain available even when the two selected final tokens play different syntactic/semantic roles.
 
407
 
408
  Bounds:
409
 
410
+ - Jaccards lie in `[0, 1]`;
411
+ - cosines should lie approximately in `[0, 1]` for non-negative SAE activations.
412
 
413
+ Do not impose an arbitrary minimum robustness threshold.
414
 
415
+ ### Test J2 — identity sanity check
416
 
417
+ Use the **same exact prompt** in **Original prompt** and **Paraphrase**, with the same token indices and layer.
418
 
419
+ Expected, within numerical precision:
420
 
421
+ ```text
422
+ selected-token Jaccard ≈ 1
423
+ selected-token cosine ≈ 1
424
+ prompt-wide Jaccard ≈ 1
425
+ prompt-wide cosine ≈ 1
426
+ ```
427
+
428
+ This is a strong sanity check for the comparison implementation.
429
+
430
+ ---
431
+
432
+ ## K. Layer trajectory
433
+
434
+ Open **Layer trajectory** and set:
435
 
436
  - **Prompt**: `The derivative of x squared is`
437
  - **Prompt token index**: `-1`
 
441
  Expected:
442
 
443
  - **Prompt tokens** renders;
444
+ - **Layer diagnostics** has exactly three rows: `4`, `14`, `26`;
445
+ - **Representation trajectory** contains reconstruction cosine, Top-5 mass, and activation entropy.
 
 
 
446
 
447
+ Do not compare numerical feature IDs across layers. Each residual layer has its own independently learned SAE dictionary.
448
 
449
  ---
450
 
451
+ # L. Edge and adversarial tests
452
+
453
+ These tests are designed to expose software or interpretation failures. They are not meant to produce attractive scientific results.
454
+
455
+ ## Test L1 — empty prompt
456
+
457
+ In **Workbench**, clear **Prompt** completely and click **Inspect sparse features**.
458
+
459
+ **Pass:** the UI displays a clear error and the Space remains alive. The container must not restart.
460
 
461
+ ## Test L2 out-of-range prompt token index
462
 
463
+ Use the normal mathematics prompt but set **Prompt token index** to `999` and click **Inspect sparse features**.
464
 
465
+ **Pass:** a clear index/range error is shown; the application remains usable afterward.
466
 
467
+ ## Test L3 feature-ID boundaries
468
+
469
+ After a successful Workbench inspection, manually enter the following in **Single feature id**:
470
+
471
+ 1. `0` — valid boundary;
472
+ 2. `32767` — valid boundary;
473
+ 3. `32768` — invalid.
474
+
475
+ Run **Run single-feature causal test** with a short target.
476
+
477
+ **Pass:** IDs 0 and 32767 are accepted; 32768 produces a clear validation error without crashing the Space.
478
+
479
+ ## Test L4 — inactive-feature semantics
480
+
481
+ Find a feature that has activation 0 at the selected prompt location. A practical way is to enter a valid feature ID that is not among the active TopK IDs.
482
+
483
+ Set **Single-feature intervention** to `ablate` and run the causal test.
484
+
485
+ Expected:
486
 
487
+ ```text
488
+ Original feature activation ≈ 0
489
+ Δ coefficient ≈ 0
490
+ Perturbation L2 ≈ 0
491
+ ```
492
+
493
+ The UI should state that ablation/scaling of an inactive feature has zero coefficient delta.
494
+
495
+ Then switch to `inject`, set **Additive feature coefficient** to `5`, and run again.
496
+
497
+ Expected: a non-zero residual perturbation is possible because injection intentionally tests the decoder direction even when the native activation is zero.
498
+
499
+ ## Test L5 — zero-perturbation random-control handling
500
+
501
+ Continue Test L4 with the inactive feature and `ablate`.
502
+
503
+ **Pass:** the zero targeted perturbation does not create `NaN`, `inf`, or a crash in random-control statistics. JS/effect values should be zero or numerical zero and ratios/p-values must be safely represented.
504
+
505
+ ## Test L6 — exact target whitespace
506
+
507
+ For the same prompt and feature, run the target twice:
508
+
509
+ ```text
510
+ 2x
511
+ ```
512
+
513
+ and
514
+
515
+ ```text
516
+ 2x
517
+ ```
518
 
519
+ (the second begins with a space).
520
 
521
+ **Pass:** FeatureLens reports the actual tokenization of each exact string and scores all returned target tokens. Different tokenizations/results are allowed.
522
 
523
+ ## Test L7 required-target validation
524
 
525
+ Clear the target and try:
526
 
527
+ - **Run scale dose-response**;
528
+ - **Run joint feature-set causal test**;
529
+ - **Run 1/3/5-feature ablation sweep**;
530
+ - **Run individual-vs-joint decomposition**.
531
+
532
+ **Pass:** every experiment that mathematically requires a target gives a clear UI error rather than silently inventing one or failing deep in the model call.
533
+
534
+ The ordinary **Run single-feature causal test** may be used without a target because next-token JS and greedy generation are still defined.
535
+
536
+ ## Test L8 — identical paraphrases
537
+
538
+ Repeat Test J2 with the exact same text, same layer, and same token index.
539
+
540
+ **Pass:** all robustness metrics are approximately 1. This is the adversarial regression check for the prompt-wide pooling code.
541
+
542
+ ## Test L9 — unrelated “paraphrase”
543
+
544
+ Compare:
545
+
546
+ ```text
547
+ The derivative of x squared is
548
+ ```
549
+
550
+ with:
551
+
552
+ ```text
553
+ Je voudrais réserver une table pour demain soir.
554
+ ```
555
+
556
+ **Pass:** the app returns finite robustness metrics but does **not** label the prompts as semantically equivalent merely because some features overlap. No hard numerical threshold is required.
557
+
558
+ ## Test L10 — lexical trap / negation
559
 
560
  Inspect:
561
 
 
563
  This is not a positive review.
564
  ```
565
 
566
+ Then optionally run **Run controlled concept contrast** on one strongly active feature.
567
+
568
+ **Pass:** the system reports activations without converting the lexical word `positive` into a hard semantic claim. `Offline concept hint` must remain whatever the offline catalog says.
569
 
570
+ ## Test L11 — mixed-language prompt
571
 
572
  Inspect:
573
 
 
575
  The answer est probablement correct, but I am not certain.
576
  ```
577
 
578
+ **Pass:** tokenization, SAE inspection, and causal controls remain finite. Do not expect a single concept feature to dominate.
579
 
580
+ ## Test L12 long prompt truncation
581
 
582
+ Paste a prompt substantially longer than the configured prompt-token limit (repeat several paragraphs if needed) and click **Inspect sparse features**.
583
 
584
+ **Pass:** the request truncates safely to the configured maximum instead of causing an OOM or index corruption. **Prompt tokens** should reflect the actually processed token sequence.
585
+
586
+ ## Test L13 — table clipboard behavior
587
+
588
+ For at least three different output tables, click **Copy table with headers** and paste into:
589
+
590
+ 1. a plain-text editor;
591
+ 2. a spreadsheet.
592
+
593
+ **Pass:** the first row always contains headers, data are tab-separated, and embedded commas in cells do not shift spreadsheet columns.
594
+
595
+ ## Test L14 — rapid double click / queue safety
596
+
597
+ Click one GPU-heavy action twice quickly, for example **Run single-feature causal test**.
598
+
599
+ **Pass:** requests queue under the app's concurrency limit rather than corrupting shared activation hooks or crashing the runtime. Do not repeatedly hammer the button beyond this single queue test because ZeroGPU quota is limited.
600
+
601
+ ## Test L15 — responsive Feature Sets form
602
+
603
+ At narrow browser width, open **Feature sets**.
604
+
605
+ Verify the row containing:
606
+
607
+ - **Intervention**;
608
+ - **Multiplier (unused for ablation)** or **Shared feature multiplier**;
609
+ - **Target continuation**
610
+
611
+ wraps cleanly. Switch **Intervention** between `ablate` and `scale`.
612
+
613
+ **Pass:** labels remain attached to the correct fields, no control text runs together, and changing the mode updates the multiplier label without shifting the target field into an ambiguous position.
614
 
615
  ---
616
 
617
+ # M. Offline scientific benchmark acceptance
618
 
619
+ After the public Space passes the live tests, run on separate CUDA compute:
620
 
621
  ```bash
622
+ python3 experiments/run_all.py
623
  ```
624
 
625
+ v0.4 offline causal runners use:
626
+
627
+ - a batched zero-edit reference;
628
+ - targeted SAE interventions;
629
+ - configurable norm-matched random-control ensembles (default `8`);
630
+ - full-continuation mean log-probability effects.
631
+
632
+ Expected major artifacts include:
633
 
634
  ```text
635
+ artifacts/feature_catalog.csv
636
+ artifacts/layer_metrics.csv
637
+ artifacts/stability.csv
638
+ artifacts/causal_results.csv
639
  artifacts/feature_set_results.csv
640
+ artifacts/report.md
641
+ artifacts/figures/
642
  ```
643
 
644
+ Before putting any number on a resume or portfolio page, manually verify:
645
+
646
+ - train-only feature selection;
647
+ - group-wise paraphrase split integrity;
648
+ - held-out AUROC/F1;
649
+ - reconstruction metrics by layer;
650
+ - selected-token and paraphrase stability;
651
+ - complete random-control ensembles for each causal condition;
652
+ - execution-context null drift;
653
+ - targeted-vs-random paired effect uncertainty;
654
+ - whether single-feature and multi-feature conclusions agree or conflict;
655
+ - whether any narrative claim is stronger than the saved raw rows support.
experiments/make_report.py CHANGED
@@ -33,7 +33,7 @@ def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
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'
@@ -126,15 +126,24 @@ def _paired_stats(
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'),
@@ -343,7 +352,7 @@ def main() -> None:
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.',
 
33
 
34
 
35
  def _effect_column(frame: pd.DataFrame) -> str:
36
+ """Prefer the full-continuation length-normalized metric, with legacy fallback."""
37
  if 'target_mean_logprob_delta' in frame.columns:
38
  return 'target_mean_logprob_delta'
39
  return 'target_logprob_delta'
 
126
  random_condition: str,
127
  seed: int,
128
  ) -> dict[str, float | list[float]]:
129
+ """Pair one SAE effect with the mean absolute effect of its random-control ensemble."""
130
  metric = _effect_column(frame)
131
+ sae = (
132
+ frame[frame['condition'] == sae_condition]
133
+ .groupby(index, as_index=False)[metric]
134
+ .first()
135
+ .rename(columns={metric: 'sae_effect'})
136
+ )
137
+ random = (
138
+ frame[frame['condition'] == random_condition]
139
+ .assign(_abs_effect=lambda data: np.abs(data[metric].astype(float)))
140
+ .groupby(index, as_index=False)['_abs_effect']
141
+ .mean()
142
+ .rename(columns={'_abs_effect': 'random_abs_effect'})
143
+ )
144
+ paired = sae.merge(random, on=index, how='inner')
145
+ sae_abs = np.abs(paired['sae_effect'].to_numpy(dtype=float))
146
+ random_abs = paired['random_abs_effect'].to_numpy(dtype=float)
147
  if sae_abs.size == 0:
148
  return {
149
  'sae_abs': float('nan'),
 
352
  '- Linear baseline: multinomial logistic regression on the dense residual stream.',
353
  '- Single-feature causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
354
  '- Feature-set causal edit: joint ablation of top same-layer concept features, evaluated at k=1/3/5 by default.',
355
+ '- Negative control: ensemble of deterministic random residual directions, each matched to the SAE perturbation L2 norm.',
356
  '- Target metric: exact full target continuation scored teacher-forced; mean log probability per target token is the primary length-comparable effect.',
357
  '- Secondary diagnostics: first-token probability/rank, next-token JS divergence, and top-1 changes.',
358
  '- Uncertainty: bootstrap 95% confidence intervals and paired sign-flip randomization tests.',
experiments/run_causal.py CHANGED
@@ -21,6 +21,7 @@ def parse_args() -> argparse.Namespace:
21
  parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
22
  parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'causal_results.csv')
23
  parser.add_argument('--seed', type=int, default=42)
 
24
  return parser.parse_args()
25
 
26
 
@@ -65,6 +66,15 @@ def append_target(inputs: dict[str, torch.Tensor], target_ids: list[int]) -> dic
65
  }
66
 
67
 
 
 
 
 
 
 
 
 
 
68
  @torch.inference_mode()
69
  def main() -> None:
70
  args = parse_args()
@@ -117,56 +127,56 @@ def main() -> None:
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):
@@ -175,8 +185,7 @@ def main() -> None:
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)
@@ -185,7 +194,26 @@ def main() -> None:
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())
@@ -212,9 +240,12 @@ def main() -> None:
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,
@@ -225,7 +256,6 @@ def main() -> None:
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,
 
21
  parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
22
  parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'causal_results.csv')
23
  parser.add_argument('--seed', type=int, default=42)
24
+ parser.add_argument('--random-controls', type=int, default=8)
25
  return parser.parse_args()
26
 
27
 
 
66
  }
67
 
68
 
69
+ def make_random_controls(delta: torch.Tensor, seed: int, count: int) -> list[torch.Tensor]:
70
+ if count < 1:
71
+ raise ValueError('--random-controls must be at least 1.')
72
+ return [
73
+ normalized_random_control(delta, seed=int(seed) + 104729 * idx)
74
+ for idx in range(int(count))
75
+ ]
76
+
77
+
78
  @torch.inference_mode()
79
  def main() -> None:
80
  args = parse_args()
 
127
  capture['hidden'] = hidden_from_output(output).detach()
128
 
129
  handle = model.model.layers[layer].register_forward_hook(capture_hook)
130
+ single_baseline_out = model(**full_inputs, use_cache=False)
131
  handle.remove()
132
+ single_baseline_logits = single_baseline_out.logits[0]
133
+ _, single_baseline_mean, _ = sequence_logprob_summary(
134
+ single_baseline_logits,
 
 
135
  prompt_length=prompt_len,
136
  target_ids=target_ids,
137
  )
138
+
139
  residual = capture['hidden'][0, prompt_len - 1]
140
  encoding = sae.encode(residual)
141
  original_activation = encoding.activation_for(feature_id)
 
 
 
 
142
 
143
  specs = [
144
  ('ablate', InterventionSpec('ablate', 0.0)),
145
  ('amplify_2x', InterventionSpec('scale', 2.0)),
146
  ]
147
+ condition_meta: list[tuple[str, str, int, InterventionSpec, torch.Tensor, float]] = []
148
  for spec_idx, (intervention_name, spec) in enumerate(specs):
149
  delta = residual_delta(sae.decoder_direction(feature_id), original_activation, spec)
150
+ condition_meta.append(
151
+ (
152
+ intervention_name,
153
+ 'sae_feature',
154
+ -1,
155
+ spec,
156
+ delta,
157
+ float(spec.delta_activation(original_activation)),
158
+ )
159
+ )
160
+ controls = make_random_controls(
161
  delta,
162
+ seed=args.seed + task_idx * 1009 + spec_idx * 100_003,
163
+ count=args.random_controls,
164
  )
165
+ for control_id, control_delta in enumerate(controls):
166
+ condition_meta.append(
 
 
 
 
 
 
 
167
  (
168
  intervention_name,
169
  'random_norm_matched',
170
+ control_id,
171
  spec,
172
  control_delta,
173
  math.nan,
174
+ )
175
+ )
 
176
 
177
+ zero = torch.zeros_like(condition_meta[0][4])
178
+ deltas = torch.stack([zero, *[item[4] for item in condition_meta]], dim=0)
179
+ repeated = {key: value.repeat(deltas.shape[0], 1) for key, value in full_inputs.items()}
180
  applied = {'done': False}
181
 
182
  def batch_edit_hook(_module, _inp, output):
 
185
  hidden = hidden_from_output(output)
186
  modified = hidden.clone()
187
  modified[:, prompt_len - 1, :] = (
188
+ modified[:, prompt_len - 1, :] + deltas.to(hidden.device, hidden.dtype)
 
189
  )
190
  applied['done'] = True
191
  return replace_hidden(output, modified)
 
194
  edited_out = model(**repeated, use_cache=False)
195
  hook.remove()
196
 
197
+ baseline_logits = edited_out.logits[0]
198
+ baseline_next = baseline_logits[prompt_len - 1]
199
+ baseline_seq, baseline_mean, _ = sequence_logprob_summary(
200
+ baseline_logits,
201
+ prompt_length=prompt_len,
202
+ target_ids=target_ids,
203
+ )
204
+ execution_drift_mean = float(baseline_mean - single_baseline_mean)
205
+ execution_drift_js = js_divergence_from_logits(
206
+ single_baseline_logits[prompt_len - 1], baseline_next
207
+ )
208
+ target_id = target_ids[0]
209
+ baseline_prob = float(torch.softmax(baseline_next.float(), dim=-1)[target_id].item())
210
+ baseline_rank = int((baseline_next > baseline_next[target_id]).sum().item()) + 1
211
+ baseline_top1 = int(torch.argmax(baseline_next).item())
212
+
213
+ for row_idx, (intervention_name, condition, control_id, spec, applied_delta, delta_activation) in enumerate(
214
+ condition_meta,
215
+ start=1,
216
+ ):
217
  modified_logits = edited_out.logits[row_idx]
218
  modified_next = modified_logits[prompt_len - 1]
219
  modified_prob = float(torch.softmax(modified_next.float(), dim=-1)[target_id].item())
 
240
  'feature_activation': original_activation,
241
  'intervention': intervention_name,
242
  'condition': condition,
243
+ 'control_id': control_id,
244
+ 'random_control_count': args.random_controls,
245
  'delta_activation': delta_activation,
246
  'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
247
+ 'execution_context_mean_logprob_drift': execution_drift_mean,
248
+ 'execution_context_js_drift': execution_drift_js,
249
  'baseline_target_prob': baseline_prob,
250
  'modified_target_prob': modified_prob,
251
  'target_prob_delta': modified_prob - baseline_prob,
 
256
  'baseline_target_rank': baseline_rank,
257
  'modified_target_rank': modified_rank,
258
  'target_rank_delta': modified_rank - baseline_rank,
 
259
  'baseline_target_sequence_logprob': baseline_seq,
260
  'modified_target_sequence_logprob': modified_seq,
261
  'target_sequence_logprob_delta': modified_seq - baseline_seq,
experiments/run_feature_sets.py CHANGED
@@ -22,6 +22,7 @@ def parse_args() -> argparse.Namespace:
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
 
@@ -44,6 +45,14 @@ def append_target(inputs: dict[str, torch.Tensor], target_ids: list[int]) -> dic
44
  }
45
 
46
 
 
 
 
 
 
 
 
 
47
 
48
  @torch.inference_mode()
49
  def main() -> None:
@@ -93,7 +102,6 @@ def main() -> None:
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):
@@ -101,12 +109,11 @@ def main() -> None:
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
  )
@@ -114,7 +121,7 @@ def main() -> None:
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]
@@ -124,19 +131,20 @@ def main() -> None:
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):
@@ -154,7 +162,20 @@ def main() -> None:
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,
@@ -174,7 +195,11 @@ def main() -> None:
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,
 
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
+ parser.add_argument('--random-controls', type=int, default=8)
26
  return parser.parse_args()
27
 
28
 
 
45
  }
46
 
47
 
48
+ def make_random_controls(delta: torch.Tensor, seed: int, count: int) -> list[torch.Tensor]:
49
+ if count < 1:
50
+ raise ValueError('--random-controls must be at least 1.')
51
+ return [
52
+ normalized_random_control(delta, seed=int(seed) + 104729 * idx)
53
+ for idx in range(int(count))
54
+ ]
55
+
56
 
57
  @torch.inference_mode()
58
  def main() -> None:
 
102
  raise RuntimeError(f"Target tokenization empty for task {task['id']}")
103
  target_ids = [int(x) for x in target_ids]
104
  full_inputs = append_target(prompt_inputs, target_ids)
 
105
  capture: dict = {}
106
 
107
  def capture_hook(_module, _inp, output):
 
109
  capture['hidden'] = hidden_from_output(output).detach()
110
 
111
  handle = model.model.layers[layer].register_forward_hook(capture_hook)
112
+ single_baseline_out = model(**full_inputs, use_cache=False)
113
  handle.remove()
114
+ single_logits = single_baseline_out.logits[0]
115
+ _, single_mean, _ = sequence_logprob_summary(
116
+ single_logits,
 
117
  prompt_length=prompt_len,
118
  target_ids=target_ids,
119
  )
 
121
  encoding = sae.encode(residual)
122
 
123
  valid_sizes = [size for size in sizes if size <= len(candidate_ids)]
124
+ condition_meta: list[tuple[int, str, int, list[int], torch.Tensor]] = []
125
  for size in valid_sizes:
126
  feature_ids = candidate_ids[:size]
127
  activations = [encoding.activation_for(feature_id) for feature_id in feature_ids]
 
131
  activations,
132
  InterventionSpec('ablate', 0.0),
133
  )
134
+ condition_meta.append((size, 'sae_feature_set', -1, feature_ids, delta))
135
+ controls = make_random_controls(
136
  delta,
137
+ seed=args.seed + task_idx * 1009 + size * 100_003,
138
+ count=args.random_controls,
 
 
 
 
 
139
  )
140
+ for control_id, control in enumerate(controls):
141
+ condition_meta.append(
142
+ (size, 'random_norm_matched', control_id, feature_ids, control)
143
+ )
144
+
145
+ zero = torch.zeros_like(condition_meta[0][4])
146
+ deltas = torch.stack([zero, *[item[4] for item in condition_meta]], dim=0)
147
+ repeated = {key: value.repeat(deltas.shape[0], 1) for key, value in full_inputs.items()}
148
  applied = {'done': False}
149
 
150
  def edit_hook(_module, _inp, output):
 
162
  edited_out = model(**repeated, use_cache=False)
163
  hook.remove()
164
 
165
+ baseline_logits = edited_out.logits[0]
166
+ baseline_next = baseline_logits[prompt_len - 1]
167
+ baseline_seq, baseline_mean, _ = sequence_logprob_summary(
168
+ baseline_logits,
169
+ prompt_length=prompt_len,
170
+ target_ids=target_ids,
171
+ )
172
+ execution_drift_mean = float(baseline_mean - single_mean)
173
+ execution_drift_js = js_divergence_from_logits(single_logits[prompt_len - 1], baseline_next)
174
+
175
+ for row_idx, (size, condition, control_id, feature_ids, applied_delta) in enumerate(
176
+ condition_meta,
177
+ start=1,
178
+ ):
179
  logits = edited_out.logits[row_idx]
180
  seq_logp, mean_logp, _ = sequence_logprob_summary(
181
  logits,
 
195
  'feature_ids': ','.join(str(x) for x in feature_ids),
196
  'active_selected_features': int(active_count),
197
  'condition': condition,
198
+ 'control_id': control_id,
199
+ 'random_control_count': args.random_controls,
200
  'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
201
+ 'execution_context_mean_logprob_drift': execution_drift_mean,
202
+ 'execution_context_js_drift': execution_drift_js,
203
  'baseline_target_sequence_logprob': baseline_seq,
204
  'modified_target_sequence_logprob': seq_logp,
205
  'target_sequence_logprob_delta': seq_logp - baseline_seq,
featurelens/config.py CHANGED
@@ -27,6 +27,8 @@ class Settings:
27
  d_model: int = 2_048
28
  max_prompt_tokens: int = int(os.getenv('FEATURELENS_MAX_PROMPT_TOKENS', '256'))
29
  max_new_tokens: int = int(os.getenv('FEATURELENS_MAX_NEW_TOKENS', '32'))
 
 
30
  eager_load: bool = os.getenv(
31
  'FEATURELENS_EAGER_LOAD', '1' if os.getenv('SPACE_ID') else '0'
32
  ).lower() in {'1', 'true', 'yes', 'on'}
 
27
  d_model: int = 2_048
28
  max_prompt_tokens: int = int(os.getenv('FEATURELENS_MAX_PROMPT_TOKENS', '256'))
29
  max_new_tokens: int = int(os.getenv('FEATURELENS_MAX_NEW_TOKENS', '32'))
30
+ live_random_controls: int = int(os.getenv('FEATURELENS_LIVE_RANDOM_CONTROLS', '8'))
31
+ contrast_prompts_per_concept: int = int(os.getenv('FEATURELENS_CONTRAST_PROMPTS_PER_CONCEPT', '4'))
32
  eager_load: bool = os.getenv(
33
  'FEATURELENS_EAGER_LOAD', '1' if os.getenv('SPACE_ID') else '0'
34
  ).lower() in {'1', 'true', 'yes', 'on'}
featurelens/runtime.py CHANGED
@@ -4,6 +4,8 @@ import hashlib
4
  import html
5
  import math
6
  import os
 
 
7
  from collections.abc import Iterator, Sequence
8
  from contextlib import contextmanager
9
  from dataclasses import dataclass
@@ -68,7 +70,12 @@ class InterventionResult:
68
  perturbation_norm: float
69
  js_divergence: float
70
  random_js_divergence: float
 
71
  js_specificity_ratio: float
 
 
 
 
72
  target_text: str
73
  target_token_count: int
74
  target_tokens: list[str]
@@ -82,7 +89,10 @@ class InterventionResult:
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
 
@@ -98,6 +108,8 @@ class LayerSweepResult:
98
  class DoseResponseResult:
99
  feature_activation: float
100
  target_tokens: list[str]
 
 
101
  rows: list[list[object]]
102
 
103
 
@@ -108,7 +120,12 @@ class FeatureSetResult:
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
@@ -116,7 +133,10 @@ class FeatureSetResult:
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
 
@@ -124,9 +144,35 @@ class FeatureSetResult:
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]
@@ -135,6 +181,8 @@ class ParaphraseResult:
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]]
@@ -353,6 +401,66 @@ class FeatureLensRuntime:
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 {
@@ -366,7 +474,13 @@ class FeatureLensRuntime:
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)
371
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
372
  if int(layer) not in self.settings.layers:
@@ -377,9 +491,13 @@ class FeatureLensRuntime:
377
  self.model(**inputs, use_cache=False)
378
  hidden = bucket['hidden'][0]
379
  idx = self._resolve_index(int(token_index), hidden.shape[0])
380
- residual = hidden[idx]
381
  sae = self.sae_store.get(int(layer))
382
- encoding = sae.encode(residual)
 
 
 
 
 
383
  reconstruction = sae.decode_sparse(encoding)
384
  metrics = reconstruction_metrics(residual, reconstruction)
385
  metrics['active_features'] = float(encoding.active_count)
@@ -398,7 +516,7 @@ class FeatureLensRuntime:
398
  rows.append(
399
  [rank + 1, feature_id, activation, self.catalog.hint(int(layer), feature_id)]
400
  )
401
- return AnalysisResult(
402
  tokens=tokens,
403
  token_index=idx,
404
  layer=int(layer),
@@ -406,6 +524,12 @@ class FeatureLensRuntime:
406
  rows=rows,
407
  metrics=metrics,
408
  )
 
 
 
 
 
 
409
 
410
  @torch.inference_mode()
411
  def layer_sweep(self, text: str, token_index: int = -1) -> LayerSweepResult:
@@ -529,76 +653,120 @@ class FeatureLensRuntime:
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),
@@ -610,44 +778,11 @@ class FeatureLensRuntime:
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,
653
  modified_text=modified_text,
@@ -655,8 +790,13 @@ class FeatureLensRuntime:
655
  delta_activation=float(spec.delta_activation(original_activation)),
656
  perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
657
  js_divergence=float(js),
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,
@@ -665,18 +805,18 @@ class FeatureLensRuntime:
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
 
@@ -692,6 +832,8 @@ class FeatureLensRuntime:
692
  ) -> DoseResponseResult:
693
  if not target_text.strip():
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)
@@ -703,22 +845,19 @@ class FeatureLensRuntime:
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)
@@ -730,13 +869,24 @@ class FeatureLensRuntime:
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
  [
@@ -747,17 +897,14 @@ class FeatureLensRuntime:
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
 
@@ -793,51 +940,70 @@ class FeatureLensRuntime:
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
  [
@@ -847,10 +1013,7 @@ class FeatureLensRuntime:
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(
@@ -858,22 +1021,27 @@ class FeatureLensRuntime:
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
 
@@ -886,7 +1054,7 @@ class FeatureLensRuntime:
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)
@@ -900,7 +1068,11 @@ class FeatureLensRuntime:
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 = [
@@ -916,84 +1088,210 @@ class FeatureLensRuntime:
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,
@@ -1006,8 +1304,8 @@ class FeatureLensRuntime:
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)
@@ -1016,11 +1314,10 @@ class FeatureLensRuntime:
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)]]
@@ -1033,13 +1330,7 @@ class FeatureLensRuntime:
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])
@@ -1052,12 +1343,103 @@ class FeatureLensRuntime:
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
 
1062
  RUNTIME = FeatureLensRuntime()
1063
 
 
4
  import html
5
  import math
6
  import os
7
+ import json
8
+ from pathlib import Path
9
  from collections.abc import Iterator, Sequence
10
  from contextlib import contextmanager
11
  from dataclasses import dataclass
 
70
  perturbation_norm: float
71
  js_divergence: float
72
  random_js_divergence: float
73
+ random_js_std: float
74
  js_specificity_ratio: float
75
+ js_empirical_p: float
76
+ random_control_count: int
77
+ execution_drift_js: float
78
+ execution_drift_mean_logprob: float | None
79
  target_text: str
80
  target_token_count: int
81
  target_tokens: list[str]
 
89
  random_sequence_logprob_delta: float | None
90
  mean_logprob_delta: float | None
91
  random_mean_logprob_delta: float | None
92
+ random_abs_mean_logprob_delta: float | None
93
+ random_mean_logprob_std: float | None
94
  target_specificity_ratio: float | None
95
+ target_empirical_p: float | None
96
  target_token_rows: list[list[object]]
97
  top_token_rows: list[list[object]]
98
 
 
108
  class DoseResponseResult:
109
  feature_activation: float
110
  target_tokens: list[str]
111
+ execution_drift_mean_logprob: float
112
+ execution_drift_js: float
113
  rows: list[list[object]]
114
 
115
 
 
120
  perturbation_norm: float
121
  js_divergence: float
122
  random_js_divergence: float
123
+ random_js_std: float
124
  js_specificity_ratio: float
125
+ js_empirical_p: float
126
+ random_control_count: int
127
+ execution_drift_mean_logprob: float
128
+ execution_drift_js: float
129
  baseline_sequence_logprob: float
130
  modified_sequence_logprob: float
131
  random_sequence_logprob: float
 
133
  random_sequence_logprob_delta: float
134
  mean_logprob_delta: float
135
  random_mean_logprob_delta: float
136
+ random_abs_mean_logprob_delta: float
137
+ random_mean_logprob_std: float
138
  target_specificity_ratio: float
139
+ target_empirical_p: float
140
  target_tokens: list[str]
141
  target_token_rows: list[list[object]]
142
 
 
144
  @dataclass
145
  class FeatureSetSweepResult:
146
  target_tokens: list[str]
147
+ random_control_count: int
148
+ execution_drift_mean_logprob: float
149
+ execution_drift_js: float
150
  rows: list[list[object]]
151
 
152
 
153
+ @dataclass
154
+ class FeatureInteractionResult:
155
+ feature_ids: list[int]
156
+ target_tokens: list[str]
157
+ rows: list[list[object]]
158
+ additive_expected_mean_delta: float
159
+ joint_mean_delta: float
160
+ interaction_excess_mean_delta: float
161
+ normalized_interaction: float
162
+ execution_drift_mean_logprob: float
163
+
164
+
165
+ @dataclass
166
+ class ConceptContrastResult:
167
+ feature_id: int
168
+ layer: int
169
+ prompts_per_concept: int
170
+ rows: list[list[object]]
171
+ chart_rows: list[list[object]]
172
+ leading_concept: str
173
+ leading_ratio: float
174
+
175
+
176
  @dataclass
177
  class ParaphraseResult:
178
  tokens_a: list[str]
 
181
  token_index_b: int
182
  topk_jaccard: float
183
  sparse_cosine: float
184
+ promptwide_jaccard: float
185
+ promptwide_cosine: float
186
  shared_top_n: int
187
  top_n: int
188
  rows: list[list[object]]
 
401
  payload = f'{text}\0{layer}\0{key}\0{mode}\0{coefficient:.8g}'.encode('utf-8')
402
  return int.from_bytes(hashlib.sha256(payload).digest()[:4], 'big', signed=False)
403
 
404
+ @staticmethod
405
+ def _random_controls(delta: torch.Tensor, seed: int, count: int) -> list[torch.Tensor]:
406
+ if int(count) < 1:
407
+ raise ValueError('Random-control ensemble must contain at least one direction.')
408
+ return [
409
+ normalized_random_control(delta, seed=int(seed) + 104729 * idx)
410
+ for idx in range(int(count))
411
+ ]
412
+
413
+ @staticmethod
414
+ def _random_effect_summary(values: Sequence[float], target_effect: float) -> tuple[float, float, float, float]:
415
+ if not values:
416
+ raise ValueError('Random-control values must not be empty.')
417
+ tensor = torch.tensor([float(x) for x in values], dtype=torch.float64)
418
+ signed_mean = float(tensor.mean().item())
419
+ abs_mean = float(tensor.abs().mean().item())
420
+ std = float(tensor.std(unbiased=False).item())
421
+ empirical_p = float(
422
+ (1 + int((tensor.abs() >= abs(float(target_effect))).sum().item()))
423
+ / (len(values) + 1)
424
+ )
425
+ return signed_mean, abs_mean, std, empirical_p
426
+
427
+ @staticmethod
428
+ def _dict_cosine(a: dict[int, float], b: dict[int, float]) -> float:
429
+ if not a and not b:
430
+ return 1.0
431
+ if not a or not b:
432
+ return 0.0
433
+ dot = sum(float(value) * float(b.get(feature_id, 0.0)) for feature_id, value in a.items())
434
+ norm_a = math.sqrt(sum(float(value) ** 2 for value in a.values()))
435
+ norm_b = math.sqrt(sum(float(value) ** 2 for value in b.values()))
436
+ if norm_a == 0.0 or norm_b == 0.0:
437
+ return 0.0
438
+ return float(dot / (norm_a * norm_b))
439
+
440
+ @staticmethod
441
+ def _dict_jaccard(a: dict[int, float], b: dict[int, float]) -> float:
442
+ set_a = {feature_id for feature_id, value in a.items() if float(value) > 0}
443
+ set_b = {feature_id for feature_id, value in b.items() if float(value) > 0}
444
+ union = set_a | set_b
445
+ return float(len(set_a & set_b) / len(union)) if union else 1.0
446
+
447
+ @staticmethod
448
+ def _max_pool_encoding(encoding: SparseEncoding) -> dict[int, float]:
449
+ indices = encoding.indices.detach().cpu()
450
+ values = encoding.values.detach().float().cpu()
451
+ if indices.ndim == 1:
452
+ indices = indices.unsqueeze(0)
453
+ values = values.unsqueeze(0)
454
+ pooled: dict[int, float] = {}
455
+ for row_ids, row_values in zip(indices.tolist(), values.tolist(), strict=True):
456
+ for feature_id, value in zip(row_ids, row_values, strict=True):
457
+ value = float(value)
458
+ if value <= 0:
459
+ continue
460
+ feature_id = int(feature_id)
461
+ pooled[feature_id] = max(pooled.get(feature_id, 0.0), value)
462
+ return pooled
463
+
464
  @staticmethod
465
  def _encoding_map(encoding: SparseEncoding) -> dict[int, float]:
466
  return {
 
474
  }
475
 
476
  @torch.inference_mode()
477
+ def _analyze_and_pool(
478
+ self,
479
+ text: str,
480
+ layer: int,
481
+ token_index: int = -1,
482
+ top_n: int = 12,
483
+ ) -> tuple[AnalysisResult, dict[int, float]]:
484
  self.ensure_ready(preload_saes=False)
485
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
486
  if int(layer) not in self.settings.layers:
 
491
  self.model(**inputs, use_cache=False)
492
  hidden = bucket['hidden'][0]
493
  idx = self._resolve_index(int(token_index), hidden.shape[0])
 
494
  sae = self.sae_store.get(int(layer))
495
+ all_encoding = sae.encode(hidden)
496
+ encoding = SparseEncoding(
497
+ indices=all_encoding.indices[idx],
498
+ values=all_encoding.values[idx],
499
+ )
500
+ residual = hidden[idx]
501
  reconstruction = sae.decode_sparse(encoding)
502
  metrics = reconstruction_metrics(residual, reconstruction)
503
  metrics['active_features'] = float(encoding.active_count)
 
516
  rows.append(
517
  [rank + 1, feature_id, activation, self.catalog.hint(int(layer), feature_id)]
518
  )
519
+ result = AnalysisResult(
520
  tokens=tokens,
521
  token_index=idx,
522
  layer=int(layer),
 
524
  rows=rows,
525
  metrics=metrics,
526
  )
527
+ return result, self._max_pool_encoding(all_encoding)
528
+
529
+ @torch.inference_mode()
530
+ def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
531
+ result, _ = self._analyze_and_pool(text, layer, token_index, top_n)
532
+ return result
533
 
534
  @torch.inference_mode()
535
  def layer_sweep(self, text: str, token_index: int = -1) -> LayerSweepResult:
 
653
  sae = self.sae_store.get(int(layer))
654
 
655
  target_ids: list[int] = []
 
 
 
656
  capture: dict = {}
657
+ single_baseline_logits: torch.Tensor
658
+ single_baseline_mean: float | None = None
659
 
660
  if target_text.strip():
661
  target_ids = self._target_ids(target_text)
662
+ scoring_inputs = self._append_target(prompt_inputs, target_ids)
663
  with self._capture_hook(int(layer), capture):
664
+ single_baseline_out = self.model(**scoring_inputs, use_cache=False)
665
+ single_baseline_logits = single_baseline_out.logits[0]
666
+ _, single_baseline_mean, _ = sequence_logprob_summary(
667
+ single_baseline_logits,
668
  prompt_length=prompt_len,
669
  target_ids=target_ids,
670
  )
671
  else:
672
+ scoring_inputs = prompt_inputs
673
  with self._capture_hook(int(layer), capture):
674
+ single_baseline_out = self.model(**scoring_inputs, use_cache=False)
675
+ single_baseline_logits = single_baseline_out.logits[0]
676
 
677
  residual = capture['hidden'][0, idx]
678
  encoding = sae.encode(residual)
679
  original_activation = encoding.activation_for(int(feature_id))
680
  spec = InterventionSpec(mode=mode, coefficient=float(coefficient))
681
  delta = residual_delta(sae.decoder_direction(int(feature_id)), original_activation, spec)
682
+ seed = self._control_seed(
683
+ text, int(layer), str(int(feature_id)), mode, float(coefficient)
684
+ )
685
+ random_controls = self._random_controls(
686
+ delta, seed=seed, count=self.settings.live_random_controls
 
 
 
 
687
  )
688
 
689
+ zero = torch.zeros_like(delta)
690
+ all_deltas = torch.stack([zero, delta, *random_controls], dim=0)
691
+ repeated = self._repeat_inputs(scoring_inputs, all_deltas.shape[0])
692
+ with self._batch_delta_hook(int(layer), idx, all_deltas):
693
+ edited = self.model(**repeated, use_cache=False)
694
+
695
+ baseline_logits = edited.logits[0]
696
+ modified_logits = edited.logits[1]
697
+ random_logits = [edited.logits[row] for row in range(2, edited.logits.shape[0])]
698
+ next_idx = prompt_len - 1
699
+ baseline_next_logits = baseline_logits[next_idx]
700
+ modified_next_logits = modified_logits[next_idx]
701
+ random_next_logits = [row[next_idx] for row in random_logits]
702
+
703
+ execution_drift_js = js_divergence_from_logits(
704
+ single_baseline_logits[next_idx], baseline_next_logits
705
+ )
706
+ js = js_divergence_from_logits(baseline_next_logits, modified_next_logits)
707
+ random_js_values = [
708
+ js_divergence_from_logits(baseline_next_logits, logits)
709
+ for logits in random_next_logits
710
+ ]
711
+ random_js_mean, random_js_abs_mean, random_js_std, js_empirical_p = (
712
+ self._random_effect_summary(random_js_values, js)
713
+ )
714
+ # JS divergence is non-negative, so signed and absolute means are identical up to numerical noise.
715
+ random_js_reference = random_js_abs_mean
716
+ js_ratio = abs(js) / max(random_js_reference, 1e-12)
717
+
718
+ baseline_seq = baseline_mean = modified_seq = modified_mean = None
719
+ random_seq_mean = random_mean_signed = random_abs_mean = random_mean_std = None
720
+ sequence_delta = random_sequence_delta = mean_delta = specificity = target_p = None
721
+ target_rows: list[list[object]] = []
722
+ target_tokens: list[str] = []
723
+ bp = mp = rp = None
724
+ execution_drift_mean = None
725
 
726
  if target_ids:
727
+ baseline_seq, baseline_mean, baseline_token_logps = sequence_logprob_summary(
728
+ baseline_logits, prompt_length=prompt_len, target_ids=target_ids
729
+ )
 
 
 
 
 
 
730
  modified_seq, modified_mean, modified_token_logps = sequence_logprob_summary(
731
+ modified_logits, prompt_length=prompt_len, target_ids=target_ids
 
 
732
  )
733
+ random_summaries = [
734
+ sequence_logprob_summary(logits, prompt_length=prompt_len, target_ids=target_ids)
735
+ for logits in random_logits
736
+ ]
737
+ random_seqs = [item[0] for item in random_summaries]
738
+ random_means = [item[1] for item in random_summaries]
739
+ random_token_matrix = [item[2] for item in random_summaries]
740
+ random_token_mean = [
741
+ float(sum(row[token_pos] for row in random_token_matrix) / len(random_token_matrix))
742
+ for token_pos in range(len(target_ids))
743
+ ]
744
+ random_mean_deltas = [float(value - baseline_mean) for value in random_means]
745
+ random_seq_deltas = [float(value - baseline_seq) for value in random_seqs]
746
+ mean_delta = float(modified_mean - baseline_mean)
747
+ sequence_delta = float(modified_seq - baseline_seq)
748
+ random_mean_signed, random_abs_mean, random_mean_std, target_p = self._random_effect_summary(
749
+ random_mean_deltas, mean_delta
750
+ )
751
+ random_sequence_delta = float(sum(random_seq_deltas) / len(random_seq_deltas))
752
+ random_seq_mean = float(sum(random_seqs) / len(random_seqs))
753
+ specificity = abs(mean_delta) / max(random_abs_mean, 1e-12)
754
+ _, single_mean, _ = sequence_logprob_summary(
755
+ single_baseline_logits, prompt_length=prompt_len, target_ids=target_ids
756
+ )
757
+ execution_drift_mean = float(baseline_mean - single_mean)
758
+
759
+ p = torch.softmax(baseline_next_logits.float(), dim=-1)
760
+ q = torch.softmax(modified_next_logits.float(), dim=-1)
761
+ random_probs = [torch.softmax(logits.float(), dim=-1) for logits in random_next_logits]
762
+ first_id = int(target_ids[0])
763
+ bp = float(p[first_id].item())
764
+ mp = float(q[first_id].item())
765
+ rp = float(sum(prob[first_id].item() for prob in random_probs) / len(random_probs))
766
+ target_tokens = [self.tokenizer.decode([int(token_id)]) for token_id in target_ids]
767
+ target_rows = self._target_rows(
768
+ target_ids, baseline_token_logps, modified_token_logps, random_token_mean
769
  )
 
 
 
 
 
 
 
770
 
771
  generation_kwargs = {
772
  'max_new_tokens': min(int(max_new_tokens), self.settings.max_new_tokens),
 
778
  baseline_generation = self.model.generate(**prompt_inputs, **generation_kwargs)
779
  with self._delta_hook(int(layer), idx, delta):
780
  modified_generation = self.model.generate(**prompt_inputs, **generation_kwargs)
 
781
  baseline_ids = baseline_generation.sequences[0, prompt_len:]
782
  modified_ids = modified_generation.sequences[0, prompt_len:]
783
  baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
784
  modified_text = self.tokenizer.decode(modified_ids, skip_special_tokens=True)
785
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
786
  return InterventionResult(
787
  baseline_text=baseline_text,
788
  modified_text=modified_text,
 
790
  delta_activation=float(spec.delta_activation(original_activation)),
791
  perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
792
  js_divergence=float(js),
793
+ random_js_divergence=float(random_js_reference),
794
+ random_js_std=float(random_js_std),
795
  js_specificity_ratio=float(js_ratio),
796
+ js_empirical_p=float(js_empirical_p),
797
+ random_control_count=len(random_controls),
798
+ execution_drift_js=float(execution_drift_js),
799
+ execution_drift_mean_logprob=execution_drift_mean,
800
  target_text=target_text,
801
  target_token_count=len(target_ids),
802
  target_tokens=target_tokens,
 
805
  random_target_prob=rp,
806
  baseline_sequence_logprob=baseline_seq,
807
  modified_sequence_logprob=modified_seq,
808
+ random_sequence_logprob=random_seq_mean,
809
  sequence_logprob_delta=sequence_delta,
810
  random_sequence_logprob_delta=random_sequence_delta,
811
  mean_logprob_delta=mean_delta,
812
+ random_mean_logprob_delta=random_mean_signed,
813
+ random_abs_mean_logprob_delta=random_abs_mean,
814
+ random_mean_logprob_std=random_mean_std,
815
  target_specificity_ratio=specificity,
816
+ target_empirical_p=target_p,
817
  target_token_rows=target_rows,
818
  top_token_rows=self._top_token_rows(
819
+ self.tokenizer, baseline_next_logits, modified_next_logits, k=8
 
 
 
820
  ),
821
  )
822
 
 
832
  ) -> DoseResponseResult:
833
  if not target_text.strip():
834
  raise ValueError('Dose-response requires a target continuation.')
835
+ if not any(abs(float(multiplier) - 1.0) < 1e-12 for multiplier in multipliers):
836
+ raise ValueError('Dose-response multipliers must include 1.0 as the zero-edit reference.')
837
  self.ensure_ready(preload_saes=False)
838
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
839
  prompt_inputs = self._inputs(text)
 
845
 
846
  capture: dict = {}
847
  with self._capture_hook(int(layer), capture):
848
+ single_baseline_out = self.model(**full_inputs, use_cache=False)
849
+ single_baseline_logits = single_baseline_out.logits[0]
850
+ _, single_baseline_mean, _ = sequence_logprob_summary(
851
+ single_baseline_logits, prompt_length=prompt_len, target_ids=target_ids
 
 
 
852
  )
853
  residual = capture['hidden'][0, idx]
854
  encoding = sae.encode(residual)
855
  original_activation = encoding.activation_for(int(feature_id))
856
  direction = sae.decoder_direction(int(feature_id))
857
 
858
+ deltas: list[torch.Tensor] = []
859
+ delta_coefficients: list[float] = []
860
+ norms: list[float] = []
861
  for multiplier in multipliers:
862
  spec = InterventionSpec('scale', float(multiplier))
863
  delta = residual_delta(direction, original_activation, spec)
 
869
  with self._batch_delta_hook(int(layer), idx, torch.stack(deltas, dim=0)):
870
  outputs = self.model(**repeated, use_cache=False)
871
 
872
+ reference_idx = next(
873
+ idx for idx, multiplier in enumerate(multipliers) if abs(float(multiplier) - 1.0) < 1e-12
874
+ )
875
+ reference_logits = outputs.logits[reference_idx]
876
+ baseline_seq, baseline_mean, _ = sequence_logprob_summary(
877
+ reference_logits, prompt_length=prompt_len, target_ids=target_ids
878
+ )
879
+ baseline_next = reference_logits[prompt_len - 1]
880
+ execution_drift_mean = float(baseline_mean - single_baseline_mean)
881
+ execution_drift_js = js_divergence_from_logits(
882
+ single_baseline_logits[prompt_len - 1], baseline_next
883
+ )
884
+
885
  rows: list[list[object]] = []
886
  for row_idx, multiplier in enumerate(multipliers):
887
  modified_logits = outputs.logits[row_idx]
888
  modified_seq, modified_mean, _ = sequence_logprob_summary(
889
+ modified_logits, prompt_length=prompt_len, target_ids=target_ids
 
 
890
  )
891
  rows.append(
892
  [
 
897
  float(modified_mean),
898
  float(modified_mean - baseline_mean),
899
  float(modified_seq - baseline_seq),
900
+ float(js_divergence_from_logits(baseline_next, modified_logits[prompt_len - 1])),
 
 
 
 
 
901
  ]
902
  )
903
  return DoseResponseResult(
904
  feature_activation=float(original_activation),
905
  target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
906
+ execution_drift_mean_logprob=execution_drift_mean,
907
+ execution_drift_js=float(execution_drift_js),
908
  rows=rows,
909
  )
910
 
 
940
 
941
  capture: dict = {}
942
  with self._capture_hook(int(layer), capture):
943
+ single_baseline_out = self.model(**full_inputs, use_cache=False)
944
+ single_baseline_logits = single_baseline_out.logits[0]
945
+ _, single_baseline_mean, _ = sequence_logprob_summary(
946
+ single_baseline_logits, prompt_length=prompt_len, target_ids=target_ids
947
+ )
948
  residual = capture['hidden'][0, idx]
949
  encoding = sae.encode(residual)
950
  activations = [encoding.activation_for(feature_id) for feature_id in ids]
951
  directions = torch.stack([sae.decoder_direction(feature_id) for feature_id in ids], dim=0)
952
  spec = InterventionSpec(mode, float(coefficient))
953
  delta, coefficient_deltas = joint_residual_delta(directions, activations, spec)
954
+ seed = self._control_seed(
955
+ text, int(layer), ','.join(str(x) for x in ids), mode, float(coefficient)
956
+ )
957
+ controls = self._random_controls(
958
+ delta, seed=seed, count=self.settings.live_random_controls
 
 
 
 
959
  )
960
 
961
+ zero = torch.zeros_like(delta)
962
+ all_deltas = torch.stack([zero, delta, *controls], dim=0)
963
+ repeated = self._repeat_inputs(full_inputs, all_deltas.shape[0])
964
+ with self._batch_delta_hook(int(layer), idx, all_deltas):
965
  outputs = self.model(**repeated, use_cache=False)
966
 
967
+ baseline_logits = outputs.logits[0]
968
+ modified_logits = outputs.logits[1]
969
+ random_logits = [outputs.logits[row] for row in range(2, outputs.logits.shape[0])]
970
  baseline_seq, baseline_mean, baseline_tokens = sequence_logprob_summary(
971
+ baseline_logits, prompt_length=prompt_len, target_ids=target_ids
 
 
972
  )
973
  modified_seq, modified_mean, modified_tokens = sequence_logprob_summary(
974
+ modified_logits, prompt_length=prompt_len, target_ids=target_ids
 
 
 
 
 
 
 
975
  )
976
+ random_summaries = [
977
+ sequence_logprob_summary(logits, prompt_length=prompt_len, target_ids=target_ids)
978
+ for logits in random_logits
979
+ ]
980
+ random_seqs = [item[0] for item in random_summaries]
981
+ random_means = [item[1] for item in random_summaries]
982
+ random_token_matrix = [item[2] for item in random_summaries]
983
+ random_token_mean = [
984
+ float(sum(row[token_pos] for row in random_token_matrix) / len(random_token_matrix))
985
+ for token_pos in range(len(target_ids))
986
+ ]
987
  mean_delta = float(modified_mean - baseline_mean)
988
+ sequence_delta = float(modified_seq - baseline_seq)
989
+ random_mean_deltas = [float(value - baseline_mean) for value in random_means]
990
+ random_seq_deltas = [float(value - baseline_seq) for value in random_seqs]
991
+ random_mean_signed, random_abs_mean, random_mean_std, target_p = self._random_effect_summary(
992
+ random_mean_deltas, mean_delta
993
+ )
994
  next_idx = prompt_len - 1
995
  js = js_divergence_from_logits(baseline_logits[next_idx], modified_logits[next_idx])
996
+ random_js_values = [
997
+ js_divergence_from_logits(baseline_logits[next_idx], logits[next_idx])
998
+ for logits in random_logits
999
+ ]
1000
+ _, random_js_abs_mean, random_js_std, js_p = self._random_effect_summary(
1001
+ random_js_values, js
1002
+ )
1003
+ execution_drift_mean = float(baseline_mean - single_baseline_mean)
1004
+ execution_drift_js = js_divergence_from_logits(
1005
+ single_baseline_logits[next_idx], baseline_logits[next_idx]
1006
+ )
1007
 
1008
  feature_rows = [
1009
  [
 
1013
  self.catalog.hint(int(layer), feature_id),
1014
  ]
1015
  for feature_id, activation, delta_coefficient in zip(
1016
+ ids, activations, coefficient_deltas, strict=True
 
 
 
1017
  )
1018
  ]
1019
  return FeatureSetResult(
 
1021
  feature_rows=feature_rows,
1022
  perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
1023
  js_divergence=float(js),
1024
+ random_js_divergence=float(random_js_abs_mean),
1025
+ random_js_std=float(random_js_std),
1026
+ js_specificity_ratio=float(abs(js) / max(random_js_abs_mean, 1e-12)),
1027
+ js_empirical_p=float(js_p),
1028
+ random_control_count=len(controls),
1029
+ execution_drift_mean_logprob=execution_drift_mean,
1030
+ execution_drift_js=float(execution_drift_js),
1031
  baseline_sequence_logprob=float(baseline_seq),
1032
  modified_sequence_logprob=float(modified_seq),
1033
+ random_sequence_logprob=float(sum(random_seqs) / len(random_seqs)),
1034
+ sequence_logprob_delta=sequence_delta,
1035
+ random_sequence_logprob_delta=float(sum(random_seq_deltas) / len(random_seq_deltas)),
1036
  mean_logprob_delta=mean_delta,
1037
+ random_mean_logprob_delta=random_mean_signed,
1038
+ random_abs_mean_logprob_delta=random_abs_mean,
1039
+ random_mean_logprob_std=float(random_mean_std),
1040
+ target_specificity_ratio=float(abs(mean_delta) / max(random_abs_mean, 1e-12)),
1041
+ target_empirical_p=float(target_p),
1042
  target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
1043
  target_token_rows=self._target_rows(
1044
+ target_ids, baseline_tokens, modified_tokens, random_token_mean
 
 
 
1045
  ),
1046
  )
1047
 
 
1054
  target_text: str,
1055
  sizes: Sequence[int] = (1, 3, 5),
1056
  ) -> FeatureSetSweepResult:
1057
+ """Jointly ablate the strongest k active features and compare to random ensembles."""
1058
  if not target_text.strip():
1059
  raise ValueError('Feature-set size sweep requires a target continuation.')
1060
  self.ensure_ready(preload_saes=False)
 
1068
 
1069
  capture: dict = {}
1070
  with self._capture_hook(int(layer), capture):
1071
+ single_baseline_out = self.model(**full_inputs, use_cache=False)
1072
+ single_baseline_logits = single_baseline_out.logits[0]
1073
+ _, single_baseline_mean, _ = sequence_logprob_summary(
1074
+ single_baseline_logits, prompt_length=prompt_len, target_ids=target_ids
1075
+ )
1076
  residual = capture['hidden'][0, idx]
1077
  encoding = sae.encode(residual)
1078
  active_ids = [
 
1088
  if not valid_sizes:
1089
  raise ValueError('Not enough active features for the requested set sizes.')
1090
 
1091
+ condition_deltas: list[torch.Tensor] = [torch.zeros_like(residual)]
1092
+ metadata: list[tuple[int, str, list[int], float]] = []
 
 
1093
  for size in valid_sizes:
1094
  selected = active_ids[:size]
1095
  activations = [encoding.activation_for(feature_id) for feature_id in selected]
1096
  directions = torch.stack([sae.decoder_direction(feature_id) for feature_id in selected])
1097
  delta, _ = joint_residual_delta(
1098
+ directions, activations, InterventionSpec('ablate', 0.0)
 
 
1099
  )
1100
+ norm = float(torch.linalg.vector_norm(delta.float()).item())
1101
+ condition_deltas.append(delta)
1102
+ metadata.append((size, 'sae', selected, norm))
1103
+ seed = self._control_seed(text, int(layer), f'top-{size}', 'ablate_set', 0.0)
1104
+ controls = self._random_controls(
1105
+ delta, seed=seed, count=self.settings.live_random_controls
 
 
 
1106
  )
1107
+ for control_idx, control in enumerate(controls):
1108
+ condition_deltas.append(control)
1109
+ metadata.append((size, f'random_{control_idx}', selected, norm))
 
1110
 
1111
+ all_deltas = torch.stack(condition_deltas, dim=0)
1112
  repeated = self._repeat_inputs(full_inputs, all_deltas.shape[0])
1113
  with self._batch_delta_hook(int(layer), idx, all_deltas):
1114
  outputs = self.model(**repeated, use_cache=False)
1115
 
1116
+ baseline_logits = outputs.logits[0]
1117
  baseline_seq, baseline_mean, _ = sequence_logprob_summary(
1118
+ baseline_logits, prompt_length=prompt_len, target_ids=target_ids
 
 
1119
  )
1120
  baseline_next = baseline_logits[prompt_len - 1]
1121
+ execution_drift_mean = float(baseline_mean - single_baseline_mean)
1122
+ execution_drift_js = js_divergence_from_logits(
1123
+ single_baseline_logits[prompt_len - 1], baseline_next
1124
+ )
1125
+
1126
+ grouped: dict[int, dict[str, object]] = {
1127
+ size: {'features': active_ids[:size], 'norm': None, 'sae': None, 'random': []}
1128
+ for size in valid_sizes
1129
+ }
1130
+ for output_idx, meta in enumerate(metadata, start=1):
1131
+ size, kind, selected, norm = meta
1132
+ logits = outputs.logits[output_idx]
1133
+ seq_logp, mean_logp, _ = sequence_logprob_summary(
1134
+ logits, prompt_length=prompt_len, target_ids=target_ids
1135
+ )
1136
+ item = {
1137
+ 'seq': float(seq_logp),
1138
+ 'mean': float(mean_logp),
1139
+ 'js': float(js_divergence_from_logits(baseline_next, logits[prompt_len - 1])),
1140
+ }
1141
+ grouped[size]['norm'] = norm
1142
+ grouped[size]['features'] = selected
1143
+ if kind == 'sae':
1144
+ grouped[size]['sae'] = item
1145
+ else:
1146
+ grouped[size]['random'].append(item)
1147
+
1148
  rows: list[list[object]] = []
1149
+ for size in valid_sizes:
1150
+ group = grouped[size]
1151
+ sae_item = group['sae']
1152
+ random_items = group['random']
1153
+ assert isinstance(sae_item, dict)
1154
+ assert isinstance(random_items, list) and random_items
1155
+ sae_mean_delta = float(sae_item['mean'] - baseline_mean)
1156
+ sae_seq_delta = float(sae_item['seq'] - baseline_seq)
1157
+ random_mean_deltas = [float(item['mean'] - baseline_mean) for item in random_items]
1158
+ random_js_values = [float(item['js']) for item in random_items]
1159
+ random_signed, random_abs, random_std, target_p = self._random_effect_summary(
1160
+ random_mean_deltas, sae_mean_delta
1161
  )
1162
+ _, random_js_abs, random_js_std, js_p = self._random_effect_summary(
1163
+ random_js_values, float(sae_item['js'])
 
 
1164
  )
 
 
 
 
1165
  rows.append(
1166
  [
1167
  int(size),
1168
+ ', '.join(str(x) for x in group['features']),
1169
+ float(group['norm']),
1170
  float(baseline_mean),
1171
+ float(sae_item['mean']),
1172
+ sae_mean_delta,
1173
+ random_signed,
1174
+ random_abs,
1175
+ random_std,
1176
+ float(abs(sae_mean_delta) / max(random_abs, 1e-12)),
1177
+ float(target_p),
1178
+ sae_seq_delta,
1179
+ float(sae_item['js']),
1180
+ random_js_abs,
1181
+ random_js_std,
1182
+ float(js_p),
1183
  ]
1184
  )
1185
  return FeatureSetSweepResult(
1186
  target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
1187
+ random_control_count=self.settings.live_random_controls,
1188
+ execution_drift_mean_logprob=execution_drift_mean,
1189
+ execution_drift_js=float(execution_drift_js),
1190
  rows=rows,
1191
  )
1192
 
1193
+ @torch.inference_mode()
1194
+ def feature_interaction_test(
1195
+ self,
1196
+ text: str,
1197
+ layer: int,
1198
+ token_index: int,
1199
+ feature_ids: Sequence[int],
1200
+ target_text: str,
1201
+ ) -> FeatureInteractionResult:
1202
+ """Compare individual ablations with their joint ablation to measure non-additivity."""
1203
+ if not target_text.strip():
1204
+ raise ValueError('Feature interaction testing requires a target continuation.')
1205
+ ids = list(dict.fromkeys(int(x) for x in feature_ids))
1206
+ if len(ids) < 2:
1207
+ raise ValueError('Select at least two features for the interaction decomposition.')
1208
+ if len(ids) > 5:
1209
+ raise ValueError('Select at most five features for the live interaction decomposition.')
1210
+ self.ensure_ready(preload_saes=False)
1211
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
1212
+ prompt_inputs = self._inputs(text)
1213
+ prompt_len = int(prompt_inputs['input_ids'].shape[1])
1214
+ idx = self._resolve_index(int(token_index), prompt_len)
1215
+ target_ids = self._target_ids(target_text)
1216
+ full_inputs = self._append_target(prompt_inputs, target_ids)
1217
+ sae = self.sae_store.get(int(layer))
1218
+
1219
+ capture: dict = {}
1220
+ with self._capture_hook(int(layer), capture):
1221
+ single_baseline_out = self.model(**full_inputs, use_cache=False)
1222
+ single_logits = single_baseline_out.logits[0]
1223
+ _, single_mean, _ = sequence_logprob_summary(
1224
+ single_logits, prompt_length=prompt_len, target_ids=target_ids
1225
+ )
1226
+ residual = capture['hidden'][0, idx]
1227
+ encoding = sae.encode(residual)
1228
+ activations = [encoding.activation_for(feature_id) for feature_id in ids]
1229
+ directions = [sae.decoder_direction(feature_id) for feature_id in ids]
1230
+ individual_deltas = [
1231
+ residual_delta(direction, activation, InterventionSpec('ablate', 0.0))
1232
+ for direction, activation in zip(directions, activations, strict=True)
1233
+ ]
1234
+ joint_delta = torch.stack(individual_deltas, dim=0).sum(dim=0)
1235
+ all_deltas = torch.stack([torch.zeros_like(joint_delta), *individual_deltas, joint_delta], dim=0)
1236
+ repeated = self._repeat_inputs(full_inputs, all_deltas.shape[0])
1237
+ with self._batch_delta_hook(int(layer), idx, all_deltas):
1238
+ outputs = self.model(**repeated, use_cache=False)
1239
+
1240
+ baseline_logits = outputs.logits[0]
1241
+ baseline_seq, baseline_mean, _ = sequence_logprob_summary(
1242
+ baseline_logits, prompt_length=prompt_len, target_ids=target_ids
1243
+ )
1244
+ baseline_next = baseline_logits[prompt_len - 1]
1245
+ rows: list[list[object]] = []
1246
+ individual_mean_deltas: list[float] = []
1247
+ for feature_idx, feature_id in enumerate(ids, start=1):
1248
+ logits = outputs.logits[feature_idx]
1249
+ seq_logp, mean_logp, _ = sequence_logprob_summary(
1250
+ logits, prompt_length=prompt_len, target_ids=target_ids
1251
+ )
1252
+ delta_mean = float(mean_logp - baseline_mean)
1253
+ individual_mean_deltas.append(delta_mean)
1254
+ rows.append(
1255
+ [
1256
+ f'Feature {feature_id}',
1257
+ str(feature_id),
1258
+ float(activations[feature_idx - 1]),
1259
+ float(torch.linalg.vector_norm(individual_deltas[feature_idx - 1].float()).item()),
1260
+ delta_mean,
1261
+ float(seq_logp - baseline_seq),
1262
+ float(js_divergence_from_logits(baseline_next, logits[prompt_len - 1])),
1263
+ ]
1264
+ )
1265
+ joint_logits = outputs.logits[len(ids) + 1]
1266
+ joint_seq, joint_mean, _ = sequence_logprob_summary(
1267
+ joint_logits, prompt_length=prompt_len, target_ids=target_ids
1268
+ )
1269
+ joint_mean_delta = float(joint_mean - baseline_mean)
1270
+ rows.append(
1271
+ [
1272
+ 'Joint ablation',
1273
+ ', '.join(str(x) for x in ids),
1274
+ float(sum(activations)),
1275
+ float(torch.linalg.vector_norm(joint_delta.float()).item()),
1276
+ joint_mean_delta,
1277
+ float(joint_seq - baseline_seq),
1278
+ float(js_divergence_from_logits(baseline_next, joint_logits[prompt_len - 1])),
1279
+ ]
1280
+ )
1281
+ additive_expected = float(sum(individual_mean_deltas))
1282
+ interaction_excess = float(joint_mean_delta - additive_expected)
1283
+ scale = max(sum(abs(value) for value in individual_mean_deltas), 1e-12)
1284
+ return FeatureInteractionResult(
1285
+ feature_ids=ids,
1286
+ target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
1287
+ rows=rows,
1288
+ additive_expected_mean_delta=additive_expected,
1289
+ joint_mean_delta=joint_mean_delta,
1290
+ interaction_excess_mean_delta=interaction_excess,
1291
+ normalized_interaction=float(interaction_excess / scale),
1292
+ execution_drift_mean_logprob=float(baseline_mean - single_mean),
1293
+ )
1294
+
1295
  @torch.inference_mode()
1296
  def compare_paraphrases(
1297
  self,
 
1304
  ) -> ParaphraseResult:
1305
  if not text_a.strip() or not text_b.strip():
1306
  raise ValueError('Enter both the original prompt and a paraphrase.')
1307
+ a, pooled_a = self._analyze_and_pool(text_a, int(layer), int(token_index_a), max(int(top_n), 12))
1308
+ b, pooled_b = self._analyze_and_pool(text_b, int(layer), int(token_index_b), max(int(top_n), 12))
1309
 
1310
  map_a = self._encoding_map(a.features)
1311
  map_b = self._encoding_map(b.features)
 
1314
  union = set_a | set_b
1315
  jaccard = len(set_a & set_b) / len(union) if union else 1.0
1316
  cosine = sparse_topk_cosine(
1317
+ a.features.indices, a.features.values, b.features.indices, b.features.values
 
 
 
1318
  )
1319
+ promptwide_jaccard = self._dict_jaccard(pooled_a, pooled_b)
1320
+ promptwide_cosine = self._dict_cosine(pooled_a, pooled_b)
1321
 
1322
  top_ids_a = [int(row[1]) for row in a.rows[: int(top_n)]]
1323
  top_ids_b = [int(row[1]) for row in b.rows[: int(top_n)]]
 
1330
  vb = float(map_b.get(feature_id, 0.0))
1331
  status = 'shared' if va > 0 and vb > 0 else ('original only' if va > 0 else 'paraphrase only')
1332
  rows.append(
1333
+ [feature_id, va, vb, status, self.catalog.hint(int(layer), feature_id)]
 
 
 
 
 
 
1334
  )
1335
  chart_rows.append([str(feature_id), 'Original', va])
1336
  chart_rows.append([str(feature_id), 'Paraphrase', vb])
 
1343
  token_index_b=b.token_index,
1344
  topk_jaccard=float(jaccard),
1345
  sparse_cosine=float(cosine),
1346
+ promptwide_jaccard=float(promptwide_jaccard),
1347
+ promptwide_cosine=float(promptwide_cosine),
1348
  shared_top_n=int(shared_top_n),
1349
  top_n=int(top_n),
1350
  rows=rows,
1351
  chart_rows=chart_rows,
1352
  )
1353
 
1354
+ def _contrast_prompt_rows(self, prompts_per_concept: int) -> list[dict]:
1355
+ data_path = Path(__file__).resolve().parents[1] / 'data' / 'prompts.jsonl'
1356
+ rows = [
1357
+ json.loads(line)
1358
+ for line in data_path.read_text(encoding='utf-8').splitlines()
1359
+ if line.strip()
1360
+ ]
1361
+ selected: list[dict] = []
1362
+ by_concept: dict[str, list[dict]] = {}
1363
+ for row in rows:
1364
+ # Use one wording per paraphrase pair so the live contrast is not dominated by near-duplicates.
1365
+ if int(row.get('variant', 0)) != 0:
1366
+ continue
1367
+ by_concept.setdefault(str(row['concept']), []).append(row)
1368
+ for concept in sorted(by_concept):
1369
+ selected.extend(by_concept[concept][: int(prompts_per_concept)])
1370
+ return selected
1371
+
1372
+ @torch.inference_mode()
1373
+ def concept_contrast_scan(
1374
+ self,
1375
+ feature_id: int,
1376
+ layer: int,
1377
+ prompts_per_concept: int | None = None,
1378
+ ) -> ConceptContrastResult:
1379
+ """Measure one SAE feature across a small balanced concept contrast batch."""
1380
+ self.ensure_ready(preload_saes=False)
1381
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
1382
+ n = int(prompts_per_concept or self.settings.contrast_prompts_per_concept)
1383
+ if n < 1 or n > 8:
1384
+ raise ValueError('Contrast prompts per concept must be between 1 and 8.')
1385
+ rows = self._contrast_prompt_rows(n)
1386
+ if not rows:
1387
+ raise RuntimeError('No controlled contrast prompts are available.')
1388
+ texts = [str(row['text']) for row in rows]
1389
+ batch = self.tokenizer(
1390
+ texts,
1391
+ return_tensors='pt',
1392
+ padding=True,
1393
+ truncation=True,
1394
+ max_length=self.settings.max_prompt_tokens,
1395
+ )
1396
+ batch = {key: value.to(self.device) for key, value in batch.items()}
1397
+ capture: dict = {}
1398
+ with self._capture_hook(int(layer), capture):
1399
+ self.model(**batch, use_cache=False)
1400
+ # Left padding means the last position is the final real token for every prompt.
1401
+ residuals = capture['hidden'][:, -1, :]
1402
+ sae = self.sae_store.get(int(layer))
1403
+ encoding = sae.encode(residuals)
1404
+ mask = encoding.indices == int(feature_id)
1405
+ activations = torch.where(mask, encoding.values, torch.zeros_like(encoding.values)).sum(dim=-1)
1406
+ values = activations.detach().float().cpu().tolist()
1407
+
1408
+ grouped: dict[str, list[float]] = {}
1409
+ for row, value in zip(rows, values, strict=True):
1410
+ grouped.setdefault(str(row['concept']), []).append(float(value))
1411
+ table_rows: list[list[object]] = []
1412
+ for concept in sorted(grouped):
1413
+ vals = grouped[concept]
1414
+ tensor = torch.tensor(vals, dtype=torch.float64)
1415
+ active = [value for value in vals if value > 0]
1416
+ median = float(torch.median(tensor).item())
1417
+ table_rows.append(
1418
+ [
1419
+ concept,
1420
+ len(vals),
1421
+ float(tensor.mean().item()),
1422
+ median,
1423
+ float(len(active) / len(vals)),
1424
+ float(max(vals) if vals else 0.0),
1425
+ ]
1426
+ )
1427
+ table_rows.sort(key=lambda row: (float(row[2]), float(row[4])), reverse=True)
1428
+ leader = str(table_rows[0][0])
1429
+ first = float(table_rows[0][2])
1430
+ second = float(table_rows[1][2]) if len(table_rows) > 1 else 0.0
1431
+ ratio = first / max(second, 1e-12) if first > 0 else 0.0
1432
+ chart_rows = [[str(row[0]), float(row[2])] for row in table_rows]
1433
+ return ConceptContrastResult(
1434
+ feature_id=int(feature_id),
1435
+ layer=int(layer),
1436
+ prompts_per_concept=n,
1437
+ rows=table_rows,
1438
+ chart_rows=chart_rows,
1439
+ leading_concept=leader,
1440
+ leading_ratio=float(ratio),
1441
+ )
1442
+
1443
 
1444
  RUNTIME = FeatureLensRuntime()
1445
 
pyproject.toml CHANGED
@@ -1,6 +1,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
 
 
1
  [project]
2
  name = "featurelens"
3
+ version = "0.4.0"
4
  description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
5
  requires-python = ">=3.10"
6
 
research_config.json CHANGED
@@ -62,6 +62,20 @@
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
  }
 
62
  "scale"
63
  ],
64
  "feature_set_negative_control": "norm-matched random residual direction",
65
+ "dose_response_execution": "all scale conditions share the 1x batched no-edit reference",
66
+ "feature_set_sweep_execution": "zero-edit reference + SAE edits + random-control ensembles evaluated in one batched forward",
67
+ "live_random_controls": 8,
68
+ "offline_random_controls_default": 8,
69
+ "control_reference": "batched zero-edit residual row",
70
+ "paraphrase_promptwide_pooling": "max activation per SAE feature across all prompt tokens",
71
+ "live_features_v0_4": [
72
+ "batch_context_null_reference",
73
+ "random_control_ensemble",
74
+ "individual_vs_joint_interaction_decomposition",
75
+ "promptwide_paraphrase_robustness",
76
+ "controlled_concept_contrast_scan",
77
+ "copy_tables_with_headers"
78
+ ],
79
+ "concept_contrast_prompts_per_concept": 4,
80
+ "interaction_feature_limit": 5
81
  }
scripts/release_check.py CHANGED
@@ -77,22 +77,28 @@ def check_config(config: dict) -> None:
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
 
@@ -151,17 +157,20 @@ def check_readme() -> None:
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:
@@ -178,7 +187,8 @@ def main() -> None:
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__':
 
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
+ 'live_random_controls': 8,
81
+ 'offline_random_controls_default': 8,
82
+ 'concept_contrast_prompts_per_concept': 4,
83
+ 'interaction_feature_limit': 5,
84
  }
85
  for key, value in expected.items():
86
  if config.get(key) != value:
87
  raise SystemExit(f'Unexpected {key}: {config.get(key)!r}. Expected {value!r}.')
88
 
89
+ required_live_v04 = {
90
+ 'batch_context_null_reference',
91
+ 'random_control_ensemble',
92
+ 'individual_vs_joint_interaction_decomposition',
93
+ 'promptwide_paraphrase_robustness',
94
+ 'controlled_concept_contrast_scan',
95
+ 'copy_tables_with_headers',
96
  }
97
+ actual_live_v04 = set(config.get('live_features_v0_4', []))
98
+ if actual_live_v04 != required_live_v04:
99
  raise SystemExit(
100
+ 'research_config.json live_features_v0_4 mismatch: '
101
+ f'{sorted(actual_live_v04)}'
102
  )
103
 
104
 
 
157
  'full-continuation',
158
  'feature-set',
159
  'paraphrase',
160
+ 'random ensemble',
161
+ 'batched zero-edit',
162
+ 'concept contrast',
163
+ 'non-additivity',
164
  ]
165
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
166
  if missing:
167
+ raise SystemExit(f'README.md is missing required v0.4 content: {missing}')
168
 
169
 
170
  def check_pyproject() -> None:
171
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
172
+ if 'version = "0.4.0"' not in text:
173
+ raise SystemExit('pyproject.toml must declare version 0.4.0.')
174
 
175
 
176
  def main() -> None:
 
187
  print(f' causal tasks: {len(causal)}')
188
  print(f' layers: {config["layers"]}')
189
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
190
+ print(f' random controls: {config["live_random_controls"]}')
191
+ print(' release: v0.4.0')
192
 
193
 
194
  if __name__ == '__main__':
tests/test_report_controls.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import pandas as pd
6
+
7
+ from experiments.make_report import _paired_stats
8
+
9
+
10
+ def test_paired_stats_uses_random_control_ensemble_mean_absolute_effect() -> None:
11
+ frame = pd.DataFrame(
12
+ [
13
+ {'task_id': 'a', 'intervention': 'ablate', 'condition': 'sae_feature', 'target_mean_logprob_delta': 0.30},
14
+ {'task_id': 'a', 'intervention': 'ablate', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': 0.10},
15
+ {'task_id': 'a', 'intervention': 'ablate', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': -0.20},
16
+ {'task_id': 'b', 'intervention': 'ablate', 'condition': 'sae_feature', 'target_mean_logprob_delta': -0.40},
17
+ {'task_id': 'b', 'intervention': 'ablate', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': 0.05},
18
+ {'task_id': 'b', 'intervention': 'ablate', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': -0.15},
19
+ ]
20
+ )
21
+ stats = _paired_stats(
22
+ frame,
23
+ index=['task_id', 'intervention'],
24
+ sae_condition='sae_feature',
25
+ random_condition='random_norm_matched',
26
+ seed=7,
27
+ )
28
+ # Per-task random absolute means are 0.15 and 0.10 -> overall 0.125.
29
+ assert math.isclose(float(stats['random_abs']), 0.125, rel_tol=1e-9)
30
+ assert math.isclose(float(stats['sae_abs']), 0.35, rel_tol=1e-9)
31
+ assert int(stats['n_pairs']) == 2
32
+
33
+
34
+ def test_paired_stats_ignores_missing_random_pairs() -> None:
35
+ frame = pd.DataFrame(
36
+ [
37
+ {'task_id': 'a', 'intervention': 'ablate', 'condition': 'sae_feature', 'target_mean_logprob_delta': 0.30},
38
+ {'task_id': 'a', 'intervention': 'ablate', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': 0.10},
39
+ {'task_id': 'b', 'intervention': 'ablate', 'condition': 'sae_feature', 'target_mean_logprob_delta': 0.90},
40
+ ]
41
+ )
42
+ stats = _paired_stats(
43
+ frame,
44
+ index=['task_id', 'intervention'],
45
+ sae_condition='sae_feature',
46
+ random_condition='random_norm_matched',
47
+ seed=8,
48
+ )
49
+ assert int(stats['n_pairs']) == 1
50
+ assert math.isclose(float(stats['sae_abs']), 0.30, rel_tol=1e-9)