ArchitSharma commited on
Commit
9d24374
·
1 Parent(s): 0b6bf61

Initial FeatureLens v0.1.0

Browse files
.github/workflows/ci.yml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: '3.12'
15
+ - name: Install lightweight test dependencies
16
+ run: |
17
+ python -m pip install --upgrade pip
18
+ pip install "torch>=2.8,<2.12" "huggingface_hub>=0.34,<2" "numpy>=2,<3" "pytest>=8.3,<10" "ruff>=0.9,<1"
19
+ - name: Ruff
20
+ run: python -m ruff check featurelens tests scripts experiments/split.py experiments/build_dataset.py
21
+ - name: Unit tests
22
+ run: python -m pytest -q
23
+ - name: Compile
24
+ run: python -m compileall -q app.py featurelens experiments scripts
25
+ - name: Release check
26
+ run: python scripts/release_check.py
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ .env
7
+ .DS_Store
8
+ artifacts/activations/
9
+ artifacts/*.npy
10
+ artifacts/*.npz
11
+ *.pt
12
+ *.safetensors
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Archit Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,13 +1,193 @@
1
  ---
2
  title: FeatureLens
3
- emoji: 📉
4
- colorFrom: yellow
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.24.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: FeatureLens
3
+ emoji: 🔬
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
+ python_version: "3.12"
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
  ---
12
 
13
+ # FeatureLens Causal Interpretability Workbench
14
+
15
+ **FeatureLens asks one concrete question:**
16
+
17
+ > Do sparse features that predict a concept also causally influence model behaviour?
18
+
19
+ It uses **Qwen3-1.7B-Base** with the official **Qwen-Scope residual-stream sparse autoencoders (SAEs)**. The live Hugging Face Space inspects feature activations and performs causal residual-stream interventions; the offline pipeline measures whether predictive features remain predictive on held-out paraphrases and whether manipulating them actually changes downstream behavior.
20
+
21
+ This is deliberately **not** a clone of an SAE viewer and does not depend on thesis code or thesis results.
22
+
23
+ ## What is different from a feature viewer?
24
+
25
+ FeatureLens separates three claims that are often blurred together:
26
+
27
+ 1. **Representation:** does an SAE reconstruct the residual stream reasonably well?
28
+ 2. **Prediction:** does a feature distinguish a controlled concept on held-out prompts?
29
+ 3. **Causation:** does changing that feature coefficient alter downstream token probabilities or generation more than a norm-matched random perturbation?
30
+
31
+ A feature can score highly on (2) and weakly on (3). FeatureLens treats that as a valid result rather than a failure.
32
+
33
+ ## Live workbench
34
+
35
+ The Gradio app supports:
36
+
37
+ - prompt input and token-level inspection;
38
+ - early / middle / late residual layers: **4, 14, 26**;
39
+ - strongest TopK SAE features at a selected token;
40
+ - reconstruction cosine and normalized MSE;
41
+ - feature **ablation**, **scaling**, or **injection**;
42
+ - baseline vs modified greedy generation;
43
+ - first-token probability table and Jensen-Shannon divergence;
44
+ - optional target-continuation probability / log-probability delta;
45
+ - optional concept hints loaded from real offline benchmark artifacts.
46
+
47
+ ### Reconstruction-preserving causal edit
48
+
49
+ Qwen-Scope provides encoder and decoder weights. Let the original residual be `h`, the selected sparse feature activation be `z_i`, and the feature decoder direction be `d_i`.
50
+
51
+ FeatureLens patches the **original** residual:
52
+
53
+ ```text
54
+ ablate: h' = h - z_i d_i
55
+ scale α: h' = h + (α - 1) z_i d_i
56
+ inject δ: h' = h + δ d_i
57
+ ```
58
+
59
+ It does **not** replace `h` with the SAE reconstruction. This keeps SAE reconstruction error out of the causal perturbation.
60
+
61
+ ## Offline experiment
62
+
63
+ The repository ships a controlled benchmark with:
64
+
65
+ - **224 discovery prompts** across 7 concepts;
66
+ - **112 paraphrase pairs** kept together during splitting;
67
+ - concepts: code, mathematics, positive sentiment, negative sentiment, French, factual entities, uncertainty;
68
+ - **28 separate causal completion tasks**;
69
+ - one forward pass captures all configured residual layers for each batch;
70
+ - TopK sparse feature activations and dense residuals are saved separately.
71
+
72
+ The evaluation computes:
73
+
74
+ - SAE reconstruction cosine / NMSE;
75
+ - actual active-feature count / sparsity;
76
+ - held-out feature/concept AUROC and F1;
77
+ - paraphrase TopK Jaccard and sparse-activation cosine;
78
+ - layer-wise multinomial linear probes on dense residual states;
79
+ - selected-feature ablation and 2× amplification;
80
+ - target first-token probability, log-probability, rank and JS divergence;
81
+ - **norm-matched random residual-direction controls**.
82
+
83
+ Feature selection uses the **training split**. Held-out AUROC/F1 are reported afterward. Paraphrases from the same pair never cross the train/test boundary.
84
+
85
+ ## Run the full benchmark
86
+
87
+ A CUDA machine is strongly recommended. The default pipeline downloads Qwen3-1.7B-Base plus the three selected Qwen-Scope layer checkpoints.
88
+
89
+ ```bash
90
+ python -m venv .venv
91
+ source .venv/bin/activate
92
+ pip install -r requirements.txt
93
+ python experiments/run_all.py
94
+ ```
95
+
96
+ Outputs are materialized under `artifacts/`:
97
+
98
+ ```text
99
+ artifacts/
100
+ ├── activations/
101
+ │ ├── metadata.json
102
+ │ ├── residuals_layer4.npy
103
+ │ ├── features_layer4.npz
104
+ │ └── ...
105
+ ├── feature_catalog.csv
106
+ ├── layer_metrics.csv
107
+ ├── stability.csv
108
+ ├── causal_results.csv
109
+ ├── summary.json
110
+ ├── report.md
111
+ └── figures/
112
+ ```
113
+
114
+ `report.md` and `summary.json` are generated from the measured results. The code contains no fabricated benchmark numbers.
115
+
116
+ ## Hugging Face ZeroGPU deployment
117
+
118
+ FeatureLens is intentionally a **Gradio SDK Space**, not a Docker Space, because ZeroGPU currently supports Gradio SDK Spaces.
119
+
120
+ 1. Create a new **Gradio** Space.
121
+ 2. In the Space hardware settings, select **ZeroGPU**.
122
+ 3. Push this repository as-is.
123
+ 4. No API key is required; both Qwen repositories are public.
124
+ 5. On first cold start, the Space downloads Qwen3-1.7B-Base and only SAE layers **4, 14 and 26**, not all 28 SAE files.
125
+
126
+ The app uses the `spaces.GPU` decorator when available and falls back to ordinary execution locally. On a Hugging Face Space it eagerly places the model and selected SAEs on the ZeroGPU CUDA-emulation device at startup, matching Hugging Face's recommended loading pattern.
127
+
128
+ Useful environment overrides:
129
+
130
+ ```text
131
+ FEATURELENS_MODEL_ID=Qwen/Qwen3-1.7B-Base
132
+ FEATURELENS_SAE_REPO=Qwen/SAE-Res-Qwen3-1.7B-Base-W32K-L0_50
133
+ FEATURELENS_LAYERS=4,14,26
134
+ FEATURELENS_EAGER_LOAD=1
135
+ FEATURELENS_SAE_DTYPE=float16
136
+ FEATURELENS_MAX_NEW_TOKENS=32
137
+ ```
138
+
139
+ For offline benchmarking, use `FEATURELENS_SAE_DTYPE=float32` (the scripts already use FP32 SAE weights by default).
140
+
141
+ ## Repository layout
142
+
143
+ ```text
144
+ FeatureLens/
145
+ ├── app.py # Gradio / ZeroGPU workbench
146
+ ├── featurelens/
147
+ │ ├── config.py # model + layer configuration
148
+ │ ├── sae.py # TopK SAE loading / encode / decode
149
+ │ ├── interventions.py # causal deltas + random controls
150
+ │ ├── runtime.py # hooks, generation, probability deltas
151
+ │ ├── metrics.py # reconstruction + divergence metrics
152
+ │ └── catalog.py # offline result integration
153
+ ├── experiments/
154
+ │ ├── build_dataset.py
155
+ │ ├── collect_activations.py
156
+ │ ├── evaluate_features.py
157
+ │ ├── run_causal.py
158
+ │ ├── make_report.py
159
+ │ └── run_all.py
160
+ ├── data/
161
+ │ ├── prompts.jsonl
162
+ │ └── causal_tasks.jsonl
163
+ ├── tests/
164
+ ├── scripts/release_check.py
165
+ ├── docs/
166
+ └── research_config.json
167
+ ```
168
+
169
+ ## Methodological limitations
170
+
171
+ - Qwen-Scope features are TopK sparse directions, not guaranteed monosemantic concepts.
172
+ - The controlled benchmark is intentionally small enough to reproduce on modest research compute; it is not a universal feature ontology.
173
+ - The live demo applies one residual edit at one selected prompt token. This is cleaner for causal attribution than repeatedly steering every generated token, but it may produce smaller behavioral effects.
174
+ - A target string may tokenize into multiple tokens. The live workbench explicitly labels its target metric as the **first-token** probability in that case.
175
+ - A causal effect can depend strongly on prompt, layer, feature scale, and downstream task. The report therefore includes raw per-task rows rather than only aggregate means.
176
+
177
+ ## Reproducibility and checks
178
+
179
+ ```bash
180
+ python -m ruff check app.py featurelens experiments tests scripts
181
+ python -m pytest -q
182
+ python -m compileall -q app.py featurelens experiments scripts
183
+ python scripts/release_check.py
184
+ ```
185
+
186
+ ## Resume-ready description
187
+
188
+ > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
189
+ > Built an SAE-based interpretability system for Qwen3-1.7B that discovers concept-associated residual features, benchmarks held-out predictiveness and paraphrase stability against linear probes, and causally ablates/amplifies features with norm-matched controls while measuring downstream token-probability and generation shifts.
190
+
191
+ ## Acknowledgements
192
+
193
+ FeatureLens builds on the open Qwen3 model and Qwen-Scope SAE checkpoints from the Qwen team. See the upstream model cards and Qwen-Scope technical report for model and SAE details and licensing requirements.
app.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import gradio as gr
4
+
5
+ from featurelens.config import SETTINGS
6
+ from featurelens.hf_runtime import gpu
7
+ from featurelens.runtime import RUNTIME
8
+
9
+ CSS = """
10
+ :root { --fl-blue:#2563eb; --fl-ink:#0f172a; --fl-muted:#64748b; }
11
+ .gradio-container { max-width: 1280px !important; }
12
+ .hero { padding: 12px 2px 6px; }
13
+ .hero h1 { margin-bottom: 2px; font-size: 2.1rem; letter-spacing:-0.03em; }
14
+ .hero p { color: var(--fl-muted); margin-top: 0; }
15
+ .panel { border:1px solid #e2e8f0; border-radius:14px; padding:8px; }
16
+ .token-wrap { display:flex; flex-wrap:wrap; gap:5px; padding:8px 2px; line-height:1.7; }
17
+ .token { background:#f1f5f9; border:1px solid #e2e8f0; border-radius:6px; padding:2px 7px; font-family:ui-monospace,SFMono-Regular,monospace; font-size:12px; }
18
+ .token.selected { background:#dbeafe; border:2px solid #2563eb; color:#1e3a8a; }
19
+ .token sup { opacity:.55; margin-right:4px; }
20
+ .small-note { color:#64748b; font-size:12px; }
21
+ """
22
+
23
+
24
+ def _analysis_metrics_markdown(result) -> str:
25
+ return (
26
+ f"**Layer {result.layer} · token {result.token_index}** \n"
27
+ f"Active SAE features: **{int(result.metrics['active_features'])}/{SETTINGS.sae_top_k}** \n"
28
+ f"Reconstruction cosine: **{result.metrics['cosine']:.4f}** \n"
29
+ f"Normalized MSE: **{result.metrics['nmse']:.4f}**"
30
+ )
31
+
32
+
33
+ def _intervention_metrics_markdown(result) -> str:
34
+ target = 'No target continuation supplied.'
35
+ if result.baseline_target_prob is not None:
36
+ warning = ''
37
+ if result.target_token_count > 1:
38
+ warning = (
39
+ f" \n_Note: target text tokenizes to {result.target_token_count} tokens; "
40
+ 'the displayed probability is for its first token only._'
41
+ )
42
+ target = (
43
+ f"Target first token: `{result.target_token}` \n"
44
+ f"Baseline p: **{result.baseline_target_prob:.6f}** · "
45
+ f"Modified p: **{result.modified_target_prob:.6f}** \n"
46
+ f"Δ log p: **{result.target_logprob_delta:+.4f}**{warning}"
47
+ )
48
+ return (
49
+ f"Original feature activation: **{result.feature_activation:.4f}** \n"
50
+ f"Δ feature coefficient: **{result.delta_activation:+.4f}** \n"
51
+ f"Residual perturbation L2: **{result.perturbation_norm:.4f}** \n"
52
+ f"Next-token JS divergence: **{result.js_divergence:.6f}** \n\n"
53
+ f"{target}"
54
+ )
55
+
56
+
57
+ @gpu(duration=45)
58
+ def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
59
+ if not prompt.strip():
60
+ raise gr.Error('Enter a prompt first.')
61
+ result = RUNTIME.analyze(prompt, int(layer), int(token_index), int(top_n))
62
+ choices = [str(int(row[1])) for row in result.rows]
63
+ feature_update = gr.update(choices=choices, value=choices[0] if choices else None)
64
+ return (
65
+ RUNTIME.token_html(result.tokens, result.token_index),
66
+ result.rows,
67
+ feature_update,
68
+ _analysis_metrics_markdown(result),
69
+ )
70
+
71
+
72
+ @gpu(duration=60)
73
+ def run_intervention(
74
+ prompt: str,
75
+ layer: int,
76
+ token_index: int,
77
+ feature_id: str,
78
+ mode: str,
79
+ coefficient: float,
80
+ target_text: str,
81
+ max_new_tokens: int,
82
+ ):
83
+ if not prompt.strip():
84
+ raise gr.Error('Enter a prompt first.')
85
+ if feature_id is None or str(feature_id).strip() == '':
86
+ raise gr.Error('Choose or enter a feature id.')
87
+ try:
88
+ fid = int(float(feature_id))
89
+ except ValueError as exc:
90
+ raise gr.Error('Feature id must be an integer.') from exc
91
+ result = RUNTIME.intervene(
92
+ text=prompt,
93
+ layer=int(layer),
94
+ token_index=int(token_index),
95
+ feature_id=fid,
96
+ mode=mode,
97
+ coefficient=float(coefficient),
98
+ target_text=target_text,
99
+ max_new_tokens=int(max_new_tokens),
100
+ )
101
+ return (
102
+ result.baseline_text,
103
+ result.modified_text,
104
+ _intervention_metrics_markdown(result),
105
+ result.top_token_rows,
106
+ )
107
+
108
+
109
+ def mode_help(mode: str):
110
+ if mode == 'ablate':
111
+ return gr.update(
112
+ value=0.0,
113
+ interactive=False,
114
+ label='Coefficient (unused for ablation)',
115
+ info='Ablation sets the selected TopK feature coefficient to zero.',
116
+ )
117
+ if mode == 'scale':
118
+ return gr.update(
119
+ value=2.0,
120
+ interactive=True,
121
+ label='Feature multiplier',
122
+ info='1.0 = unchanged, 0 = ablate, 2.0 = double the original coefficient.',
123
+ )
124
+ return gr.update(
125
+ value=5.0,
126
+ interactive=True,
127
+ label='Additive feature coefficient',
128
+ info='Adds this amount along the feature decoder direction, even if the feature is inactive.',
129
+ )
130
+
131
+
132
+ with gr.Blocks(title='FeatureLens — Causal Interpretability Workbench') as demo:
133
+ gr.HTML(
134
+ '<div class="hero"><h1>FeatureLens</h1>'
135
+ '<p>Causal sparse-feature interpretability for Qwen3-1.7B: inspect → intervene → measure.</p></div>'
136
+ )
137
+ gr.Markdown(
138
+ f"Model: `{SETTINGS.model_id}` · Qwen-Scope TopK SAE · layers "
139
+ f"`{', '.join(map(str, SETTINGS.layers))}` · width `{SETTINGS.sae_width:,}`"
140
+ )
141
+
142
+ with gr.Tab('Workbench'):
143
+ with gr.Row(equal_height=False):
144
+ with gr.Column(scale=2):
145
+ prompt = gr.Textbox(
146
+ label='Prompt',
147
+ lines=7,
148
+ value='The derivative of x squared is',
149
+ placeholder='Enter a prompt to inspect...',
150
+ )
151
+ with gr.Row():
152
+ layer = gr.Dropdown(
153
+ choices=list(SETTINGS.layers),
154
+ value=SETTINGS.layers[1] if len(SETTINGS.layers) > 1 else SETTINGS.layers[0],
155
+ label='Residual layer',
156
+ )
157
+ token_index = gr.Number(
158
+ value=-1,
159
+ precision=0,
160
+ label='Token index',
161
+ info='Use -1 for the final prompt token. Analyze once to see all token indices.',
162
+ )
163
+ top_n = gr.Slider(5, 20, value=12, step=1, label='Top features')
164
+ analyze_btn = gr.Button('Inspect SAE features', variant='primary')
165
+ token_view = gr.HTML(
166
+ '<div class="small-note">Token positions will appear here after analysis.</div>'
167
+ )
168
+ analysis_metrics = gr.Markdown()
169
+
170
+ with gr.Column(scale=3):
171
+ feature_table = gr.Dataframe(
172
+ headers=['Rank', 'Feature id', 'Activation', 'Offline concept hint'],
173
+ datatype=['number', 'number', 'number', 'str'],
174
+ interactive=False,
175
+ label='Strongest TopK features at the selected token',
176
+ wrap=True,
177
+ )
178
+
179
+ gr.Markdown('### Causal intervention')
180
+ gr.Markdown(
181
+ 'The edit is **reconstruction-preserving**: FeatureLens changes only the selected SAE '
182
+ 'coefficient and adds the decoded delta to the *original* residual stream. It does not '
183
+ 'replace the residual with an SAE reconstruction.'
184
+ )
185
+ with gr.Row(equal_height=False):
186
+ with gr.Column(scale=2):
187
+ feature_id = gr.Dropdown(
188
+ choices=[],
189
+ allow_custom_value=True,
190
+ label='Feature id',
191
+ info='Analyze first to populate the strongest features, or enter any id 0–32767.',
192
+ )
193
+ mode = gr.Radio(
194
+ choices=['ablate', 'scale', 'inject'],
195
+ value='ablate',
196
+ label='Intervention',
197
+ )
198
+ coefficient = gr.Number(
199
+ value=0.0,
200
+ interactive=False,
201
+ label='Coefficient (unused for ablation)',
202
+ )
203
+ target_text = gr.Textbox(
204
+ label='Optional target continuation',
205
+ placeholder='e.g. 2x',
206
+ info='FeatureLens reports the first-token probability shift for this continuation.',
207
+ )
208
+ max_new = gr.Slider(
209
+ 4,
210
+ SETTINGS.max_new_tokens,
211
+ value=min(20, SETTINGS.max_new_tokens),
212
+ step=1,
213
+ label='Max new tokens',
214
+ )
215
+ intervene_btn = gr.Button('Run baseline vs modified', variant='primary')
216
+ intervention_metrics = gr.Markdown()
217
+
218
+ with gr.Column(scale=3):
219
+ with gr.Row():
220
+ baseline_out = gr.Textbox(label='Baseline output', lines=8, interactive=False)
221
+ modified_out = gr.Textbox(label='Modified output', lines=8, interactive=False)
222
+ token_prob_table = gr.Dataframe(
223
+ headers=['Token', 'Baseline p', 'Modified p', 'Δ probability'],
224
+ datatype=['str', 'number', 'number', 'number'],
225
+ interactive=False,
226
+ label='First generated token distribution',
227
+ )
228
+
229
+ analyze_btn.click(
230
+ analyze_prompt,
231
+ inputs=[prompt, layer, token_index, top_n],
232
+ outputs=[token_view, feature_table, feature_id, analysis_metrics],
233
+ )
234
+ mode.change(mode_help, inputs=[mode], outputs=[coefficient])
235
+ intervene_btn.click(
236
+ run_intervention,
237
+ inputs=[
238
+ prompt,
239
+ layer,
240
+ token_index,
241
+ feature_id,
242
+ mode,
243
+ coefficient,
244
+ target_text,
245
+ max_new,
246
+ ],
247
+ outputs=[baseline_out, modified_out, intervention_metrics, token_prob_table],
248
+ )
249
+
250
+ with gr.Tab('Offline benchmark'):
251
+ benchmark_md = gr.Markdown(RUNTIME.catalog.benchmark_markdown())
252
+ gr.Markdown(
253
+ 'The offline pipeline evaluates feature/concept AUROC + F1, reconstruction quality, '
254
+ 'paraphrase stability, layer-wise residual linear probes, causal ablation/amplification, '
255
+ 'and norm-matched random-direction controls. Results are loaded from `artifacts/` and '
256
+ 'are never hard-coded into the demo.'
257
+ )
258
+
259
+ with gr.Tab('Method'):
260
+ gr.Markdown(
261
+ r"""
262
+ ### What the intervention means
263
+
264
+ For residual vector $h$, sparse code $z$, decoder column $d_i$, and selected feature $i$:
265
+
266
+ - **Ablate:** $h' = h - z_i d_i$
267
+ - **Scale by $\alpha$:** $h' = h + (\alpha - 1) z_i d_i$
268
+ - **Inject $\delta$:** $h' = h + \delta d_i$
269
+
270
+ This is equivalent to editing the SAE reconstruction by the chosen feature delta while retaining
271
+ $h$ itself, so SAE reconstruction error is not injected as a confound. The modified residual is
272
+ patched at one selected **prompt token**; downstream generation is then allowed to evolve normally.
273
+
274
+ ### What FeatureLens does *not* claim
275
+
276
+ A high feature/concept AUROC is correlational evidence. A causal claim requires downstream effects
277
+ under intervention, held-out prompts, and comparison with controls. The offline report is designed
278
+ to make a weak or null causal result visible rather than hide it.
279
+ """
280
+ )
281
+
282
+ gr.Markdown(
283
+ 'Built with PyTorch, Transformers, Qwen3-1.7B-Base and Qwen-Scope SAEs. '
284
+ 'This project is independent of any thesis dataset or thesis code.'
285
+ )
286
+
287
+ if __name__ == '__main__':
288
+ demo.queue(default_concurrency_limit=1).launch(css=CSS)
artifacts/README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated artifacts
2
+
3
+ This directory intentionally ships without invented results.
4
+
5
+ Run:
6
+
7
+ ```bash
8
+ python experiments/run_all.py
9
+ ```
10
+
11
+ to create activation caches, `feature_catalog.csv`, `layer_metrics.csv`, `stability.csv`, `causal_results.csv`, `summary.json`, `report.md`, and figures.
12
+
13
+ Large activation arrays are ignored by Git. Commit the small CSV/JSON/report/figure outputs if you want the live Space to show benchmark-derived feature hints and the benchmark summary.
data/causal_tasks.jsonl ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"id": 0, "concept": "positive_sentiment", "prompt": "Sentiment: The meal was wonderful and the staff were kind. Label:", "target": " positive"}
2
+ {"id": 1, "concept": "positive_sentiment", "prompt": "Sentiment: I loved the thoughtful, beautifully written novel. Label:", "target": " positive"}
3
+ {"id": 2, "concept": "positive_sentiment", "prompt": "Sentiment: The service was fast and extremely helpful. Label:", "target": " positive"}
4
+ {"id": 3, "concept": "positive_sentiment", "prompt": "Sentiment: The journey was comfortable and enjoyable. Label:", "target": " positive"}
5
+ {"id": 4, "concept": "negative_sentiment", "prompt": "Sentiment: The meal was awful and the staff were rude. Label:", "target": " negative"}
6
+ {"id": 5, "concept": "negative_sentiment", "prompt": "Sentiment: I hated the tedious, badly written novel. Label:", "target": " negative"}
7
+ {"id": 6, "concept": "negative_sentiment", "prompt": "Sentiment: The service was slow and completely unhelpful. Label:", "target": " negative"}
8
+ {"id": 7, "concept": "negative_sentiment", "prompt": "Sentiment: The journey was stressful and unpleasant. Label:", "target": " negative"}
9
+ {"id": 8, "concept": "mathematics", "prompt": "2 + 3 =", "target": " 5"}
10
+ {"id": 9, "concept": "mathematics", "prompt": "7 - 4 =", "target": " 3"}
11
+ {"id": 10, "concept": "mathematics", "prompt": "6 * 2 =", "target": " 12"}
12
+ {"id": 11, "concept": "mathematics", "prompt": "The square root of 81 is", "target": " 9"}
13
+ {"id": 12, "concept": "code", "prompt": "Python function declaration keyword:", "target": " def"}
14
+ {"id": 13, "concept": "code", "prompt": "In Python, an exception handler begins with the keyword", "target": " except"}
15
+ {"id": 14, "concept": "code", "prompt": "SQL keyword used to retrieve rows:", "target": " SELECT"}
16
+ {"id": 15, "concept": "code", "prompt": "A Python conditional branch commonly starts with", "target": " if"}
17
+ {"id": 16, "concept": "french_language", "prompt": "Translate 'hello' into French:", "target": " bonjour"}
18
+ {"id": 17, "concept": "french_language", "prompt": "Translate 'thank you' into French:", "target": " merci"}
19
+ {"id": 18, "concept": "french_language", "prompt": "Translate 'yes' into French:", "target": " oui"}
20
+ {"id": 19, "concept": "french_language", "prompt": "Translate 'good evening' into French:", "target": " bonsoir"}
21
+ {"id": 20, "concept": "factual_entities", "prompt": "The scientist associated with radium research, Marie", "target": " Curie"}
22
+ {"id": 21, "concept": "factual_entities", "prompt": "The composer of the Fifth Symphony, Ludwig van", "target": " Beethoven"}
23
+ {"id": 22, "concept": "factual_entities", "prompt": "The computer scientist known for the Turing machine, Alan", "target": " Turing"}
24
+ {"id": 23, "concept": "factual_entities", "prompt": "The Japanese city famous for many historic temples,", "target": " Kyoto"}
25
+ {"id": 24, "concept": "uncertainty", "prompt": "The evidence is insufficient. The answer is", "target": " unknown"}
26
+ {"id": 25, "concept": "uncertainty", "prompt": "The passage never states the value, so it is", "target": " unknown"}
27
+ {"id": 26, "concept": "uncertainty", "prompt": "There is not enough information to determine the result. It remains", "target": " uncertain"}
28
+ {"id": 27, "concept": "uncertainty", "prompt": "No reliable conclusion can be drawn; the outcome is", "target": " unclear"}
data/prompts.jsonl ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"id": 0, "concept": "code", "pair_id": "code-00", "variant": 0, "text": "Write a Python function that returns the larger of two integers."}
2
+ {"id": 1, "concept": "code", "pair_id": "code-00", "variant": 1, "text": "In Python, create a function that chooses the maximum of two integer inputs."}
3
+ {"id": 2, "concept": "code", "pair_id": "code-01", "variant": 0, "text": "Explain what a recursive function does in a program."}
4
+ {"id": 3, "concept": "code", "pair_id": "code-01", "variant": 1, "text": "Describe recursion in the context of a programming function."}
5
+ {"id": 4, "concept": "code", "pair_id": "code-02", "variant": 0, "text": "Show a JavaScript loop that prints numbers from one to five."}
6
+ {"id": 5, "concept": "code", "pair_id": "code-02", "variant": 1, "text": "Give JavaScript code for iterating over the integers 1 through 5 and logging them."}
7
+ {"id": 6, "concept": "code", "pair_id": "code-03", "variant": 0, "text": "What is the purpose of a hash map in software?"}
8
+ {"id": 7, "concept": "code", "pair_id": "code-03", "variant": 1, "text": "Explain why programmers use hash maps or dictionaries."}
9
+ {"id": 8, "concept": "code", "pair_id": "code-04", "variant": 0, "text": "Write SQL that selects every row from a table named users."}
10
+ {"id": 9, "concept": "code", "pair_id": "code-04", "variant": 1, "text": "Provide a SQL query to retrieve all records in the users table."}
11
+ {"id": 10, "concept": "code", "pair_id": "code-05", "variant": 0, "text": "How do I catch an exception in Python?"}
12
+ {"id": 11, "concept": "code", "pair_id": "code-05", "variant": 1, "text": "Show the Python syntax for handling an exception with try and except."}
13
+ {"id": 12, "concept": "code", "pair_id": "code-06", "variant": 0, "text": "Explain the difference between a list and a tuple in Python."}
14
+ {"id": 13, "concept": "code", "pair_id": "code-06", "variant": 1, "text": "Compare Python lists with tuples."}
15
+ {"id": 14, "concept": "code", "pair_id": "code-07", "variant": 0, "text": "Write a function that checks whether a string is a palindrome."}
16
+ {"id": 15, "concept": "code", "pair_id": "code-07", "variant": 1, "text": "Create code that tests if text reads the same forwards and backwards."}
17
+ {"id": 16, "concept": "code", "pair_id": "code-08", "variant": 0, "text": "What does git rebase do?"}
18
+ {"id": 17, "concept": "code", "pair_id": "code-08", "variant": 1, "text": "Describe the effect of rebasing a Git branch."}
19
+ {"id": 18, "concept": "code", "pair_id": "code-09", "variant": 0, "text": "How can a REST API return JSON?"}
20
+ {"id": 19, "concept": "code", "pair_id": "code-09", "variant": 1, "text": "Explain how a web API endpoint sends a JSON response."}
21
+ {"id": 20, "concept": "code", "pair_id": "code-10", "variant": 0, "text": "Write pseudocode for binary search."}
22
+ {"id": 21, "concept": "code", "pair_id": "code-10", "variant": 1, "text": "Describe the binary-search algorithm as pseudocode."}
23
+ {"id": 22, "concept": "code", "pair_id": "code-11", "variant": 0, "text": "What is a class constructor used for?"}
24
+ {"id": 23, "concept": "code", "pair_id": "code-11", "variant": 1, "text": "Explain the role of a constructor when creating an object."}
25
+ {"id": 24, "concept": "code", "pair_id": "code-12", "variant": 0, "text": "Give a regular expression that matches a basic email-like string."}
26
+ {"id": 25, "concept": "code", "pair_id": "code-12", "variant": 1, "text": "Show a regex pattern for a simple email address format."}
27
+ {"id": 26, "concept": "code", "pair_id": "code-13", "variant": 0, "text": "Why is time complexity important when comparing algorithms?"}
28
+ {"id": 27, "concept": "code", "pair_id": "code-13", "variant": 1, "text": "Explain why Big-O runtime matters in algorithm analysis."}
29
+ {"id": 28, "concept": "code", "pair_id": "code-14", "variant": 0, "text": "How do unit tests help a codebase?"}
30
+ {"id": 29, "concept": "code", "pair_id": "code-14", "variant": 1, "text": "Describe the purpose of automated unit testing in software."}
31
+ {"id": 30, "concept": "code", "pair_id": "code-15", "variant": 0, "text": "Write Python that sorts a list of dictionaries by an age key."}
32
+ {"id": 31, "concept": "code", "pair_id": "code-15", "variant": 1, "text": "Show how to order Python dictionaries in a list according to their age field."}
33
+ {"id": 32, "concept": "mathematics", "pair_id": "mathematics-00", "variant": 0, "text": "Find the derivative of x cubed plus two x."}
34
+ {"id": 33, "concept": "mathematics", "pair_id": "mathematics-00", "variant": 1, "text": "Differentiate the function x^3 + 2x."}
35
+ {"id": 34, "concept": "mathematics", "pair_id": "mathematics-01", "variant": 0, "text": "Solve the equation 3x + 5 = 20."}
36
+ {"id": 35, "concept": "mathematics", "pair_id": "mathematics-01", "variant": 1, "text": "Find x when three x plus five equals twenty."}
37
+ {"id": 36, "concept": "mathematics", "pair_id": "mathematics-02", "variant": 0, "text": "What is the integral of cosine x?"}
38
+ {"id": 37, "concept": "mathematics", "pair_id": "mathematics-02", "variant": 1, "text": "Compute an antiderivative of cos(x)."}
39
+ {"id": 38, "concept": "mathematics", "pair_id": "mathematics-03", "variant": 0, "text": "Factor the polynomial x squared minus nine."}
40
+ {"id": 39, "concept": "mathematics", "pair_id": "mathematics-03", "variant": 1, "text": "Rewrite x^2 - 9 as a product of factors."}
41
+ {"id": 40, "concept": "mathematics", "pair_id": "mathematics-04", "variant": 0, "text": "Calculate the mean of 4, 7, 9, and 10."}
42
+ {"id": 41, "concept": "mathematics", "pair_id": "mathematics-04", "variant": 1, "text": "What is the arithmetic average of 4, 7, 9, 10?"}
43
+ {"id": 42, "concept": "mathematics", "pair_id": "mathematics-05", "variant": 0, "text": "Explain the Pythagorean theorem."}
44
+ {"id": 43, "concept": "mathematics", "pair_id": "mathematics-05", "variant": 1, "text": "State the relationship among the sides of a right triangle."}
45
+ {"id": 44, "concept": "mathematics", "pair_id": "mathematics-06", "variant": 0, "text": "What is the determinant of a two by two matrix?"}
46
+ {"id": 45, "concept": "mathematics", "pair_id": "mathematics-06", "variant": 1, "text": "Give the formula for the determinant of a 2x2 matrix."}
47
+ {"id": 46, "concept": "mathematics", "pair_id": "mathematics-07", "variant": 0, "text": "Simplify two to the third power times two squared."}
48
+ {"id": 47, "concept": "mathematics", "pair_id": "mathematics-07", "variant": 1, "text": "Reduce 2^3 multiplied by 2^2 using exponent rules."}
49
+ {"id": 48, "concept": "mathematics", "pair_id": "mathematics-08", "variant": 0, "text": "Convert one half into a percentage."}
50
+ {"id": 49, "concept": "mathematics", "pair_id": "mathematics-08", "variant": 1, "text": "Express 1/2 as a percent."}
51
+ {"id": 50, "concept": "mathematics", "pair_id": "mathematics-09", "variant": 0, "text": "What is the probability of heads on a fair coin?"}
52
+ {"id": 51, "concept": "mathematics", "pair_id": "mathematics-09", "variant": 1, "text": "For a fair coin, calculate the chance of flipping heads."}
53
+ {"id": 52, "concept": "mathematics", "pair_id": "mathematics-10", "variant": 0, "text": "Solve x squared equals sixteen."}
54
+ {"id": 53, "concept": "mathematics", "pair_id": "mathematics-10", "variant": 1, "text": "Find the real values of x satisfying x^2 = 16."}
55
+ {"id": 54, "concept": "mathematics", "pair_id": "mathematics-11", "variant": 0, "text": "What is the slope between points (1,2) and (3,6)?"}
56
+ {"id": 55, "concept": "mathematics", "pair_id": "mathematics-11", "variant": 1, "text": "Calculate the gradient of the line through (1,2) and (3,6)."}
57
+ {"id": 56, "concept": "mathematics", "pair_id": "mathematics-12", "variant": 0, "text": "Explain what a prime number is."}
58
+ {"id": 57, "concept": "mathematics", "pair_id": "mathematics-12", "variant": 1, "text": "Define a prime integer."}
59
+ {"id": 58, "concept": "mathematics", "pair_id": "mathematics-13", "variant": 0, "text": "Compute the dot product of vectors (1,2) and (3,4)."}
60
+ {"id": 59, "concept": "mathematics", "pair_id": "mathematics-13", "variant": 1, "text": "Find (1,2) · (3,4)."}
61
+ {"id": 60, "concept": "mathematics", "pair_id": "mathematics-14", "variant": 0, "text": "What is log base ten of one thousand?"}
62
+ {"id": 61, "concept": "mathematics", "pair_id": "mathematics-14", "variant": 1, "text": "Evaluate log_10(1000)."}
63
+ {"id": 62, "concept": "mathematics", "pair_id": "mathematics-15", "variant": 0, "text": "A circle has radius three. What is its area?"}
64
+ {"id": 63, "concept": "mathematics", "pair_id": "mathematics-15", "variant": 1, "text": "Calculate the area of a circle whose radius is 3."}
65
+ {"id": 64, "concept": "positive_sentiment", "pair_id": "positive_sentiment-00", "variant": 0, "text": "The film was delightful, clever, and beautifully acted."}
66
+ {"id": 65, "concept": "positive_sentiment", "pair_id": "positive_sentiment-00", "variant": 1, "text": "I found the movie charming, smart, and wonderfully performed."}
67
+ {"id": 66, "concept": "positive_sentiment", "pair_id": "positive_sentiment-01", "variant": 0, "text": "This restaurant served an excellent meal and the staff were kind."}
68
+ {"id": 67, "concept": "positive_sentiment", "pair_id": "positive_sentiment-01", "variant": 1, "text": "The food was fantastic and the service team was genuinely friendly."}
69
+ {"id": 68, "concept": "positive_sentiment", "pair_id": "positive_sentiment-02", "variant": 0, "text": "I am very pleased with how reliable this laptop has been."}
70
+ {"id": 69, "concept": "positive_sentiment", "pair_id": "positive_sentiment-02", "variant": 1, "text": "This laptop has worked dependably and I am extremely satisfied with it."}
71
+ {"id": 70, "concept": "positive_sentiment", "pair_id": "positive_sentiment-03", "variant": 0, "text": "The concert was energetic and unforgettable."}
72
+ {"id": 71, "concept": "positive_sentiment", "pair_id": "positive_sentiment-03", "variant": 1, "text": "I had an amazing time at the lively, memorable concert."}
73
+ {"id": 72, "concept": "positive_sentiment", "pair_id": "positive_sentiment-04", "variant": 0, "text": "Her explanation was clear and genuinely helpful."}
74
+ {"id": 73, "concept": "positive_sentiment", "pair_id": "positive_sentiment-04", "variant": 1, "text": "She explained the topic in a useful and easy-to-understand way."}
75
+ {"id": 74, "concept": "positive_sentiment", "pair_id": "positive_sentiment-05", "variant": 0, "text": "The hotel room was spotless and comfortable."}
76
+ {"id": 75, "concept": "positive_sentiment", "pair_id": "positive_sentiment-05", "variant": 1, "text": "Our room was exceptionally clean and pleasant to stay in."}
77
+ {"id": 76, "concept": "positive_sentiment", "pair_id": "positive_sentiment-06", "variant": 0, "text": "I loved the book from beginning to end."}
78
+ {"id": 77, "concept": "positive_sentiment", "pair_id": "positive_sentiment-06", "variant": 1, "text": "The novel kept me delighted all the way through."}
79
+ {"id": 78, "concept": "positive_sentiment", "pair_id": "positive_sentiment-07", "variant": 0, "text": "The new update makes the app much easier to use."}
80
+ {"id": 79, "concept": "positive_sentiment", "pair_id": "positive_sentiment-07", "variant": 1, "text": "After the update, the application feels significantly more convenient."}
81
+ {"id": 80, "concept": "positive_sentiment", "pair_id": "positive_sentiment-08", "variant": 0, "text": "Their customer support solved my problem quickly."}
82
+ {"id": 81, "concept": "positive_sentiment", "pair_id": "positive_sentiment-08", "variant": 1, "text": "Support handled the issue fast and left me very happy."}
83
+ {"id": 82, "concept": "positive_sentiment", "pair_id": "positive_sentiment-09", "variant": 0, "text": "The hike had spectacular views and perfect weather."}
84
+ {"id": 83, "concept": "positive_sentiment", "pair_id": "positive_sentiment-09", "variant": 1, "text": "We enjoyed gorgeous scenery and wonderful conditions on the hike."}
85
+ {"id": 84, "concept": "positive_sentiment", "pair_id": "positive_sentiment-10", "variant": 0, "text": "The presentation was engaging and well organized."}
86
+ {"id": 85, "concept": "positive_sentiment", "pair_id": "positive_sentiment-10", "variant": 1, "text": "I enjoyed the talk because it was compelling and structured clearly."}
87
+ {"id": 86, "concept": "positive_sentiment", "pair_id": "positive_sentiment-11", "variant": 0, "text": "This camera takes sharp photos and feels great to use."}
88
+ {"id": 87, "concept": "positive_sentiment", "pair_id": "positive_sentiment-11", "variant": 1, "text": "The camera produces crisp images and has a satisfying design."}
89
+ {"id": 88, "concept": "positive_sentiment", "pair_id": "positive_sentiment-12", "variant": 0, "text": "The workshop exceeded my expectations."}
90
+ {"id": 89, "concept": "positive_sentiment", "pair_id": "positive_sentiment-12", "variant": 1, "text": "I was impressed because the workshop was even better than I expected."}
91
+ {"id": 90, "concept": "positive_sentiment", "pair_id": "positive_sentiment-13", "variant": 0, "text": "Dinner turned out wonderfully and everyone enjoyed it."}
92
+ {"id": 91, "concept": "positive_sentiment", "pair_id": "positive_sentiment-13", "variant": 1, "text": "The evening meal was a success and all of us had a great time."}
93
+ {"id": 92, "concept": "positive_sentiment", "pair_id": "positive_sentiment-14", "variant": 0, "text": "The museum exhibition was fascinating."}
94
+ {"id": 93, "concept": "positive_sentiment", "pair_id": "positive_sentiment-14", "variant": 1, "text": "I thought the exhibition was deeply interesting and rewarding."}
95
+ {"id": 94, "concept": "positive_sentiment", "pair_id": "positive_sentiment-15", "variant": 0, "text": "The train journey was smooth and relaxing."}
96
+ {"id": 95, "concept": "positive_sentiment", "pair_id": "positive_sentiment-15", "variant": 1, "text": "The trip by train felt easy, calm, and comfortable."}
97
+ {"id": 96, "concept": "negative_sentiment", "pair_id": "negative_sentiment-00", "variant": 0, "text": "The film was tedious, confusing, and badly acted."}
98
+ {"id": 97, "concept": "negative_sentiment", "pair_id": "negative_sentiment-00", "variant": 1, "text": "I found the movie boring, incoherent, and poorly performed."}
99
+ {"id": 98, "concept": "negative_sentiment", "pair_id": "negative_sentiment-01", "variant": 0, "text": "This restaurant served cold food and the staff were rude."}
100
+ {"id": 99, "concept": "negative_sentiment", "pair_id": "negative_sentiment-01", "variant": 1, "text": "The meal arrived cold and the service team behaved unpleasantly."}
101
+ {"id": 100, "concept": "negative_sentiment", "pair_id": "negative_sentiment-02", "variant": 0, "text": "I am disappointed by how unreliable this laptop has been."}
102
+ {"id": 101, "concept": "negative_sentiment", "pair_id": "negative_sentiment-02", "variant": 1, "text": "This laptop keeps failing and I am extremely dissatisfied with it."}
103
+ {"id": 102, "concept": "negative_sentiment", "pair_id": "negative_sentiment-03", "variant": 0, "text": "The concert was chaotic and forgettable."}
104
+ {"id": 103, "concept": "negative_sentiment", "pair_id": "negative_sentiment-03", "variant": 1, "text": "I had a miserable time at the disorganized, dull concert."}
105
+ {"id": 104, "concept": "negative_sentiment", "pair_id": "negative_sentiment-04", "variant": 0, "text": "Her explanation was unclear and unhelpful."}
106
+ {"id": 105, "concept": "negative_sentiment", "pair_id": "negative_sentiment-04", "variant": 1, "text": "She explained the topic in a confusing way that did not help me."}
107
+ {"id": 106, "concept": "negative_sentiment", "pair_id": "negative_sentiment-05", "variant": 0, "text": "The hotel room was dirty and uncomfortable."}
108
+ {"id": 107, "concept": "negative_sentiment", "pair_id": "negative_sentiment-05", "variant": 1, "text": "Our room was unpleasant, unclean, and difficult to relax in."}
109
+ {"id": 108, "concept": "negative_sentiment", "pair_id": "negative_sentiment-06", "variant": 0, "text": "I regretted reading the book."}
110
+ {"id": 109, "concept": "negative_sentiment", "pair_id": "negative_sentiment-06", "variant": 1, "text": "The novel was a frustrating waste of my time."}
111
+ {"id": 110, "concept": "negative_sentiment", "pair_id": "negative_sentiment-07", "variant": 0, "text": "The new update makes the app harder to use."}
112
+ {"id": 111, "concept": "negative_sentiment", "pair_id": "negative_sentiment-07", "variant": 1, "text": "After the update, the application feels significantly more awkward."}
113
+ {"id": 112, "concept": "negative_sentiment", "pair_id": "negative_sentiment-08", "variant": 0, "text": "Their customer support ignored my problem."}
114
+ {"id": 113, "concept": "negative_sentiment", "pair_id": "negative_sentiment-08", "variant": 1, "text": "Support failed to resolve the issue and left me angry."}
115
+ {"id": 114, "concept": "negative_sentiment", "pair_id": "negative_sentiment-09", "variant": 0, "text": "The hike had awful weather and disappointing views."}
116
+ {"id": 115, "concept": "negative_sentiment", "pair_id": "negative_sentiment-09", "variant": 1, "text": "We dealt with terrible conditions and underwhelming scenery on the hike."}
117
+ {"id": 116, "concept": "negative_sentiment", "pair_id": "negative_sentiment-10", "variant": 0, "text": "The presentation was dull and poorly organized."}
118
+ {"id": 117, "concept": "negative_sentiment", "pair_id": "negative_sentiment-10", "variant": 1, "text": "I disliked the talk because it was tedious and structured badly."}
119
+ {"id": 118, "concept": "negative_sentiment", "pair_id": "negative_sentiment-11", "variant": 0, "text": "This camera takes blurry photos and feels cheap."}
120
+ {"id": 119, "concept": "negative_sentiment", "pair_id": "negative_sentiment-11", "variant": 1, "text": "The camera produces soft images and has a flimsy design."}
121
+ {"id": 120, "concept": "negative_sentiment", "pair_id": "negative_sentiment-12", "variant": 0, "text": "The workshop fell far below my expectations."}
122
+ {"id": 121, "concept": "negative_sentiment", "pair_id": "negative_sentiment-12", "variant": 1, "text": "I was disappointed because the workshop was much worse than I expected."}
123
+ {"id": 122, "concept": "negative_sentiment", "pair_id": "negative_sentiment-13", "variant": 0, "text": "Dinner went badly and nobody enjoyed it."}
124
+ {"id": 123, "concept": "negative_sentiment", "pair_id": "negative_sentiment-13", "variant": 1, "text": "The evening meal was a failure and all of us had a poor time."}
125
+ {"id": 124, "concept": "negative_sentiment", "pair_id": "negative_sentiment-14", "variant": 0, "text": "The museum exhibition was painfully boring."}
126
+ {"id": 125, "concept": "negative_sentiment", "pair_id": "negative_sentiment-14", "variant": 1, "text": "I thought the exhibition was dull and unrewarding."}
127
+ {"id": 126, "concept": "negative_sentiment", "pair_id": "negative_sentiment-15", "variant": 0, "text": "The train journey was stressful and uncomfortable."}
128
+ {"id": 127, "concept": "negative_sentiment", "pair_id": "negative_sentiment-15", "variant": 1, "text": "The trip by train felt frustrating, noisy, and unpleasant."}
129
+ {"id": 128, "concept": "french_language", "pair_id": "french_language-00", "variant": 0, "text": "Bonjour, comment allez-vous aujourd’hui ?"}
130
+ {"id": 129, "concept": "french_language", "pair_id": "french_language-00", "variant": 1, "text": "Salut, comment vas-tu aujourd’hui ?"}
131
+ {"id": 130, "concept": "french_language", "pair_id": "french_language-01", "variant": 0, "text": "Je voudrais réserver une table pour deux personnes."}
132
+ {"id": 131, "concept": "french_language", "pair_id": "french_language-01", "variant": 1, "text": "Puis-je réserver une table pour deux, s’il vous plaît ?"}
133
+ {"id": 132, "concept": "french_language", "pair_id": "french_language-02", "variant": 0, "text": "La bibliothèque ferme à dix-huit heures."}
134
+ {"id": 133, "concept": "french_language", "pair_id": "french_language-02", "variant": 1, "text": "La bibliothèque est ouverte jusqu’à dix-huit heures."}
135
+ {"id": 134, "concept": "french_language", "pair_id": "french_language-03", "variant": 0, "text": "Ce livre raconte une histoire très intéressante."}
136
+ {"id": 135, "concept": "french_language", "pair_id": "french_language-03", "variant": 1, "text": "L’histoire racontée dans ce livre est vraiment intéressante."}
137
+ {"id": 136, "concept": "french_language", "pair_id": "french_language-04", "variant": 0, "text": "Nous allons prendre le train demain matin."}
138
+ {"id": 137, "concept": "french_language", "pair_id": "french_language-04", "variant": 1, "text": "Demain matin, nous voyagerons en train."}
139
+ {"id": 138, "concept": "french_language", "pair_id": "french_language-05", "variant": 0, "text": "Pouvez-vous m’indiquer le chemin vers la gare ?"}
140
+ {"id": 139, "concept": "french_language", "pair_id": "french_language-05", "variant": 1, "text": "Comment puis-je aller jusqu’à la gare ?"}
141
+ {"id": 140, "concept": "french_language", "pair_id": "french_language-06", "variant": 0, "text": "J’aime apprendre de nouvelles langues."}
142
+ {"id": 141, "concept": "french_language", "pair_id": "french_language-06", "variant": 1, "text": "Apprendre des langues nouvelles me plaît beaucoup."}
143
+ {"id": 142, "concept": "french_language", "pair_id": "french_language-07", "variant": 0, "text": "Le temps est magnifique au bord de la mer."}
144
+ {"id": 143, "concept": "french_language", "pair_id": "french_language-07", "variant": 1, "text": "Il fait très beau près de la mer."}
145
+ {"id": 144, "concept": "french_language", "pair_id": "french_language-08", "variant": 0, "text": "Elle prépare le dîner dans la cuisine."}
146
+ {"id": 145, "concept": "french_language", "pair_id": "french_language-08", "variant": 1, "text": "Dans la cuisine, elle est en train de préparer le repas du soir."}
147
+ {"id": 146, "concept": "french_language", "pair_id": "french_language-09", "variant": 0, "text": "Nous avons visité un musée pendant le week-end."}
148
+ {"id": 147, "concept": "french_language", "pair_id": "french_language-09", "variant": 1, "text": "Ce week-end, nous sommes allés voir un musée."}
149
+ {"id": 148, "concept": "french_language", "pair_id": "french_language-10", "variant": 0, "text": "La réunion commence à neuf heures précises."}
150
+ {"id": 149, "concept": "french_language", "pair_id": "french_language-10", "variant": 1, "text": "Le rendez-vous débute exactement à neuf heures."}
151
+ {"id": 150, "concept": "french_language", "pair_id": "french_language-11", "variant": 0, "text": "Mon ordinateur ne fonctionne plus correctement."}
152
+ {"id": 151, "concept": "french_language", "pair_id": "french_language-11", "variant": 1, "text": "J’ai un problème : mon ordinateur marche mal maintenant."}
153
+ {"id": 152, "concept": "french_language", "pair_id": "french_language-12", "variant": 0, "text": "Cette ville possède de nombreux bâtiments historiques."}
154
+ {"id": 153, "concept": "french_language", "pair_id": "french_language-12", "variant": 1, "text": "On trouve beaucoup d’édifices historiques dans cette ville."}
155
+ {"id": 154, "concept": "french_language", "pair_id": "french_language-13", "variant": 0, "text": "Il faut acheter du pain et des légumes."}
156
+ {"id": 155, "concept": "french_language", "pair_id": "french_language-13", "variant": 1, "text": "Nous devons prendre du pain ainsi que des légumes."}
157
+ {"id": 156, "concept": "french_language", "pair_id": "french_language-14", "variant": 0, "text": "Je cherche un appartement près de l’université."}
158
+ {"id": 157, "concept": "french_language", "pair_id": "french_language-14", "variant": 1, "text": "Je voudrais trouver un logement proche de l’université."}
159
+ {"id": 158, "concept": "french_language", "pair_id": "french_language-15", "variant": 0, "text": "Merci beaucoup pour votre aide."}
160
+ {"id": 159, "concept": "french_language", "pair_id": "french_language-15", "variant": 1, "text": "Je vous remercie sincèrement de votre aide."}
161
+ {"id": 160, "concept": "factual_entities", "pair_id": "factual_entities-00", "variant": 0, "text": "Tell me about Marie Curie and her scientific work."}
162
+ {"id": 161, "concept": "factual_entities", "pair_id": "factual_entities-00", "variant": 1, "text": "Summarize the scientific contributions of Marie Curie."}
163
+ {"id": 162, "concept": "factual_entities", "pair_id": "factual_entities-01", "variant": 0, "text": "What is notable about the city of Kyoto?"}
164
+ {"id": 163, "concept": "factual_entities", "pair_id": "factual_entities-01", "variant": 1, "text": "Give a short factual overview of Kyoto."}
165
+ {"id": 164, "concept": "factual_entities", "pair_id": "factual_entities-02", "variant": 0, "text": "Explain the role of the Nile in ancient Egypt."}
166
+ {"id": 165, "concept": "factual_entities", "pair_id": "factual_entities-02", "variant": 1, "text": "Describe why the Nile mattered to ancient Egyptian civilization."}
167
+ {"id": 166, "concept": "factual_entities", "pair_id": "factual_entities-03", "variant": 0, "text": "Who was Ada Lovelace?"}
168
+ {"id": 167, "concept": "factual_entities", "pair_id": "factual_entities-03", "variant": 1, "text": "Provide a concise factual description of Ada Lovelace."}
169
+ {"id": 168, "concept": "factual_entities", "pair_id": "factual_entities-04", "variant": 0, "text": "What is Mount Everest?"}
170
+ {"id": 169, "concept": "factual_entities", "pair_id": "factual_entities-04", "variant": 1, "text": "Give basic factual information about Mount Everest."}
171
+ {"id": 170, "concept": "factual_entities", "pair_id": "factual_entities-05", "variant": 0, "text": "Describe the planet Saturn."}
172
+ {"id": 171, "concept": "factual_entities", "pair_id": "factual_entities-05", "variant": 1, "text": "Provide several factual details about Saturn."}
173
+ {"id": 172, "concept": "factual_entities", "pair_id": "factual_entities-06", "variant": 0, "text": "What is the Great Barrier Reef?"}
174
+ {"id": 173, "concept": "factual_entities", "pair_id": "factual_entities-06", "variant": 1, "text": "Give an overview of the Great Barrier Reef."}
175
+ {"id": 174, "concept": "factual_entities", "pair_id": "factual_entities-07", "variant": 0, "text": "Tell me about Ludwig van Beethoven."}
176
+ {"id": 175, "concept": "factual_entities", "pair_id": "factual_entities-07", "variant": 1, "text": "Summarize who Beethoven was and why he is remembered."}
177
+ {"id": 176, "concept": "factual_entities", "pair_id": "factual_entities-08", "variant": 0, "text": "What is the Amazon River?"}
178
+ {"id": 177, "concept": "factual_entities", "pair_id": "factual_entities-08", "variant": 1, "text": "Provide factual information about the Amazon River."}
179
+ {"id": 178, "concept": "factual_entities", "pair_id": "factual_entities-09", "variant": 0, "text": "Explain what the Rosetta Stone is."}
180
+ {"id": 179, "concept": "factual_entities", "pair_id": "factual_entities-09", "variant": 1, "text": "Describe the Rosetta Stone and its historical importance."}
181
+ {"id": 180, "concept": "factual_entities", "pair_id": "factual_entities-10", "variant": 0, "text": "Tell me about the element gold."}
182
+ {"id": 181, "concept": "factual_entities", "pair_id": "factual_entities-10", "variant": 1, "text": "Give a factual overview of the chemical element gold."}
183
+ {"id": 182, "concept": "factual_entities", "pair_id": "factual_entities-11", "variant": 0, "text": "What was the Renaissance?"}
184
+ {"id": 183, "concept": "factual_entities", "pair_id": "factual_entities-11", "variant": 1, "text": "Summarize the historical period known as the Renaissance."}
185
+ {"id": 184, "concept": "factual_entities", "pair_id": "factual_entities-12", "variant": 0, "text": "Describe the city of Hamburg."}
186
+ {"id": 185, "concept": "factual_entities", "pair_id": "factual_entities-12", "variant": 1, "text": "Give several basic facts about Hamburg, Germany."}
187
+ {"id": 186, "concept": "factual_entities", "pair_id": "factual_entities-13", "variant": 0, "text": "Who was Alan Turing?"}
188
+ {"id": 187, "concept": "factual_entities", "pair_id": "factual_entities-13", "variant": 1, "text": "Provide a factual summary of Alan Turing’s life and work."}
189
+ {"id": 188, "concept": "factual_entities", "pair_id": "factual_entities-14", "variant": 0, "text": "What is the Pacific Ocean?"}
190
+ {"id": 189, "concept": "factual_entities", "pair_id": "factual_entities-14", "variant": 1, "text": "Give a concise factual description of the Pacific Ocean."}
191
+ {"id": 190, "concept": "factual_entities", "pair_id": "factual_entities-15", "variant": 0, "text": "Explain what DNA is."}
192
+ {"id": 191, "concept": "factual_entities", "pair_id": "factual_entities-15", "variant": 1, "text": "Provide a factual description of DNA and its biological role."}
193
+ {"id": 192, "concept": "uncertainty", "pair_id": "uncertainty-00", "variant": 0, "text": "I have not given enough information to know which box contains the key."}
194
+ {"id": 193, "concept": "uncertainty", "pair_id": "uncertainty-00", "variant": 1, "text": "From the details provided, the location of the key cannot be determined."}
195
+ {"id": 194, "concept": "uncertainty", "pair_id": "uncertainty-01", "variant": 0, "text": "The evidence is incomplete, so the cause remains uncertain."}
196
+ {"id": 195, "concept": "uncertainty", "pair_id": "uncertainty-01", "variant": 1, "text": "There is insufficient evidence to identify the cause with confidence."}
197
+ {"id": 196, "concept": "uncertainty", "pair_id": "uncertainty-02", "variant": 0, "text": "I do not know which route they chose because the text never says."}
198
+ {"id": 197, "concept": "uncertainty", "pair_id": "uncertainty-02", "variant": 1, "text": "The passage does not specify the route, so the answer is unknown."}
199
+ {"id": 198, "concept": "uncertainty", "pair_id": "uncertainty-03", "variant": 0, "text": "Without the missing measurement, the result cannot be calculated."}
200
+ {"id": 199, "concept": "uncertainty", "pair_id": "uncertainty-03", "variant": 1, "text": "The calculation is underdetermined because a required value is absent."}
201
+ {"id": 200, "concept": "uncertainty", "pair_id": "uncertainty-04", "variant": 0, "text": "The source does not state when the event happened."}
202
+ {"id": 201, "concept": "uncertainty", "pair_id": "uncertainty-04", "variant": 1, "text": "The event date is not provided by the available source."}
203
+ {"id": 202, "concept": "uncertainty", "pair_id": "uncertainty-05", "variant": 0, "text": "Several explanations fit the observations, so no single one is established."}
204
+ {"id": 203, "concept": "uncertainty", "pair_id": "uncertainty-05", "variant": 1, "text": "The observations support multiple possibilities and do not settle on one explanation."}
205
+ {"id": 204, "concept": "uncertainty", "pair_id": "uncertainty-06", "variant": 0, "text": "There is not enough context to identify who the pronoun refers to."}
206
+ {"id": 205, "concept": "uncertainty", "pair_id": "uncertainty-06", "variant": 1, "text": "The pronoun’s referent is ambiguous given the limited context."}
207
+ {"id": 206, "concept": "uncertainty", "pair_id": "uncertainty-07", "variant": 0, "text": "The sample is too small to draw a reliable conclusion."}
208
+ {"id": 207, "concept": "uncertainty", "pair_id": "uncertainty-07", "variant": 1, "text": "A confident conclusion would be unjustified because the sample size is inadequate."}
209
+ {"id": 208, "concept": "uncertainty", "pair_id": "uncertainty-08", "variant": 0, "text": "I cannot verify that claim from the information available."}
210
+ {"id": 209, "concept": "uncertainty", "pair_id": "uncertainty-08", "variant": 1, "text": "The available information is insufficient to confirm the claim."}
211
+ {"id": 210, "concept": "uncertainty", "pair_id": "uncertainty-09", "variant": 0, "text": "The instructions omit the final step, so the intended outcome is unclear."}
212
+ {"id": 211, "concept": "uncertainty", "pair_id": "uncertainty-09", "variant": 1, "text": "Because the last instruction is missing, the desired result cannot be known."}
213
+ {"id": 212, "concept": "uncertainty", "pair_id": "uncertainty-10", "variant": 0, "text": "We have two plausible answers and no evidence that distinguishes them."}
214
+ {"id": 213, "concept": "uncertainty", "pair_id": "uncertainty-10", "variant": 1, "text": "Both answers remain possible because there is no discriminating evidence."}
215
+ {"id": 214, "concept": "uncertainty", "pair_id": "uncertainty-11", "variant": 0, "text": "The report gives a range but not an exact value."}
216
+ {"id": 215, "concept": "uncertainty", "pair_id": "uncertainty-11", "variant": 1, "text": "Only an interval is reported, so the precise value is unspecified."}
217
+ {"id": 216, "concept": "uncertainty", "pair_id": "uncertainty-12", "variant": 0, "text": "The image is too blurry to read the number confidently."}
218
+ {"id": 217, "concept": "uncertainty", "pair_id": "uncertainty-12", "variant": 1, "text": "The number cannot be identified reliably because the image lacks clarity."}
219
+ {"id": 218, "concept": "uncertainty", "pair_id": "uncertainty-13", "variant": 0, "text": "No forecast was provided, so tomorrow’s value is unknown."}
220
+ {"id": 219, "concept": "uncertainty", "pair_id": "uncertainty-13", "variant": 1, "text": "The future value cannot be stated because there is no forecast information."}
221
+ {"id": 220, "concept": "uncertainty", "pair_id": "uncertainty-14", "variant": 0, "text": "The experiment was not repeated, so the finding remains tentative."}
222
+ {"id": 221, "concept": "uncertainty", "pair_id": "uncertainty-14", "variant": 1, "text": "Without replication, the result should be treated as uncertain."}
223
+ {"id": 222, "concept": "uncertainty", "pair_id": "uncertainty-15", "variant": 0, "text": "The text names several candidates but never identifies the winner."}
224
+ {"id": 223, "concept": "uncertainty", "pair_id": "uncertainty-15", "variant": 1, "text": "A winner cannot be determined because the passage lists candidates without a result."}
docs/HF_DEPLOY.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face deployment
2
+
3
+ FeatureLens targets a Gradio SDK Space with ZeroGPU hardware.
4
+
5
+ ## Why Gradio rather than Docker?
6
+
7
+ ZeroGPU currently supports Gradio SDK Spaces. The workbench therefore uses `app.py` plus `requirements.txt` and avoids a Dockerfile.
8
+
9
+ ## Cold start
10
+
11
+ On Hugging Face, `FEATURELENS_EAGER_LOAD` defaults to `1`. The runtime loads Qwen3-1.7B-Base and only the configured Qwen-Scope layer files. The default layer set is 4, 14 and 26.
12
+
13
+ The model and SAEs are placed on `cuda` during startup. ZeroGPU provides CUDA emulation outside GPU-decorated callbacks and a real GPU during `spaces.GPU` execution.
14
+
15
+ ## GPU-decorated functions
16
+
17
+ - feature inspection: 45-second maximum allocation;
18
+ - baseline-vs-modified generation: 60-second maximum allocation.
19
+
20
+ Keeping prompt length and generation length bounded protects the free daily quota and improves queue behavior.
21
+
22
+ ## No persistent benchmark jobs in the Space
23
+
24
+ The full experiment is intentionally offline. A free ZeroGPU quota is appropriate for interactive inspection, not a hundreds-of-forward-passes benchmark. Run `python experiments/run_all.py` elsewhere once, commit the small report/catalog artifacts, and the Space will display them automatically.
docs/METHODOLOGY.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Methodology
2
+
3
+ ## Primary hypothesis
4
+
5
+ A sparse feature that is predictive of a semantic category is not automatically a causal mechanism for behavior. FeatureLens evaluates association and intervention separately.
6
+
7
+ ## Discovery data
8
+
9
+ Seven controlled concepts are represented by 16 independently authored prompt pairs per concept. Each pair contains two paraphrases. The split is grouped by pair id so lexical near-duplicates never cross train and test.
10
+
11
+ The final token residual is used for the offline concept benchmark because it has access to the complete prompt prefix and keeps the activation tensor small enough to reproduce easily. The live workbench is token-selectable and is not restricted to the final token.
12
+
13
+ ## Sparse feature evaluation
14
+
15
+ For each configured layer, FeatureLens stores the TopK SAE code for each sample. Candidate features must fire at least three times on the training split. Features are ranked using training AUROC and an activation-rate contrast tie-break. AUROC and F1 shown in the report are then calculated on the held-out split.
16
+
17
+ This makes the feature catalog a selection procedure rather than a post-hoc leaderboard over the test set.
18
+
19
+ ## Dense baseline
20
+
21
+ A multinomial logistic-regression probe is fit to the dense residual vectors at the same layers. This asks whether the representation carries concept information even if no single SAE feature isolates it cleanly.
22
+
23
+ ## Causal intervention
24
+
25
+ If the SAE code of the selected prompt residual is `z` and the selected feature is `i`, FeatureLens changes only coefficient `z_i`. The resulting decoder delta is added back to the original residual. Full SAE reconstruction is never substituted for the original activation.
26
+
27
+ Ablation uses `Δz_i = -z_i`; 2× amplification uses `Δz_i = z_i`.
28
+
29
+ ## Negative control
30
+
31
+ For every SAE residual perturbation, a deterministic random direction with identical L2 norm is generated and patched at the same layer/token. This does not prove specificity by itself, but it gives a much stronger baseline than reporting an intervention effect without a perturbation control.
32
+
33
+ ## Causal outcomes
34
+
35
+ The causal task set is separate from feature discovery. The main behavioral score is the change in log-probability of the first token of a target completion. The pipeline also stores probability delta, target rank change, JS divergence of the next-token distribution, and whether the top-1 token changed.
36
+
37
+ ## Interpretation
38
+
39
+ The auto-generated report uses conservative heuristics to summarize measured results, but preserves all raw rows. A high predictive AUROC with a small causal effect is explicitly reported as evidence that correlation did not translate into strong downstream control.
experiments/__init__.py ADDED
File without changes
experiments/build_dataset.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parents[1]
7
+ DATA_DIR = ROOT / 'data'
8
+
9
+ PAIRS: dict[str, list[tuple[str, str]]] = {
10
+ 'code': [
11
+ ('Write a Python function that returns the larger of two integers.', 'In Python, create a function that chooses the maximum of two integer inputs.'),
12
+ ('Explain what a recursive function does in a program.', 'Describe recursion in the context of a programming function.'),
13
+ ('Show a JavaScript loop that prints numbers from one to five.', 'Give JavaScript code for iterating over the integers 1 through 5 and logging them.'),
14
+ ('What is the purpose of a hash map in software?', 'Explain why programmers use hash maps or dictionaries.'),
15
+ ('Write SQL that selects every row from a table named users.', 'Provide a SQL query to retrieve all records in the users table.'),
16
+ ('How do I catch an exception in Python?', 'Show the Python syntax for handling an exception with try and except.'),
17
+ ('Explain the difference between a list and a tuple in Python.', 'Compare Python lists with tuples.'),
18
+ ('Write a function that checks whether a string is a palindrome.', 'Create code that tests if text reads the same forwards and backwards.'),
19
+ ('What does git rebase do?', 'Describe the effect of rebasing a Git branch.'),
20
+ ('How can a REST API return JSON?', 'Explain how a web API endpoint sends a JSON response.'),
21
+ ('Write pseudocode for binary search.', 'Describe the binary-search algorithm as pseudocode.'),
22
+ ('What is a class constructor used for?', 'Explain the role of a constructor when creating an object.'),
23
+ ('Give a regular expression that matches a basic email-like string.', 'Show a regex pattern for a simple email address format.'),
24
+ ('Why is time complexity important when comparing algorithms?', 'Explain why Big-O runtime matters in algorithm analysis.'),
25
+ ('How do unit tests help a codebase?', 'Describe the purpose of automated unit testing in software.'),
26
+ ('Write Python that sorts a list of dictionaries by an age key.', 'Show how to order Python dictionaries in a list according to their age field.'),
27
+ ],
28
+ 'mathematics': [
29
+ ('Find the derivative of x cubed plus two x.', 'Differentiate the function x^3 + 2x.'),
30
+ ('Solve the equation 3x + 5 = 20.', 'Find x when three x plus five equals twenty.'),
31
+ ('What is the integral of cosine x?', 'Compute an antiderivative of cos(x).'),
32
+ ('Factor the polynomial x squared minus nine.', 'Rewrite x^2 - 9 as a product of factors.'),
33
+ ('Calculate the mean of 4, 7, 9, and 10.', 'What is the arithmetic average of 4, 7, 9, 10?'),
34
+ ('Explain the Pythagorean theorem.', 'State the relationship among the sides of a right triangle.'),
35
+ ('What is the determinant of a two by two matrix?', 'Give the formula for the determinant of a 2x2 matrix.'),
36
+ ('Simplify two to the third power times two squared.', 'Reduce 2^3 multiplied by 2^2 using exponent rules.'),
37
+ ('Convert one half into a percentage.', 'Express 1/2 as a percent.'),
38
+ ('What is the probability of heads on a fair coin?', 'For a fair coin, calculate the chance of flipping heads.'),
39
+ ('Solve x squared equals sixteen.', 'Find the real values of x satisfying x^2 = 16.'),
40
+ ('What is the slope between points (1,2) and (3,6)?', 'Calculate the gradient of the line through (1,2) and (3,6).'),
41
+ ('Explain what a prime number is.', 'Define a prime integer.'),
42
+ ('Compute the dot product of vectors (1,2) and (3,4).', 'Find (1,2) · (3,4).'),
43
+ ('What is log base ten of one thousand?', 'Evaluate log_10(1000).'),
44
+ ('A circle has radius three. What is its area?', 'Calculate the area of a circle whose radius is 3.'),
45
+ ],
46
+ 'positive_sentiment': [
47
+ ('The film was delightful, clever, and beautifully acted.', 'I found the movie charming, smart, and wonderfully performed.'),
48
+ ('This restaurant served an excellent meal and the staff were kind.', 'The food was fantastic and the service team was genuinely friendly.'),
49
+ ('I am very pleased with how reliable this laptop has been.', 'This laptop has worked dependably and I am extremely satisfied with it.'),
50
+ ('The concert was energetic and unforgettable.', 'I had an amazing time at the lively, memorable concert.'),
51
+ ('Her explanation was clear and genuinely helpful.', 'She explained the topic in a useful and easy-to-understand way.'),
52
+ ('The hotel room was spotless and comfortable.', 'Our room was exceptionally clean and pleasant to stay in.'),
53
+ ('I loved the book from beginning to end.', 'The novel kept me delighted all the way through.'),
54
+ ('The new update makes the app much easier to use.', 'After the update, the application feels significantly more convenient.'),
55
+ ('Their customer support solved my problem quickly.', 'Support handled the issue fast and left me very happy.'),
56
+ ('The hike had spectacular views and perfect weather.', 'We enjoyed gorgeous scenery and wonderful conditions on the hike.'),
57
+ ('The presentation was engaging and well organized.', 'I enjoyed the talk because it was compelling and structured clearly.'),
58
+ ('This camera takes sharp photos and feels great to use.', 'The camera produces crisp images and has a satisfying design.'),
59
+ ('The workshop exceeded my expectations.', 'I was impressed because the workshop was even better than I expected.'),
60
+ ('Dinner turned out wonderfully and everyone enjoyed it.', 'The evening meal was a success and all of us had a great time.'),
61
+ ('The museum exhibition was fascinating.', 'I thought the exhibition was deeply interesting and rewarding.'),
62
+ ('The train journey was smooth and relaxing.', 'The trip by train felt easy, calm, and comfortable.'),
63
+ ],
64
+ 'negative_sentiment': [
65
+ ('The film was tedious, confusing, and badly acted.', 'I found the movie boring, incoherent, and poorly performed.'),
66
+ ('This restaurant served cold food and the staff were rude.', 'The meal arrived cold and the service team behaved unpleasantly.'),
67
+ ('I am disappointed by how unreliable this laptop has been.', 'This laptop keeps failing and I am extremely dissatisfied with it.'),
68
+ ('The concert was chaotic and forgettable.', 'I had a miserable time at the disorganized, dull concert.'),
69
+ ('Her explanation was unclear and unhelpful.', 'She explained the topic in a confusing way that did not help me.'),
70
+ ('The hotel room was dirty and uncomfortable.', 'Our room was unpleasant, unclean, and difficult to relax in.'),
71
+ ('I regretted reading the book.', 'The novel was a frustrating waste of my time.'),
72
+ ('The new update makes the app harder to use.', 'After the update, the application feels significantly more awkward.'),
73
+ ('Their customer support ignored my problem.', 'Support failed to resolve the issue and left me angry.'),
74
+ ('The hike had awful weather and disappointing views.', 'We dealt with terrible conditions and underwhelming scenery on the hike.'),
75
+ ('The presentation was dull and poorly organized.', 'I disliked the talk because it was tedious and structured badly.'),
76
+ ('This camera takes blurry photos and feels cheap.', 'The camera produces soft images and has a flimsy design.'),
77
+ ('The workshop fell far below my expectations.', 'I was disappointed because the workshop was much worse than I expected.'),
78
+ ('Dinner went badly and nobody enjoyed it.', 'The evening meal was a failure and all of us had a poor time.'),
79
+ ('The museum exhibition was painfully boring.', 'I thought the exhibition was dull and unrewarding.'),
80
+ ('The train journey was stressful and uncomfortable.', 'The trip by train felt frustrating, noisy, and unpleasant.'),
81
+ ],
82
+ 'french_language': [
83
+ ('Bonjour, comment allez-vous aujourd’hui ?', 'Salut, comment vas-tu aujourd’hui ?'),
84
+ ('Je voudrais réserver une table pour deux personnes.', 'Puis-je réserver une table pour deux, s’il vous plaît ?'),
85
+ ('La bibliothèque ferme à dix-huit heures.', 'La bibliothèque est ouverte jusqu’à dix-huit heures.'),
86
+ ('Ce livre raconte une histoire très intéressante.', 'L’histoire racontée dans ce livre est vraiment intéressante.'),
87
+ ('Nous allons prendre le train demain matin.', 'Demain matin, nous voyagerons en train.'),
88
+ ('Pouvez-vous m’indiquer le chemin vers la gare ?', 'Comment puis-je aller jusqu’à la gare ?'),
89
+ ('J’aime apprendre de nouvelles langues.', 'Apprendre des langues nouvelles me plaît beaucoup.'),
90
+ ('Le temps est magnifique au bord de la mer.', 'Il fait très beau près de la mer.'),
91
+ ('Elle prépare le dîner dans la cuisine.', 'Dans la cuisine, elle est en train de préparer le repas du soir.'),
92
+ ('Nous avons visité un musée pendant le week-end.', 'Ce week-end, nous sommes allés voir un musée.'),
93
+ ('La réunion commence à neuf heures précises.', 'Le rendez-vous débute exactement à neuf heures.'),
94
+ ('Mon ordinateur ne fonctionne plus correctement.', 'J’ai un problème : mon ordinateur marche mal maintenant.'),
95
+ ('Cette ville possède de nombreux bâtiments historiques.', 'On trouve beaucoup d’édifices historiques dans cette ville.'),
96
+ ('Il faut acheter du pain et des légumes.', 'Nous devons prendre du pain ainsi que des légumes.'),
97
+ ('Je cherche un appartement près de l’université.', 'Je voudrais trouver un logement proche de l’université.'),
98
+ ('Merci beaucoup pour votre aide.', 'Je vous remercie sincèrement de votre aide.'),
99
+ ],
100
+ 'factual_entities': [
101
+ ('Tell me about Marie Curie and her scientific work.', 'Summarize the scientific contributions of Marie Curie.'),
102
+ ('What is notable about the city of Kyoto?', 'Give a short factual overview of Kyoto.'),
103
+ ('Explain the role of the Nile in ancient Egypt.', 'Describe why the Nile mattered to ancient Egyptian civilization.'),
104
+ ('Who was Ada Lovelace?', 'Provide a concise factual description of Ada Lovelace.'),
105
+ ('What is Mount Everest?', 'Give basic factual information about Mount Everest.'),
106
+ ('Describe the planet Saturn.', 'Provide several factual details about Saturn.'),
107
+ ('What is the Great Barrier Reef?', 'Give an overview of the Great Barrier Reef.'),
108
+ ('Tell me about Ludwig van Beethoven.', 'Summarize who Beethoven was and why he is remembered.'),
109
+ ('What is the Amazon River?', 'Provide factual information about the Amazon River.'),
110
+ ('Explain what the Rosetta Stone is.', 'Describe the Rosetta Stone and its historical importance.'),
111
+ ('Tell me about the element gold.', 'Give a factual overview of the chemical element gold.'),
112
+ ('What was the Renaissance?', 'Summarize the historical period known as the Renaissance.'),
113
+ ('Describe the city of Hamburg.', 'Give several basic facts about Hamburg, Germany.'),
114
+ ('Who was Alan Turing?', 'Provide a factual summary of Alan Turing’s life and work.'),
115
+ ('What is the Pacific Ocean?', 'Give a concise factual description of the Pacific Ocean.'),
116
+ ('Explain what DNA is.', 'Provide a factual description of DNA and its biological role.'),
117
+ ],
118
+ 'uncertainty': [
119
+ ('I have not given enough information to know which box contains the key.', 'From the details provided, the location of the key cannot be determined.'),
120
+ ('The evidence is incomplete, so the cause remains uncertain.', 'There is insufficient evidence to identify the cause with confidence.'),
121
+ ('I do not know which route they chose because the text never says.', 'The passage does not specify the route, so the answer is unknown.'),
122
+ ('Without the missing measurement, the result cannot be calculated.', 'The calculation is underdetermined because a required value is absent.'),
123
+ ('The source does not state when the event happened.', 'The event date is not provided by the available source.'),
124
+ ('Several explanations fit the observations, so no single one is established.', 'The observations support multiple possibilities and do not settle on one explanation.'),
125
+ ('There is not enough context to identify who the pronoun refers to.', 'The pronoun’s referent is ambiguous given the limited context.'),
126
+ ('The sample is too small to draw a reliable conclusion.', 'A confident conclusion would be unjustified because the sample size is inadequate.'),
127
+ ('I cannot verify that claim from the information available.', 'The available information is insufficient to confirm the claim.'),
128
+ ('The instructions omit the final step, so the intended outcome is unclear.', 'Because the last instruction is missing, the desired result cannot be known.'),
129
+ ('We have two plausible answers and no evidence that distinguishes them.', 'Both answers remain possible because there is no discriminating evidence.'),
130
+ ('The report gives a range but not an exact value.', 'Only an interval is reported, so the precise value is unspecified.'),
131
+ ('The image is too blurry to read the number confidently.', 'The number cannot be identified reliably because the image lacks clarity.'),
132
+ ('No forecast was provided, so tomorrow’s value is unknown.', 'The future value cannot be stated because there is no forecast information.'),
133
+ ('The experiment was not repeated, so the finding remains tentative.', 'Without replication, the result should be treated as uncertain.'),
134
+ ('The text names several candidates but never identifies the winner.', 'A winner cannot be determined because the passage lists candidates without a result.'),
135
+ ],
136
+ }
137
+
138
+ CAUSAL_TASKS = [
139
+ {'concept': 'positive_sentiment', 'prompt': 'Sentiment: The meal was wonderful and the staff were kind. Label:', 'target': ' positive'},
140
+ {'concept': 'positive_sentiment', 'prompt': 'Sentiment: I loved the thoughtful, beautifully written novel. Label:', 'target': ' positive'},
141
+ {'concept': 'positive_sentiment', 'prompt': 'Sentiment: The service was fast and extremely helpful. Label:', 'target': ' positive'},
142
+ {'concept': 'positive_sentiment', 'prompt': 'Sentiment: The journey was comfortable and enjoyable. Label:', 'target': ' positive'},
143
+ {'concept': 'negative_sentiment', 'prompt': 'Sentiment: The meal was awful and the staff were rude. Label:', 'target': ' negative'},
144
+ {'concept': 'negative_sentiment', 'prompt': 'Sentiment: I hated the tedious, badly written novel. Label:', 'target': ' negative'},
145
+ {'concept': 'negative_sentiment', 'prompt': 'Sentiment: The service was slow and completely unhelpful. Label:', 'target': ' negative'},
146
+ {'concept': 'negative_sentiment', 'prompt': 'Sentiment: The journey was stressful and unpleasant. Label:', 'target': ' negative'},
147
+ {'concept': 'mathematics', 'prompt': '2 + 3 =', 'target': ' 5'},
148
+ {'concept': 'mathematics', 'prompt': '7 - 4 =', 'target': ' 3'},
149
+ {'concept': 'mathematics', 'prompt': '6 * 2 =', 'target': ' 12'},
150
+ {'concept': 'mathematics', 'prompt': 'The square root of 81 is', 'target': ' 9'},
151
+ {'concept': 'code', 'prompt': 'Python function declaration keyword:', 'target': ' def'},
152
+ {'concept': 'code', 'prompt': 'In Python, an exception handler begins with the keyword', 'target': ' except'},
153
+ {'concept': 'code', 'prompt': 'SQL keyword used to retrieve rows:', 'target': ' SELECT'},
154
+ {'concept': 'code', 'prompt': 'A Python conditional branch commonly starts with', 'target': ' if'},
155
+ {'concept': 'french_language', 'prompt': "Translate 'hello' into French:", 'target': ' bonjour'},
156
+ {'concept': 'french_language', 'prompt': "Translate 'thank you' into French:", 'target': ' merci'},
157
+ {'concept': 'french_language', 'prompt': "Translate 'yes' into French:", 'target': ' oui'},
158
+ {'concept': 'french_language', 'prompt': "Translate 'good evening' into French:", 'target': ' bonsoir'},
159
+ {'concept': 'factual_entities', 'prompt': 'The scientist associated with radium research, Marie', 'target': ' Curie'},
160
+ {'concept': 'factual_entities', 'prompt': 'The composer of the Fifth Symphony, Ludwig van', 'target': ' Beethoven'},
161
+ {'concept': 'factual_entities', 'prompt': 'The computer scientist known for the Turing machine, Alan', 'target': ' Turing'},
162
+ {'concept': 'factual_entities', 'prompt': 'The Japanese city famous for many historic temples,', 'target': ' Kyoto'},
163
+ {'concept': 'uncertainty', 'prompt': 'The evidence is insufficient. The answer is', 'target': ' unknown'},
164
+ {'concept': 'uncertainty', 'prompt': 'The passage never states the value, so it is', 'target': ' unknown'},
165
+ {'concept': 'uncertainty', 'prompt': 'There is not enough information to determine the result. It remains', 'target': ' uncertain'},
166
+ {'concept': 'uncertainty', 'prompt': 'No reliable conclusion can be drawn; the outcome is', 'target': ' unclear'},
167
+ ]
168
+
169
+
170
+ def main() -> None:
171
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
172
+ prompt_path = DATA_DIR / 'prompts.jsonl'
173
+ with prompt_path.open('w', encoding='utf-8') as handle:
174
+ sample_id = 0
175
+ for concept, pairs in PAIRS.items():
176
+ for pair_num, (a, b) in enumerate(pairs):
177
+ pair_id = f'{concept}-{pair_num:02d}'
178
+ for variant, text in enumerate((a, b)):
179
+ record = {
180
+ 'id': sample_id,
181
+ 'concept': concept,
182
+ 'pair_id': pair_id,
183
+ 'variant': variant,
184
+ 'text': text,
185
+ }
186
+ handle.write(json.dumps(record, ensure_ascii=False) + '\n')
187
+ sample_id += 1
188
+
189
+ causal_path = DATA_DIR / 'causal_tasks.jsonl'
190
+ with causal_path.open('w', encoding='utf-8') as handle:
191
+ for idx, task in enumerate(CAUSAL_TASKS):
192
+ record = {'id': idx, **task}
193
+ handle.write(json.dumps(record, ensure_ascii=False) + '\n')
194
+
195
+ print(f'Wrote {sample_id} discovery prompts to {prompt_path}')
196
+ print(f'Wrote {len(CAUSAL_TASKS)} causal tasks to {causal_path}')
197
+
198
+
199
+ if __name__ == '__main__':
200
+ main()
experiments/collect_activations.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import scipy.sparse as sp
9
+ import torch
10
+ from transformers import AutoModelForCausalLM, AutoTokenizer
11
+
12
+ from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
13
+ from featurelens.config import SETTINGS
14
+ from featurelens.metrics import reconstruction_metrics
15
+ from featurelens.sae import SAEStore
16
+
17
+
18
+ def parse_args() -> argparse.Namespace:
19
+ parser = argparse.ArgumentParser(description='Collect residual and Qwen-Scope SAE activations.')
20
+ parser.add_argument('--input', type=Path, default=DATA_DIR / 'prompts.jsonl')
21
+ parser.add_argument('--output-dir', type=Path, default=ARTIFACT_DIR / 'activations')
22
+ parser.add_argument('--batch-size', type=int, default=16)
23
+ parser.add_argument('--max-length', type=int, default=192)
24
+ parser.add_argument('--seed', type=int, default=42)
25
+ parser.add_argument('--layers', type=int, nargs='+', default=list(SETTINGS.layers))
26
+ return parser.parse_args()
27
+
28
+
29
+ def _build_sparse(encodings: list, n_rows: int, width: int) -> sp.csr_matrix:
30
+ row_ids: list[int] = []
31
+ col_ids: list[int] = []
32
+ values: list[float] = []
33
+ for row, encoding in enumerate(encodings):
34
+ idx = encoding.indices.detach().cpu().numpy().reshape(-1)
35
+ vals = encoding.values.detach().float().cpu().numpy().reshape(-1)
36
+ positive = vals > 0
37
+ row_ids.extend([row] * int(positive.sum()))
38
+ col_ids.extend(idx[positive].astype(int).tolist())
39
+ values.extend(vals[positive].astype(float).tolist())
40
+ return sp.csr_matrix((values, (row_ids, col_ids)), shape=(n_rows, width), dtype=np.float32)
41
+
42
+
43
+ @torch.inference_mode()
44
+ def main() -> None:
45
+ args = parse_args()
46
+ set_seed(args.seed)
47
+ rows = load_jsonl(args.input)
48
+ args.output_dir.mkdir(parents=True, exist_ok=True)
49
+
50
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
51
+ model_dtype = torch.float16 if device.type == 'cuda' else torch.float32
52
+ tokenizer = AutoTokenizer.from_pretrained(SETTINGS.model_id)
53
+ if tokenizer.pad_token_id is None:
54
+ tokenizer.pad_token = tokenizer.eos_token
55
+ tokenizer.padding_side = 'left'
56
+ model = AutoModelForCausalLM.from_pretrained(
57
+ SETTINGS.model_id,
58
+ torch_dtype=model_dtype,
59
+ low_cpu_mem_usage=True,
60
+ ).to(device)
61
+ model.eval()
62
+ sae_store = SAEStore(
63
+ SETTINGS.sae_repo_id,
64
+ layers=args.layers,
65
+ device=device,
66
+ dtype=torch.float32,
67
+ top_k=SETTINGS.sae_top_k,
68
+ )
69
+
70
+ residuals: dict[int, list[np.ndarray]] = {layer: [] for layer in args.layers}
71
+ encodings: dict[int, list] = {layer: [] for layer in args.layers}
72
+ recon_stats: dict[int, list[dict[str, float]]] = {layer: [] for layer in args.layers}
73
+
74
+ for start in range(0, len(rows), args.batch_size):
75
+ batch_rows = rows[start : start + args.batch_size]
76
+ texts = [row['text'] for row in batch_rows]
77
+ batch = tokenizer(
78
+ texts,
79
+ return_tensors='pt',
80
+ padding=True,
81
+ truncation=True,
82
+ max_length=args.max_length,
83
+ )
84
+ batch = {key: value.to(device) for key, value in batch.items()}
85
+ captured: dict[int, torch.Tensor] = {}
86
+ handles = []
87
+
88
+ def make_hook(layer: int):
89
+ def hook(_module, _inputs, output):
90
+ hidden = output[0] if isinstance(output, tuple) else output
91
+ captured[layer] = hidden.detach()
92
+
93
+ return hook
94
+
95
+ for layer in args.layers:
96
+ handles.append(model.model.layers[layer].register_forward_hook(make_hook(layer)))
97
+ model(**batch, use_cache=False)
98
+ for handle in handles:
99
+ handle.remove()
100
+
101
+ for layer in args.layers:
102
+ sae = sae_store.get(layer)
103
+ final_token_residuals = captured[layer][:, -1, :]
104
+ batch_encoding = sae.encode(final_token_residuals)
105
+ for row_idx in range(final_token_residuals.shape[0]):
106
+ residual = final_token_residuals[row_idx]
107
+ from featurelens.sae import SparseEncoding
108
+
109
+ encoding = SparseEncoding(
110
+ indices=batch_encoding.indices[row_idx],
111
+ values=batch_encoding.values[row_idx],
112
+ )
113
+ reconstructed = sae.decode_sparse(encoding)
114
+ residuals[layer].append(residual.detach().float().cpu().numpy().astype(np.float16))
115
+ encodings[layer].append(encoding)
116
+ recon_stats[layer].append(reconstruction_metrics(residual, reconstructed))
117
+
118
+ print(f'Processed {min(start + args.batch_size, len(rows))}/{len(rows)} prompts', flush=True)
119
+
120
+ for layer in args.layers:
121
+ residual_array = np.stack(residuals[layer], axis=0)
122
+ np.save(args.output_dir / f'residuals_layer{layer}.npy', residual_array)
123
+ sparse = _build_sparse(encodings[layer], len(rows), SETTINGS.sae_width)
124
+ sp.save_npz(args.output_dir / f'features_layer{layer}.npz', sparse, compressed=True)
125
+ summary = {
126
+ 'layer': layer,
127
+ 'n_samples': len(rows),
128
+ 'mean_cosine': float(np.mean([x['cosine'] for x in recon_stats[layer]])),
129
+ 'mean_nmse': float(np.mean([x['nmse'] for x in recon_stats[layer]])),
130
+ 'median_nmse': float(np.median([x['nmse'] for x in recon_stats[layer]])),
131
+ 'mean_active_features': float(np.mean(np.diff(sparse.indptr))),
132
+ }
133
+ (args.output_dir / f'reconstruction_layer{layer}.json').write_text(
134
+ json.dumps(summary, indent=2), encoding='utf-8'
135
+ )
136
+
137
+ metadata = {
138
+ 'model_id': SETTINGS.model_id,
139
+ 'sae_repo_id': SETTINGS.sae_repo_id,
140
+ 'layers': args.layers,
141
+ 'top_k': SETTINGS.sae_top_k,
142
+ 'width': SETTINGS.sae_width,
143
+ 'n_samples': len(rows),
144
+ 'rows': rows,
145
+ }
146
+ (args.output_dir / 'metadata.json').write_text(json.dumps(metadata, indent=2), encoding='utf-8')
147
+ print(f'Activation artifacts written to {args.output_dir}')
148
+
149
+
150
+ if __name__ == '__main__':
151
+ main()
experiments/common.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import torch
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ DATA_DIR = ROOT / 'data'
12
+ ARTIFACT_DIR = ROOT / 'artifacts'
13
+
14
+
15
+ def load_jsonl(path: str | Path) -> list[dict]:
16
+ rows = []
17
+ with Path(path).open(encoding='utf-8') as handle:
18
+ for line in handle:
19
+ line = line.strip()
20
+ if line:
21
+ rows.append(json.loads(line))
22
+ return rows
23
+
24
+
25
+ def set_seed(seed: int) -> None:
26
+ random.seed(seed)
27
+ np.random.seed(seed)
28
+ torch.manual_seed(seed)
29
+ if torch.cuda.is_available():
30
+ torch.cuda.manual_seed_all(seed)
experiments/evaluate_features.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ from collections import defaultdict
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import scipy.sparse as sp
11
+ from sklearn.linear_model import LogisticRegression
12
+ from sklearn.metrics import f1_score, precision_recall_curve, roc_auc_score
13
+ from sklearn.pipeline import make_pipeline
14
+ from sklearn.preprocessing import LabelEncoder, StandardScaler
15
+
16
+ from experiments.common import ARTIFACT_DIR
17
+ from experiments.split import grouped_concept_split
18
+
19
+
20
+ def parse_args() -> argparse.Namespace:
21
+ parser = argparse.ArgumentParser(description='Evaluate predictive SAE features and residual probes.')
22
+ parser.add_argument('--activation-dir', type=Path, default=ARTIFACT_DIR / 'activations')
23
+ parser.add_argument('--output-dir', type=Path, default=ARTIFACT_DIR)
24
+ parser.add_argument('--top-features', type=int, default=20)
25
+ parser.add_argument('--min-train-fires', type=int, default=3)
26
+ parser.add_argument('--seed', type=int, default=42)
27
+ return parser.parse_args()
28
+
29
+
30
+ def _best_threshold(y_true: np.ndarray, scores: np.ndarray) -> float:
31
+ precision, recall, thresholds = precision_recall_curve(y_true, scores)
32
+ if thresholds.size == 0:
33
+ return 0.0
34
+ denom = precision[:-1] + recall[:-1]
35
+ f1 = np.divide(
36
+ 2 * precision[:-1] * recall[:-1],
37
+ denom,
38
+ out=np.zeros_like(denom),
39
+ where=denom > 0,
40
+ )
41
+ return float(thresholds[int(np.argmax(f1))])
42
+
43
+
44
+ def _evaluate_feature(
45
+ train_scores: np.ndarray,
46
+ test_scores: np.ndarray,
47
+ y_train: np.ndarray,
48
+ y_test: np.ndarray,
49
+ ) -> tuple[float, float, float]:
50
+ threshold = _best_threshold(y_train, train_scores)
51
+ train_auc = float(roc_auc_score(y_train, train_scores))
52
+ test_auc = float(roc_auc_score(y_test, test_scores))
53
+ pred = (test_scores >= threshold).astype(int)
54
+ f1 = float(f1_score(y_test, pred, zero_division=0))
55
+ return train_auc, test_auc, f1, threshold
56
+
57
+
58
+ def _sparse_cosine(a: sp.csr_matrix, b: sp.csr_matrix) -> float:
59
+ numerator = float(a.multiply(b).sum())
60
+ denom = float(np.sqrt(a.multiply(a).sum()) * np.sqrt(b.multiply(b).sum()))
61
+ return numerator / denom if denom > 0 else 1.0
62
+
63
+
64
+ def _jaccard(a: sp.csr_matrix, b: sp.csr_matrix) -> float:
65
+ sa = set(a.indices.tolist())
66
+ sb = set(b.indices.tolist())
67
+ union = sa | sb
68
+ return len(sa & sb) / len(union) if union else 1.0
69
+
70
+
71
+ def main() -> None:
72
+ args = parse_args()
73
+ args.output_dir.mkdir(parents=True, exist_ok=True)
74
+ metadata = json.loads((args.activation_dir / 'metadata.json').read_text(encoding='utf-8'))
75
+ rows = metadata['rows']
76
+ layers = [int(x) for x in metadata['layers']]
77
+ train_idx, test_idx = grouped_concept_split(rows, seed=args.seed)
78
+ labels = np.array([row['concept'] for row in rows])
79
+ concepts = sorted(set(labels.tolist()))
80
+
81
+ feature_rows: list[dict] = []
82
+ layer_rows: list[dict] = []
83
+ stability_rows: list[dict] = []
84
+
85
+ encoder = LabelEncoder().fit(labels)
86
+ y_all = encoder.transform(labels)
87
+ y_train_multi = y_all[train_idx]
88
+ y_test_multi = y_all[test_idx]
89
+
90
+ for layer in layers:
91
+ x = sp.load_npz(args.activation_dir / f'features_layer{layer}.npz').tocsr()
92
+ x_csc = x.tocsc()
93
+ residuals = np.load(args.activation_dir / f'residuals_layer{layer}.npy').astype(np.float32)
94
+ recon = json.loads(
95
+ (args.activation_dir / f'reconstruction_layer{layer}.json').read_text(encoding='utf-8')
96
+ )
97
+
98
+ probe = make_pipeline(
99
+ StandardScaler(),
100
+ LogisticRegression(max_iter=2500, class_weight='balanced', random_state=args.seed),
101
+ )
102
+ probe.fit(residuals[train_idx], y_train_multi)
103
+ pred = probe.predict(residuals[test_idx])
104
+ probs = probe.predict_proba(residuals[test_idx])
105
+ probe_f1 = float(f1_score(y_test_multi, pred, average='macro'))
106
+ probe_auc = float(
107
+ roc_auc_score(y_test_multi, probs, multi_class='ovr', average='macro')
108
+ )
109
+
110
+ layer_rows.append(
111
+ {
112
+ 'layer': layer,
113
+ 'linear_probe_macro_auroc': probe_auc,
114
+ 'linear_probe_macro_f1': probe_f1,
115
+ 'reconstruction_cosine': recon['mean_cosine'],
116
+ 'reconstruction_nmse': recon['mean_nmse'],
117
+ 'mean_active_features': recon['mean_active_features'],
118
+ }
119
+ )
120
+
121
+ train_matrix = x[train_idx]
122
+ candidate_ids, counts = np.unique(train_matrix.indices, return_counts=True)
123
+ candidate_ids = candidate_ids[counts >= args.min_train_fires]
124
+
125
+ for concept in concepts:
126
+ y_train = (labels[train_idx] == concept).astype(int)
127
+ y_test = (labels[test_idx] == concept).astype(int)
128
+ concept_results: list[dict] = []
129
+ for feature_id in candidate_ids.tolist():
130
+ train_scores = x_csc[train_idx, feature_id].toarray().ravel()
131
+ if int((train_scores > 0).sum()) < args.min_train_fires:
132
+ continue
133
+ test_scores = x_csc[test_idx, feature_id].toarray().ravel()
134
+ train_auc, test_auc, f1, threshold = _evaluate_feature(
135
+ train_scores, test_scores, y_train, y_test
136
+ )
137
+ pos_train = train_scores[y_train == 1]
138
+ neg_train = train_scores[y_train == 0]
139
+ result = {
140
+ 'layer': layer,
141
+ 'concept': concept,
142
+ 'feature_id': int(feature_id),
143
+ 'train_auroc': train_auc,
144
+ 'auroc': test_auc,
145
+ 'f1': f1,
146
+ 'threshold': threshold,
147
+ 'activation_rate_pos': float(np.mean(pos_train > 0)),
148
+ 'activation_rate_neg': float(np.mean(neg_train > 0)),
149
+ 'mean_activation_pos': float(np.mean(pos_train)),
150
+ 'mean_activation_neg': float(np.mean(neg_train)),
151
+ }
152
+ concept_results.append(result)
153
+ concept_results.sort(
154
+ key=lambda item: (item['train_auroc'], item['activation_rate_pos'] - item['activation_rate_neg']),
155
+ reverse=True,
156
+ )
157
+ feature_rows.extend(concept_results[: args.top_features])
158
+
159
+ pair_map: dict[str, list[int]] = defaultdict(list)
160
+ for idx, row in enumerate(rows):
161
+ pair_map[row['pair_id']].append(idx)
162
+ for pair_id, indices in pair_map.items():
163
+ if len(indices) != 2:
164
+ continue
165
+ a, b = indices
166
+ stability_rows.append(
167
+ {
168
+ 'layer': layer,
169
+ 'pair_id': pair_id,
170
+ 'concept': rows[a]['concept'],
171
+ 'topk_jaccard': _jaccard(x.getrow(a), x.getrow(b)),
172
+ 'sparse_cosine': _sparse_cosine(x.getrow(a), x.getrow(b)),
173
+ }
174
+ )
175
+ print(f'Evaluated layer {layer}', flush=True)
176
+
177
+ with (args.output_dir / 'feature_catalog.csv').open('w', newline='', encoding='utf-8') as handle:
178
+ writer = csv.DictWriter(handle, fieldnames=list(feature_rows[0].keys()))
179
+ writer.writeheader()
180
+ writer.writerows(feature_rows)
181
+
182
+ with (args.output_dir / 'layer_metrics.csv').open('w', newline='', encoding='utf-8') as handle:
183
+ writer = csv.DictWriter(handle, fieldnames=list(layer_rows[0].keys()))
184
+ writer.writeheader()
185
+ writer.writerows(layer_rows)
186
+
187
+ with (args.output_dir / 'stability.csv').open('w', newline='', encoding='utf-8') as handle:
188
+ writer = csv.DictWriter(handle, fieldnames=list(stability_rows[0].keys()))
189
+ writer.writeheader()
190
+ writer.writerows(stability_rows)
191
+
192
+ split_payload = {
193
+ 'seed': args.seed,
194
+ 'train_indices': train_idx,
195
+ 'test_indices': test_idx,
196
+ 'n_train': len(train_idx),
197
+ 'n_test': len(test_idx),
198
+ }
199
+ (args.output_dir / 'split.json').write_text(json.dumps(split_payload, indent=2), encoding='utf-8')
200
+ print(f'Wrote evaluation artifacts to {args.output_dir}')
201
+
202
+
203
+ if __name__ == '__main__':
204
+ main()
experiments/make_report.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from experiments.common import ARTIFACT_DIR
12
+
13
+
14
+ def parse_args() -> argparse.Namespace:
15
+ parser = argparse.ArgumentParser(description='Build a truthful experiment report from saved metrics.')
16
+ parser.add_argument('--artifact-dir', type=Path, default=ARTIFACT_DIR)
17
+ return parser.parse_args()
18
+
19
+
20
+ def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
21
+ scored = catalog.copy()
22
+ scored['activation_contrast'] = (
23
+ scored['activation_rate_pos'] - scored['activation_rate_neg']
24
+ )
25
+ ordered = scored.sort_values(
26
+ ['concept', 'train_auroc', 'activation_contrast'],
27
+ ascending=[True, False, False],
28
+ )
29
+ return ordered.groupby('concept', as_index=False).first()
30
+
31
+
32
+ def _save_plots(artifact_dir: Path, selected: pd.DataFrame, layers: pd.DataFrame, causal: pd.DataFrame) -> None:
33
+ fig_dir = artifact_dir / 'figures'
34
+ fig_dir.mkdir(parents=True, exist_ok=True)
35
+
36
+ figure = plt.figure(figsize=(7.5, 4.2))
37
+ ax = figure.add_subplot(111)
38
+ ordered = selected.sort_values('auroc')
39
+ ax.barh(ordered['concept'], ordered['auroc'])
40
+ ax.axvline(0.5, linewidth=1, linestyle='--')
41
+ ax.set_xlabel('Held-out AUROC')
42
+ ax.set_title('Selected SAE feature predictiveness')
43
+ figure.tight_layout()
44
+ figure.savefig(fig_dir / 'feature_auroc.png', dpi=160)
45
+ plt.close(figure)
46
+
47
+ figure = plt.figure(figsize=(7.0, 4.2))
48
+ ax = figure.add_subplot(111)
49
+ ax.plot(layers['layer'], layers['linear_probe_macro_auroc'], marker='o', label='Linear probe AUROC')
50
+ ax.plot(layers['layer'], layers['reconstruction_cosine'], marker='o', label='SAE reconstruction cosine')
51
+ ax.set_xlabel('Layer')
52
+ ax.set_ylim(0, 1.05)
53
+ ax.set_title('Layer-wise representation diagnostics')
54
+ ax.legend()
55
+ figure.tight_layout()
56
+ figure.savefig(fig_dir / 'layer_diagnostics.png', dpi=160)
57
+ plt.close(figure)
58
+
59
+ grouped = (
60
+ causal.groupby(['intervention', 'condition'])['target_logprob_delta']
61
+ .apply(lambda values: float(np.mean(np.abs(values))))
62
+ .reset_index(name='mean_abs_delta_logp')
63
+ )
64
+ pivot = grouped.pivot(index='intervention', columns='condition', values='mean_abs_delta_logp')
65
+ figure = plt.figure(figsize=(7.0, 4.2))
66
+ ax = figure.add_subplot(111)
67
+ pivot.plot(kind='bar', ax=ax)
68
+ ax.set_ylabel('Mean |Δ log p(target)|')
69
+ ax.set_title('SAE interventions vs norm-matched random controls')
70
+ ax.tick_params(axis='x', rotation=0)
71
+ figure.tight_layout()
72
+ figure.savefig(fig_dir / 'causal_effects.png', dpi=160)
73
+ plt.close(figure)
74
+
75
+
76
+ def main() -> None:
77
+ args = parse_args()
78
+ catalog = pd.read_csv(args.artifact_dir / 'feature_catalog.csv')
79
+ layers = pd.read_csv(args.artifact_dir / 'layer_metrics.csv')
80
+ stability = pd.read_csv(args.artifact_dir / 'stability.csv')
81
+ causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
82
+ selected = _selected_features(catalog)
83
+ _save_plots(args.artifact_dir, selected, layers, causal)
84
+
85
+ mean_auc = float(selected['auroc'].mean())
86
+ median_auc = float(selected['auroc'].median())
87
+ best_layer_row = layers.sort_values('linear_probe_macro_auroc', ascending=False).iloc[0]
88
+ mean_jaccard = float(stability['topk_jaccard'].mean())
89
+ mean_sparse_cos = float(stability['sparse_cosine'].mean())
90
+
91
+ sae = causal[causal['condition'] == 'sae_feature']
92
+ random = causal[causal['condition'] == 'random_norm_matched']
93
+ sae_abs = float(np.mean(np.abs(sae['target_logprob_delta'])))
94
+ random_abs = float(np.mean(np.abs(random['target_logprob_delta'])))
95
+ ratio = sae_abs / max(random_abs, 1e-12)
96
+ active_rate = float(np.mean(sae['feature_activation'] > 0))
97
+ top1_change = float(sae['top1_changed'].mean())
98
+
99
+ if mean_auc >= 0.8 and sae_abs < 0.08:
100
+ interpretation = (
101
+ 'The selected sparse features were strongly predictive on held-out prompts, but their '
102
+ 'interventions produced only modest downstream target-probability changes. This supports '
103
+ 'the project’s central warning that representation-level correlation need not imply strong '
104
+ 'causal control.'
105
+ )
106
+ elif mean_auc >= 0.8 and sae_abs >= 0.08 and ratio >= 1.5:
107
+ interpretation = (
108
+ 'The selected sparse features were strongly predictive and their interventions produced '
109
+ 'larger target-probability shifts than norm-matched random residual perturbations, providing '
110
+ 'evidence that at least some predictive features also have downstream causal influence.'
111
+ )
112
+ elif mean_auc < 0.65:
113
+ interpretation = (
114
+ 'Feature/concept predictiveness was limited on held-out prompts, so strong causal claims '
115
+ 'would be premature. The main result is diagnostic: the chosen concepts or feature-selection '
116
+ 'procedure should be refined before interpreting intervention effects.'
117
+ )
118
+ else:
119
+ interpretation = (
120
+ 'The results show mixed predictive and causal evidence. FeatureLens therefore reports the '
121
+ 'association and intervention measurements separately rather than collapsing them into a '
122
+ 'single interpretability score.'
123
+ )
124
+
125
+ headline = (
126
+ f'Selected SAE features averaged {mean_auc:.3f} held-out AUROC; SAE interventions changed '
127
+ f'target log-probability by {sae_abs:.3f} on average versus {random_abs:.3f} for norm-matched '
128
+ 'random residual controls.'
129
+ )
130
+ highlights = [
131
+ f'Median selected-feature held-out AUROC: {median_auc:.3f}.',
132
+ f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
133
+ f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
134
+ f'Selected feature active on {active_rate:.1%} of causal prompts; modified top-1 token on {top1_change:.1%}.',
135
+ f'Mean absolute causal effect / random-control effect ratio: {ratio:.2f}×.',
136
+ ]
137
+ summary = {
138
+ 'headline': headline,
139
+ 'highlights': highlights,
140
+ 'interpretation': interpretation,
141
+ 'metrics': {
142
+ 'mean_selected_feature_test_auroc': mean_auc,
143
+ 'median_selected_feature_test_auroc': median_auc,
144
+ 'best_linear_probe_layer': int(best_layer_row['layer']),
145
+ 'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
146
+ 'mean_paraphrase_topk_jaccard': mean_jaccard,
147
+ 'mean_paraphrase_sparse_cosine': mean_sparse_cos,
148
+ 'mean_abs_sae_target_logprob_delta': sae_abs,
149
+ 'mean_abs_random_target_logprob_delta': random_abs,
150
+ 'causal_to_random_effect_ratio': ratio,
151
+ 'causal_prompt_feature_active_rate': active_rate,
152
+ 'sae_top1_change_rate': top1_change,
153
+ },
154
+ }
155
+ (args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
156
+
157
+ lines = [
158
+ '# FeatureLens experiment report',
159
+ '',
160
+ '## Research question',
161
+ '',
162
+ '**Do sparse features that predict a concept also causally influence model behaviour?**',
163
+ '',
164
+ '## Executive summary',
165
+ '',
166
+ headline,
167
+ '',
168
+ interpretation,
169
+ '',
170
+ '## Key measurements',
171
+ '',
172
+ *[f'- {item}' for item in highlights],
173
+ '',
174
+ '## Experimental design',
175
+ '',
176
+ '- Model: Qwen3-1.7B-Base.',
177
+ '- SAEs: Qwen-Scope residual-stream TopK SAEs at the configured early/middle/late layers.',
178
+ '- Discovery set: controlled concept prompts with paired paraphrases.',
179
+ '- Split discipline: paraphrase groups are kept entirely in train or held-out test.',
180
+ '- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
181
+ '- Linear baseline: multinomial logistic regression on the dense residual stream.',
182
+ '- Causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
183
+ '- Negative control: deterministic random residual direction matched to the SAE perturbation L2 norm.',
184
+ '- Behavioural metric: first-token target probability/log-probability, rank, JS divergence, and top-1 changes.',
185
+ '',
186
+ '## Figures',
187
+ '',
188
+ '![Feature AUROC](figures/feature_auroc.png)',
189
+ '',
190
+ '![Layer diagnostics](figures/layer_diagnostics.png)',
191
+ '',
192
+ '![Causal effects](figures/causal_effects.png)',
193
+ '',
194
+ '## Interpretation guardrails',
195
+ '',
196
+ 'A high feature/concept AUROC is treated as correlational evidence only. Causal evidence requires a downstream change under intervention and is interpreted relative to the norm-matched random control. The narrative above is generated from saved metrics; no result values are hard-coded.',
197
+ '',
198
+ '## Reproducibility',
199
+ '',
200
+ 'Run `python experiments/run_all.py` from the repository root. Raw activation matrices, splits, selected features, causal rows, figures, and this report are all materialized under `artifacts/`.',
201
+ '',
202
+ ]
203
+ (args.artifact_dir / 'report.md').write_text('\n'.join(lines), encoding='utf-8')
204
+ print(headline)
205
+ print(f'Wrote {args.artifact_dir / "report.md"}')
206
+
207
+
208
+ if __name__ == '__main__':
209
+ main()
experiments/run_all.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+
9
+
10
+ def run(script: str, *args: str) -> None:
11
+ module = f"experiments.{Path(script).stem}"
12
+ command = [sys.executable, '-m', module, *args]
13
+ print('\n$', ' '.join(command), flush=True)
14
+ subprocess.run(command, cwd=ROOT, check=True)
15
+
16
+
17
+ def main() -> None:
18
+ run('build_dataset.py')
19
+ run('collect_activations.py')
20
+ run('evaluate_features.py')
21
+ run('run_causal.py')
22
+ run('make_report.py')
23
+ print('\nFeatureLens experiment pipeline complete. See artifacts/report.md')
24
+
25
+
26
+ if __name__ == '__main__':
27
+ main()
experiments/run_causal.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ import math
6
+ from pathlib import Path
7
+
8
+ import torch
9
+ from transformers import AutoModelForCausalLM, AutoTokenizer
10
+
11
+ from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
12
+ from featurelens.config import SETTINGS
13
+ from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
14
+ from featurelens.metrics import js_divergence_from_logits, safe_log_probability
15
+ from featurelens.sae import SAEStore
16
+
17
+
18
+ def parse_args() -> argparse.Namespace:
19
+ parser = argparse.ArgumentParser(description='Run held-out causal SAE interventions.')
20
+ parser.add_argument('--tasks', type=Path, default=DATA_DIR / 'causal_tasks.jsonl')
21
+ parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
22
+ parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'causal_results.csv')
23
+ parser.add_argument('--seed', type=int, default=42)
24
+ return parser.parse_args()
25
+
26
+
27
+ def load_selected_features(path: Path) -> dict[str, dict]:
28
+ rows = []
29
+ with path.open(newline='', encoding='utf-8') as handle:
30
+ rows = list(csv.DictReader(handle))
31
+ selected: dict[str, dict] = {}
32
+ for row in rows:
33
+ concept = row['concept']
34
+ score = float(row['train_auroc'])
35
+ contrast = float(row['activation_rate_pos']) - float(row['activation_rate_neg'])
36
+ key = (score, contrast)
37
+ if concept not in selected or key > selected[concept]['_key']:
38
+ selected[concept] = {
39
+ '_key': key,
40
+ 'layer': int(row['layer']),
41
+ 'feature_id': int(row['feature_id']),
42
+ 'train_auroc': score,
43
+ 'test_auroc': float(row['auroc']),
44
+ 'test_f1': float(row['f1']),
45
+ }
46
+ return selected
47
+
48
+
49
+ def hidden_from_output(output):
50
+ return output[0] if isinstance(output, tuple) else output
51
+
52
+
53
+ def replace_hidden(output, hidden):
54
+ return (hidden, *output[1:]) if isinstance(output, tuple) else hidden
55
+
56
+
57
+ @torch.inference_mode()
58
+ def main() -> None:
59
+ args = parse_args()
60
+ set_seed(args.seed)
61
+ tasks = load_jsonl(args.tasks)
62
+ selected = load_selected_features(args.catalog)
63
+ missing = sorted({task['concept'] for task in tasks}.difference(selected))
64
+ if missing:
65
+ raise RuntimeError(f'No selected SAE features for concepts: {missing}')
66
+
67
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
68
+ model_dtype = torch.float16 if device.type == 'cuda' else torch.float32
69
+ tokenizer = AutoTokenizer.from_pretrained(SETTINGS.model_id)
70
+ if tokenizer.pad_token_id is None:
71
+ tokenizer.pad_token = tokenizer.eos_token
72
+ model = AutoModelForCausalLM.from_pretrained(
73
+ SETTINGS.model_id,
74
+ torch_dtype=model_dtype,
75
+ low_cpu_mem_usage=True,
76
+ ).to(device)
77
+ model.eval()
78
+ selected_layers = sorted({item['layer'] for item in selected.values()})
79
+ sae_store = SAEStore(
80
+ SETTINGS.sae_repo_id,
81
+ layers=selected_layers,
82
+ device=device,
83
+ dtype=torch.float32,
84
+ top_k=SETTINGS.sae_top_k,
85
+ )
86
+
87
+ results: list[dict] = []
88
+ for task_idx, task in enumerate(tasks):
89
+ concept = task['concept']
90
+ choice = selected[concept]
91
+ layer = int(choice['layer'])
92
+ feature_id = int(choice['feature_id'])
93
+ sae = sae_store.get(layer)
94
+ inputs = tokenizer(task['prompt'], return_tensors='pt', truncation=True, max_length=192)
95
+ inputs = {key: value.to(device) for key, value in inputs.items()}
96
+ capture: dict = {}
97
+
98
+ def capture_hook(_module, _inp, output):
99
+ if 'hidden' not in capture:
100
+ capture['hidden'] = hidden_from_output(output).detach()
101
+
102
+ handle = model.model.layers[layer].register_forward_hook(capture_hook)
103
+ baseline_out = model(**inputs, use_cache=False)
104
+ handle.remove()
105
+ baseline_logits = baseline_out.logits[0, -1]
106
+ residual = capture['hidden'][0, -1]
107
+ encoding = sae.encode(residual)
108
+ original_activation = encoding.activation_for(feature_id)
109
+
110
+ target_ids = tokenizer(task['target'], add_special_tokens=False)['input_ids']
111
+ if not target_ids:
112
+ raise RuntimeError(f"Target tokenization empty for task {task['id']}")
113
+ target_id = int(target_ids[0])
114
+ baseline_prob = float(torch.softmax(baseline_logits.float(), dim=-1)[target_id].item())
115
+ baseline_rank = int((baseline_logits > baseline_logits[target_id]).sum().item()) + 1
116
+ baseline_top1 = int(torch.argmax(baseline_logits).item())
117
+
118
+ specs = [
119
+ ('ablate', InterventionSpec('ablate', 0.0)),
120
+ ('amplify_2x', InterventionSpec('scale', 2.0)),
121
+ ]
122
+ for intervention_name, spec in specs:
123
+ delta = residual_delta(sae.decoder_direction(feature_id), original_activation, spec)
124
+ control_delta = normalized_random_control(delta, seed=args.seed + task_idx * 17 + len(results))
125
+
126
+ for condition, applied_delta in (
127
+ ('sae_feature', delta),
128
+ ('random_norm_matched', control_delta),
129
+ ):
130
+ applied = {'done': False}
131
+
132
+ def edit_hook(_module, _inp, output, *, edit=applied_delta, state=applied):
133
+ if state['done']:
134
+ return output
135
+ hidden = hidden_from_output(output)
136
+ modified = hidden.clone()
137
+ modified[:, -1, :] = modified[:, -1, :] + edit.to(hidden.device, hidden.dtype)
138
+ state['done'] = True
139
+ return replace_hidden(output, modified)
140
+
141
+ hook = model.model.layers[layer].register_forward_hook(edit_hook)
142
+ modified_out = model(**inputs, use_cache=False)
143
+ hook.remove()
144
+ modified_logits = modified_out.logits[0, -1]
145
+ modified_prob = float(
146
+ torch.softmax(modified_logits.float(), dim=-1)[target_id].item()
147
+ )
148
+ modified_rank = int((modified_logits > modified_logits[target_id]).sum().item()) + 1
149
+ modified_top1 = int(torch.argmax(modified_logits).item())
150
+ results.append(
151
+ {
152
+ 'task_id': task['id'],
153
+ 'concept': concept,
154
+ 'prompt': task['prompt'],
155
+ 'target_text': task['target'],
156
+ 'target_first_token': tokenizer.decode([target_id]),
157
+ 'target_token_count': len(target_ids),
158
+ 'layer': layer,
159
+ 'feature_id': feature_id,
160
+ 'feature_train_auroc': choice['train_auroc'],
161
+ 'feature_test_auroc': choice['test_auroc'],
162
+ 'feature_test_f1': choice['test_f1'],
163
+ 'feature_activation': original_activation,
164
+ 'intervention': intervention_name,
165
+ 'condition': condition,
166
+ 'delta_activation': spec.delta_activation(original_activation)
167
+ if condition == 'sae_feature'
168
+ else math.nan,
169
+ 'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
170
+ 'baseline_target_prob': baseline_prob,
171
+ 'modified_target_prob': modified_prob,
172
+ 'target_prob_delta': modified_prob - baseline_prob,
173
+ 'target_logprob_delta': safe_log_probability(modified_prob)
174
+ - safe_log_probability(baseline_prob),
175
+ 'baseline_target_rank': baseline_rank,
176
+ 'modified_target_rank': modified_rank,
177
+ 'target_rank_delta': modified_rank - baseline_rank,
178
+ 'js_divergence': js_divergence_from_logits(
179
+ baseline_logits, modified_logits
180
+ ),
181
+ 'top1_changed': int(modified_top1 != baseline_top1),
182
+ }
183
+ )
184
+ print(f"Causal task {task_idx + 1}/{len(tasks)}: {concept}", flush=True)
185
+
186
+ args.output.parent.mkdir(parents=True, exist_ok=True)
187
+ with args.output.open('w', newline='', encoding='utf-8') as handle:
188
+ writer = csv.DictWriter(handle, fieldnames=list(results[0].keys()))
189
+ writer.writeheader()
190
+ writer.writerows(results)
191
+ print(f'Wrote {len(results)} causal intervention rows to {args.output}')
192
+
193
+
194
+ if __name__ == '__main__':
195
+ main()
experiments/split.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+
5
+
6
+ def grouped_concept_split(rows: list[dict], test_fraction: float = 0.25, seed: int = 42):
7
+ """Split paraphrase groups within each concept so paired prompts never leak across splits."""
8
+ rng = random.Random(seed)
9
+ train_ids: list[int] = []
10
+ test_ids: list[int] = []
11
+ concepts = sorted({row['concept'] for row in rows})
12
+ for concept in concepts:
13
+ concept_rows = [(idx, row) for idx, row in enumerate(rows) if row['concept'] == concept]
14
+ pair_ids = sorted({row['pair_id'] for _, row in concept_rows})
15
+ rng.shuffle(pair_ids)
16
+ n_test = max(1, round(len(pair_ids) * test_fraction))
17
+ test_pairs = set(pair_ids[:n_test])
18
+ for idx, row in concept_rows:
19
+ (test_ids if row['pair_id'] in test_pairs else train_ids).append(idx)
20
+ return sorted(train_ids), sorted(test_ids)
featurelens/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FeatureLens: causal sparse-feature interpretability for Qwen3."""
2
+
3
+ from .config import SETTINGS, Settings
4
+ from .interventions import InterventionSpec
5
+ from .sae import SAEStore, SAEWeights, SparseEncoding
6
+
7
+ __all__ = [
8
+ 'SETTINGS',
9
+ 'Settings',
10
+ 'InterventionSpec',
11
+ 'SAEStore',
12
+ 'SAEWeights',
13
+ 'SparseEncoding',
14
+ ]
featurelens/catalog.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ class FeatureCatalog:
9
+ def __init__(self, artifact_dir: str | Path = 'artifacts') -> None:
10
+ self.artifact_dir = Path(artifact_dir)
11
+ self.rows: list[dict] = []
12
+ path = self.artifact_dir / 'feature_catalog.csv'
13
+ if path.exists():
14
+ with path.open(newline='', encoding='utf-8') as handle:
15
+ self.rows = list(csv.DictReader(handle))
16
+
17
+ def hint(self, layer: int, feature_id: int) -> str:
18
+ matches = [
19
+ row
20
+ for row in self.rows
21
+ if int(row.get('layer', -1)) == int(layer)
22
+ and int(row.get('feature_id', -1)) == int(feature_id)
23
+ ]
24
+ if not matches:
25
+ return 'unlabeled'
26
+ best = max(
27
+ matches,
28
+ key=lambda row: float(row.get('train_auroc', row.get('auroc', 0.0)) or 0.0),
29
+ )
30
+ concept = best.get('concept', 'unlabeled')
31
+ auc = float(best.get('auroc', 0.0) or 0.0)
32
+ return f'{concept} (AUROC {auc:.2f})'
33
+
34
+ def benchmark_markdown(self) -> str:
35
+ summary_path = self.artifact_dir / 'summary.json'
36
+ if not summary_path.exists():
37
+ return (
38
+ '### Offline benchmark\n\n'
39
+ 'No benchmark artifacts are committed yet. Run `python experiments/run_all.py` '
40
+ 'on a CUDA machine, then commit `artifacts/summary.json`, `feature_catalog.csv`, '
41
+ 'and `report.md`. The live workbench is fully usable without them.'
42
+ )
43
+ data = json.loads(summary_path.read_text(encoding='utf-8'))
44
+ headline = data.get('headline', 'Benchmark completed.')
45
+ bullets = data.get('highlights', [])
46
+ body = '\n'.join(f'- {item}' for item in bullets)
47
+ return f'### Offline benchmark\n\n{headline}\n\n{body}'
featurelens/config.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+
7
+ def _parse_layers(raw: str | None) -> tuple[int, ...]:
8
+ if not raw:
9
+ return (4, 14, 26)
10
+ layers = tuple(sorted({int(x.strip()) for x in raw.split(',') if x.strip()}))
11
+ if not layers:
12
+ raise ValueError('FEATURELENS_LAYERS must contain at least one layer.')
13
+ if any(layer < 0 or layer > 27 for layer in layers):
14
+ raise ValueError('Qwen3-1.7B has residual-stream SAE layers 0-27.')
15
+ return layers
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Settings:
20
+ model_id: str = os.getenv('FEATURELENS_MODEL_ID', 'Qwen/Qwen3-1.7B-Base')
21
+ sae_repo_id: str = os.getenv(
22
+ 'FEATURELENS_SAE_REPO', 'Qwen/SAE-Res-Qwen3-1.7B-Base-W32K-L0_50'
23
+ )
24
+ layers: tuple[int, ...] = _parse_layers(os.getenv('FEATURELENS_LAYERS'))
25
+ sae_top_k: int = int(os.getenv('FEATURELENS_SAE_TOP_K', '50'))
26
+ sae_width: int = 32_768
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'}
33
+ sae_dtype: str = os.getenv(
34
+ 'FEATURELENS_SAE_DTYPE', 'float16' if os.getenv('SPACE_ID') else 'float32'
35
+ )
36
+
37
+
38
+ SETTINGS = Settings()
featurelens/hf_runtime.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ try:
4
+ import spaces # type: ignore
5
+ except ImportError: # Local development and unit tests.
6
+ spaces = None
7
+
8
+
9
+ def gpu(duration: int = 60):
10
+ if spaces is not None and hasattr(spaces, "GPU"):
11
+ return spaces.GPU(duration=duration)
12
+
13
+ def decorator(fn):
14
+ return fn
15
+
16
+ return decorator
featurelens/interventions.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import torch
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class InterventionSpec:
10
+ mode: str
11
+ coefficient: float
12
+
13
+ def delta_activation(self, original_activation: float) -> float:
14
+ mode = self.mode.lower().strip()
15
+ original = float(original_activation)
16
+ coefficient = float(self.coefficient)
17
+ if mode == 'ablate':
18
+ return -original
19
+ if mode == 'scale':
20
+ return (coefficient - 1.0) * original
21
+ if mode == 'inject':
22
+ return coefficient
23
+ raise ValueError("mode must be one of: 'ablate', 'scale', 'inject'.")
24
+
25
+
26
+ def residual_delta(
27
+ decoder_direction: torch.Tensor,
28
+ original_activation: float,
29
+ spec: InterventionSpec,
30
+ ) -> torch.Tensor:
31
+ """Return the reconstruction-preserving SAE delta applied to the original residual."""
32
+ return decoder_direction * spec.delta_activation(original_activation)
33
+
34
+
35
+ def normalized_random_control(delta: torch.Tensor, seed: int) -> torch.Tensor:
36
+ """Generate a deterministic random residual perturbation with identical L2 norm."""
37
+ norm = torch.linalg.vector_norm(delta.float())
38
+ if float(norm.item()) == 0.0:
39
+ return torch.zeros_like(delta)
40
+ generator = torch.Generator(device='cpu').manual_seed(int(seed))
41
+ random_vec = torch.randn(delta.shape, generator=generator, dtype=torch.float32)
42
+ random_vec = random_vec / torch.linalg.vector_norm(random_vec)
43
+ random_vec = random_vec * norm.cpu()
44
+ return random_vec.to(device=delta.device, dtype=delta.dtype)
featurelens/metrics.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ import torch
7
+
8
+
9
+ def reconstruction_metrics(original: torch.Tensor, reconstructed: torch.Tensor) -> dict[str, float]:
10
+ x = original.float().reshape(-1)
11
+ x_hat = reconstructed.float().reshape(-1)
12
+ mse = torch.mean((x - x_hat) ** 2)
13
+ denom = torch.mean(x**2).clamp_min(1e-12)
14
+ nmse = mse / denom
15
+ cosine = torch.nn.functional.cosine_similarity(x.unsqueeze(0), x_hat.unsqueeze(0)).item()
16
+ return {
17
+ 'mse': float(mse.item()),
18
+ 'nmse': float(nmse.item()),
19
+ 'cosine': float(cosine),
20
+ }
21
+
22
+
23
+ def js_divergence_from_logits(logits_a: torch.Tensor, logits_b: torch.Tensor) -> float:
24
+ p = torch.softmax(logits_a.float(), dim=-1)
25
+ q = torch.softmax(logits_b.float(), dim=-1)
26
+ m = 0.5 * (p + q)
27
+ eps = 1e-12
28
+ kl_pm = torch.sum(p * (torch.log(p + eps) - torch.log(m + eps)))
29
+ kl_qm = torch.sum(q * (torch.log(q + eps) - torch.log(m + eps)))
30
+ return float((0.5 * (kl_pm + kl_qm)).item())
31
+
32
+
33
+ def safe_log_probability(probability: float) -> float:
34
+ return math.log(max(float(probability), 1e-12))
35
+
36
+
37
+ def sparse_jaccard(indices_a: np.ndarray, values_a: np.ndarray, indices_b: np.ndarray, values_b: np.ndarray) -> float:
38
+ a = set(indices_a[np.asarray(values_a) > 0].tolist())
39
+ b = set(indices_b[np.asarray(values_b) > 0].tolist())
40
+ union = a | b
41
+ if not union:
42
+ return 1.0
43
+ return len(a & b) / len(union)
featurelens/runtime.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import os
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass
8
+
9
+ import torch
10
+ from transformers import AutoModelForCausalLM, AutoTokenizer
11
+
12
+ from .catalog import FeatureCatalog
13
+ from .config import SETTINGS, Settings
14
+ from .interventions import InterventionSpec, residual_delta
15
+ from .metrics import js_divergence_from_logits, reconstruction_metrics, safe_log_probability
16
+ from .sae import SAEStore, SparseEncoding
17
+
18
+
19
+ def _dtype_from_name(name: str) -> torch.dtype:
20
+ name = name.lower().strip()
21
+ if name in {'float16', 'fp16', 'half'}:
22
+ return torch.float16
23
+ if name in {'bfloat16', 'bf16'}:
24
+ return torch.bfloat16
25
+ if name in {'float32', 'fp32'}:
26
+ return torch.float32
27
+ raise ValueError(f'Unsupported dtype: {name}')
28
+
29
+
30
+ def _default_device() -> torch.device:
31
+ # ZeroGPU exposes CUDA emulation at module load time. Force the recommended
32
+ # CUDA placement on Spaces even if a local availability probe is conservative.
33
+ if os.getenv('SPACE_ID'):
34
+ return torch.device('cuda')
35
+ if torch.cuda.is_available():
36
+ return torch.device('cuda')
37
+ return torch.device('cpu')
38
+
39
+
40
+ @dataclass
41
+ class AnalysisResult:
42
+ tokens: list[str]
43
+ token_index: int
44
+ layer: int
45
+ features: SparseEncoding
46
+ rows: list[list[object]]
47
+ metrics: dict[str, float]
48
+
49
+
50
+ @dataclass
51
+ class InterventionResult:
52
+ baseline_text: str
53
+ modified_text: str
54
+ feature_activation: float
55
+ delta_activation: float
56
+ perturbation_norm: float
57
+ js_divergence: float
58
+ target_text: str
59
+ target_token: str
60
+ target_token_count: int
61
+ baseline_target_prob: float | None
62
+ modified_target_prob: float | None
63
+ target_logprob_delta: float | None
64
+ top_token_rows: list[list[object]]
65
+
66
+
67
+ class FeatureLensRuntime:
68
+ def __init__(self, settings: Settings = SETTINGS) -> None:
69
+ self.settings = settings
70
+ self.device = _default_device()
71
+ self.model_dtype = torch.float16 if self.device.type == 'cuda' else torch.float32
72
+ self.sae_dtype = _dtype_from_name(settings.sae_dtype)
73
+ if self.device.type == 'cpu' and self.sae_dtype != torch.float32:
74
+ self.sae_dtype = torch.float32
75
+ self.model = None
76
+ self.tokenizer = None
77
+ self.sae_store: SAEStore | None = None
78
+ self.catalog = FeatureCatalog()
79
+ self.load_error: str | None = None
80
+
81
+ def ensure_ready(self, preload_saes: bool = False) -> None:
82
+ if self.model is not None and self.tokenizer is not None and self.sae_store is not None:
83
+ if preload_saes:
84
+ self.sae_store.preload()
85
+ return
86
+ self.tokenizer = AutoTokenizer.from_pretrained(self.settings.model_id)
87
+ if self.tokenizer.pad_token_id is None:
88
+ self.tokenizer.pad_token = self.tokenizer.eos_token
89
+ self.tokenizer.padding_side = 'left'
90
+ self.model = AutoModelForCausalLM.from_pretrained(
91
+ self.settings.model_id,
92
+ torch_dtype=self.model_dtype,
93
+ low_cpu_mem_usage=True,
94
+ )
95
+ self.model.to(self.device)
96
+ self.model.eval()
97
+ self.sae_store = SAEStore(
98
+ repo_id=self.settings.sae_repo_id,
99
+ layers=self.settings.layers,
100
+ device=self.device,
101
+ dtype=self.sae_dtype,
102
+ top_k=self.settings.sae_top_k,
103
+ )
104
+ if preload_saes:
105
+ self.sae_store.preload()
106
+
107
+ def token_choices(self, text: str) -> list[tuple[str, int]]:
108
+ self.ensure_ready(preload_saes=False)
109
+ assert self.tokenizer is not None
110
+ ids = self.tokenizer(text, add_special_tokens=True)['input_ids']
111
+ tokens = [self.tokenizer.decode([token_id]) for token_id in ids]
112
+ return [(f'{idx}: {token!r}', idx) for idx, token in enumerate(tokens)]
113
+
114
+ def _inputs(self, text: str) -> dict[str, torch.Tensor]:
115
+ assert self.tokenizer is not None
116
+ batch = self.tokenizer(
117
+ text,
118
+ return_tensors='pt',
119
+ truncation=True,
120
+ max_length=self.settings.max_prompt_tokens,
121
+ )
122
+ return {key: value.to(self.device) for key, value in batch.items()}
123
+
124
+ @staticmethod
125
+ def _hidden_from_output(output):
126
+ return output[0] if isinstance(output, tuple) else output
127
+
128
+ @staticmethod
129
+ def _replace_hidden_in_output(output, hidden: torch.Tensor):
130
+ if isinstance(output, tuple):
131
+ return (hidden, *output[1:])
132
+ return hidden
133
+
134
+ @contextmanager
135
+ def _capture_hook(self, layer: int, bucket: dict) -> Iterator[None]:
136
+ assert self.model is not None
137
+
138
+ def hook(_module, _inputs, output):
139
+ hidden = self._hidden_from_output(output)
140
+ if 'hidden' not in bucket:
141
+ bucket['hidden'] = hidden.detach()
142
+
143
+ handle = self.model.model.layers[int(layer)].register_forward_hook(hook)
144
+ try:
145
+ yield
146
+ finally:
147
+ handle.remove()
148
+
149
+ @contextmanager
150
+ def _delta_hook(self, layer: int, token_index: int, delta: torch.Tensor) -> Iterator[None]:
151
+ assert self.model is not None
152
+ applied = {'done': False}
153
+
154
+ def hook(_module, _inputs, output):
155
+ if applied['done']:
156
+ return output
157
+ hidden = self._hidden_from_output(output)
158
+ if hidden.ndim != 3:
159
+ return output
160
+ seq_len = hidden.shape[1]
161
+ idx = int(token_index)
162
+ if idx < 0:
163
+ idx = seq_len + idx
164
+ if idx < 0 or idx >= seq_len:
165
+ raise IndexError(f'Token index {token_index} outside prompt length {seq_len}.')
166
+ modified = hidden.clone()
167
+ modified[:, idx, :] = modified[:, idx, :] + delta.to(hidden.device, hidden.dtype)
168
+ applied['done'] = True
169
+ return self._replace_hidden_in_output(output, modified)
170
+
171
+ handle = self.model.model.layers[int(layer)].register_forward_hook(hook)
172
+ try:
173
+ yield
174
+ finally:
175
+ handle.remove()
176
+
177
+ @torch.inference_mode()
178
+ def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
179
+ self.ensure_ready(preload_saes=False)
180
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
181
+ if int(layer) not in self.settings.layers:
182
+ raise ValueError(f'Layer must be one of {self.settings.layers}.')
183
+ inputs = self._inputs(text)
184
+ bucket: dict = {}
185
+ with self._capture_hook(int(layer), bucket):
186
+ self.model(**inputs, use_cache=False)
187
+ hidden = bucket['hidden'][0]
188
+ seq_len = hidden.shape[0]
189
+ idx = int(token_index)
190
+ if idx < 0:
191
+ idx = seq_len + idx
192
+ if idx < 0 or idx >= seq_len:
193
+ raise IndexError(f'Token index {token_index} outside prompt length {seq_len}.')
194
+ residual = hidden[idx]
195
+ sae = self.sae_store.get(int(layer))
196
+ encoding = sae.encode(residual)
197
+ reconstruction = sae.decode_sparse(encoding)
198
+ metrics = reconstruction_metrics(residual, reconstruction)
199
+ metrics['active_features'] = float(encoding.active_count)
200
+ ids = inputs['input_ids'][0].tolist()
201
+ tokens = [self.tokenizer.decode([token_id]) for token_id in ids]
202
+ rows: list[list[object]] = []
203
+ rank_count = min(int(top_n), encoding.indices.numel())
204
+ for rank in range(rank_count):
205
+ feature_id = int(encoding.indices[rank].item())
206
+ activation = float(encoding.values[rank].item())
207
+ rows.append(
208
+ [rank + 1, feature_id, activation, self.catalog.hint(int(layer), feature_id)]
209
+ )
210
+ return AnalysisResult(
211
+ tokens=tokens,
212
+ token_index=idx,
213
+ layer=int(layer),
214
+ features=encoding,
215
+ rows=rows,
216
+ metrics=metrics,
217
+ )
218
+
219
+ def token_html(self, tokens: list[str], selected_index: int) -> str:
220
+ chips = []
221
+ for idx, token in enumerate(tokens):
222
+ safe = html.escape(token if token.strip() else repr(token))
223
+ selected = idx == int(selected_index)
224
+ cls = 'token selected' if selected else 'token'
225
+ chips.append(f'<span class="{cls}"><sup>{idx}</sup>{safe}</span>')
226
+ return '<div class="token-wrap">' + ''.join(chips) + '</div>'
227
+
228
+ @staticmethod
229
+ def _top_token_rows(tokenizer, baseline_logits: torch.Tensor, modified_logits: torch.Tensor, k: int = 8):
230
+ p = torch.softmax(baseline_logits.float(), dim=-1)
231
+ q = torch.softmax(modified_logits.float(), dim=-1)
232
+ union_ids = torch.unique(torch.cat([torch.topk(p, k).indices, torch.topk(q, k).indices]))
233
+ rows = []
234
+ for token_id in union_ids.tolist():
235
+ token = tokenizer.decode([int(token_id)])
236
+ bp = float(p[token_id].item())
237
+ mp = float(q[token_id].item())
238
+ rows.append([repr(token), bp, mp, mp - bp])
239
+ rows.sort(key=lambda row: max(row[1], row[2]), reverse=True)
240
+ return rows[: min(len(rows), 12)]
241
+
242
+ @torch.inference_mode()
243
+ def intervene(
244
+ self,
245
+ text: str,
246
+ layer: int,
247
+ token_index: int,
248
+ feature_id: int,
249
+ mode: str,
250
+ coefficient: float,
251
+ target_text: str = '',
252
+ max_new_tokens: int = 24,
253
+ ) -> InterventionResult:
254
+ self.ensure_ready(preload_saes=False)
255
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
256
+ inputs = self._inputs(text)
257
+ prompt_len = int(inputs['input_ids'].shape[1])
258
+ idx = int(token_index)
259
+ if idx < 0:
260
+ idx = prompt_len + idx
261
+ if idx < 0 or idx >= prompt_len:
262
+ raise IndexError(f'Token index {token_index} outside prompt length {prompt_len}.')
263
+
264
+ sae = self.sae_store.get(int(layer))
265
+ capture: dict = {}
266
+ generation_kwargs = {
267
+ 'max_new_tokens': min(int(max_new_tokens), self.settings.max_new_tokens),
268
+ 'do_sample': False,
269
+ 'return_dict_in_generate': True,
270
+ 'output_scores': True,
271
+ 'pad_token_id': self.tokenizer.eos_token_id,
272
+ }
273
+ with self._capture_hook(int(layer), capture):
274
+ baseline = self.model.generate(**inputs, **generation_kwargs)
275
+ residual = capture['hidden'][0, idx]
276
+ encoding = sae.encode(residual)
277
+ original_activation = encoding.activation_for(int(feature_id))
278
+ spec = InterventionSpec(mode=mode, coefficient=float(coefficient))
279
+ delta = residual_delta(sae.decoder_direction(int(feature_id)), original_activation, spec)
280
+
281
+ with self._delta_hook(int(layer), idx, delta):
282
+ modified = self.model.generate(**inputs, **generation_kwargs)
283
+
284
+ baseline_ids = baseline.sequences[0, prompt_len:]
285
+ modified_ids = modified.sequences[0, prompt_len:]
286
+ baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
287
+ modified_text = self.tokenizer.decode(modified_ids, skip_special_tokens=True)
288
+
289
+ if not baseline.scores or not modified.scores:
290
+ raise RuntimeError('Generation returned no score tensors.')
291
+ baseline_logits = baseline.scores[0][0]
292
+ modified_logits = modified.scores[0][0]
293
+ js = js_divergence_from_logits(baseline_logits, modified_logits)
294
+
295
+ target_token = ''
296
+ target_count = 0
297
+ bp = mp = log_delta = None
298
+ if target_text.strip():
299
+ target_ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
300
+ target_count = len(target_ids)
301
+ if target_ids:
302
+ target_id = int(target_ids[0])
303
+ target_token = self.tokenizer.decode([target_id])
304
+ p = torch.softmax(baseline_logits.float(), dim=-1)
305
+ q = torch.softmax(modified_logits.float(), dim=-1)
306
+ bp = float(p[target_id].item())
307
+ mp = float(q[target_id].item())
308
+ log_delta = safe_log_probability(mp) - safe_log_probability(bp)
309
+
310
+ return InterventionResult(
311
+ baseline_text=baseline_text,
312
+ modified_text=modified_text,
313
+ feature_activation=float(original_activation),
314
+ delta_activation=float(spec.delta_activation(original_activation)),
315
+ perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
316
+ js_divergence=float(js),
317
+ target_text=target_text,
318
+ target_token=target_token,
319
+ target_token_count=target_count,
320
+ baseline_target_prob=bp,
321
+ modified_target_prob=mp,
322
+ target_logprob_delta=log_delta,
323
+ top_token_rows=self._top_token_rows(
324
+ self.tokenizer, baseline_logits, modified_logits, k=8
325
+ ),
326
+ )
327
+
328
+
329
+ RUNTIME = FeatureLensRuntime()
330
+
331
+ if SETTINGS.eager_load:
332
+ try:
333
+ RUNTIME.ensure_ready(preload_saes=True)
334
+ except Exception as exc: # Keep the UI alive and surface a useful error on first call.
335
+ RUNTIME.load_error = f'{type(exc).__name__}: {exc}'
336
+ print(f'FeatureLens eager load failed: {RUNTIME.load_error}')
featurelens/sae.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import torch
8
+ from huggingface_hub import hf_hub_download
9
+
10
+
11
+ @dataclass
12
+ class SparseEncoding:
13
+ indices: torch.Tensor
14
+ values: torch.Tensor
15
+ pre_activations: torch.Tensor | None = None
16
+
17
+ @property
18
+ def active_count(self) -> int:
19
+ return int((self.values > 0).sum().item())
20
+
21
+ def activation_for(self, feature_id: int) -> float:
22
+ mask = self.indices == int(feature_id)
23
+ if not bool(mask.any()):
24
+ return 0.0
25
+ return float(self.values[mask][0].item())
26
+
27
+
28
+ @dataclass
29
+ class SAEWeights:
30
+ layer: int
31
+ w_enc_t: torch.Tensor # [d_model, d_sae]
32
+ w_dec: torch.Tensor # [d_model, d_sae]
33
+ b_enc: torch.Tensor # [d_sae]
34
+ b_dec: torch.Tensor # [d_model]
35
+ top_k: int = 50
36
+
37
+ @torch.inference_mode()
38
+ def encode(self, hidden: torch.Tensor, return_pre: bool = False) -> SparseEncoding:
39
+ """Encode one or more residual vectors without materializing dense sparse acts."""
40
+ if hidden.shape[-1] != self.w_enc_t.shape[0]:
41
+ raise ValueError(
42
+ f'Expected hidden dim {self.w_enc_t.shape[0]}, got {hidden.shape[-1]}.'
43
+ )
44
+ compute_hidden = hidden.to(device=self.w_enc_t.device, dtype=self.w_enc_t.dtype)
45
+ pre = compute_hidden @ self.w_enc_t + self.b_enc
46
+ relu = torch.relu(pre)
47
+ values, indices = torch.topk(relu, k=self.top_k, dim=-1)
48
+ return SparseEncoding(indices=indices, values=values, pre_activations=pre if return_pre else None)
49
+
50
+ @torch.inference_mode()
51
+ def decode_sparse(self, encoding: SparseEncoding) -> torch.Tensor:
52
+ """Decode TopK features efficiently using only selected decoder columns."""
53
+ indices = encoding.indices
54
+ values = encoding.values.to(device=self.w_dec.device, dtype=self.w_dec.dtype)
55
+ if indices.ndim == 1:
56
+ cols = self.w_dec[:, indices] # [d_model, k]
57
+ return self.b_dec + cols @ values
58
+ flat_idx = indices.reshape(-1, indices.shape[-1])
59
+ flat_vals = values.reshape(-1, values.shape[-1])
60
+ outputs = []
61
+ for row_idx, row_vals in zip(flat_idx, flat_vals, strict=True):
62
+ cols = self.w_dec[:, row_idx]
63
+ outputs.append(self.b_dec + cols @ row_vals)
64
+ return torch.stack(outputs).reshape(*indices.shape[:-1], self.w_dec.shape[0])
65
+
66
+ def decoder_direction(self, feature_id: int) -> torch.Tensor:
67
+ if feature_id < 0 or feature_id >= self.w_dec.shape[1]:
68
+ raise ValueError(f'Feature id must be in [0, {self.w_dec.shape[1] - 1}].')
69
+ return self.w_dec[:, int(feature_id)]
70
+
71
+
72
+ class SAEStore:
73
+ def __init__(
74
+ self,
75
+ repo_id: str,
76
+ layers: Iterable[int],
77
+ device: torch.device,
78
+ dtype: torch.dtype,
79
+ top_k: int = 50,
80
+ cache_dir: str | Path | None = None,
81
+ ) -> None:
82
+ self.repo_id = repo_id
83
+ self.layers = tuple(int(x) for x in layers)
84
+ self.device = device
85
+ self.dtype = dtype
86
+ self.top_k = int(top_k)
87
+ self.cache_dir = str(cache_dir) if cache_dir else None
88
+ self._cache: dict[int, SAEWeights] = {}
89
+
90
+ def get(self, layer: int) -> SAEWeights:
91
+ layer = int(layer)
92
+ if layer not in self.layers:
93
+ raise ValueError(f'Layer {layer} is not configured. Available: {self.layers}.')
94
+ if layer in self._cache:
95
+ return self._cache[layer]
96
+
97
+ path = hf_hub_download(
98
+ repo_id=self.repo_id,
99
+ filename=f'layer{layer}.sae.pt',
100
+ cache_dir=self.cache_dir,
101
+ )
102
+ try:
103
+ raw = torch.load(path, map_location='cpu', weights_only=True)
104
+ except TypeError: # pragma: no cover - old torch fallback
105
+ raw = torch.load(path, map_location='cpu')
106
+
107
+ required = {'W_enc', 'W_dec', 'b_enc', 'b_dec'}
108
+ missing = required.difference(raw)
109
+ if missing:
110
+ raise KeyError(f'SAE checkpoint layer {layer} missing keys: {sorted(missing)}')
111
+
112
+ sae = SAEWeights(
113
+ layer=layer,
114
+ w_enc_t=raw['W_enc'].T.contiguous().to(self.device, dtype=self.dtype),
115
+ w_dec=raw['W_dec'].contiguous().to(self.device, dtype=self.dtype),
116
+ b_enc=raw['b_enc'].contiguous().to(self.device, dtype=self.dtype),
117
+ b_dec=raw['b_dec'].contiguous().to(self.device, dtype=self.dtype),
118
+ top_k=self.top_k,
119
+ )
120
+ self._cache[layer] = sae
121
+ return sae
122
+
123
+ def preload(self) -> None:
124
+ for layer in self.layers:
125
+ self.get(layer)
pyproject.toml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "featurelens"
3
+ version = "0.1.0"
4
+ description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
5
+ requires-python = ">=3.10"
6
+
7
+ [tool.pytest.ini_options]
8
+ testpaths = ["tests"]
9
+ addopts = "-q"
10
+
11
+ [tool.ruff]
12
+ line-length = 100
13
+ target-version = "py310"
14
+ exclude = ["artifacts"]
15
+
16
+ [tool.ruff.lint]
17
+ select = ["E", "F", "I", "B", "UP"]
18
+ ignore = ["E501"]
requirements-dev.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ -r requirements.txt
2
+ pytest>=8.3,<10
3
+ ruff>=0.9,<1
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.8.0,<2.12.0
2
+ transformers>=4.51.0,<6.0.0
3
+ huggingface_hub>=0.34.0,<2.0.0
4
+ gradio>=6.0.0,<7.0.0
5
+ numpy>=2.0.0,<3.0.0
6
+ scipy>=1.14.0,<2.0.0
7
+ scikit-learn>=1.6.0,<2.0.0
8
+ pandas>=2.2.0,<3.0.0
9
+ matplotlib>=3.9.0,<4.0.0
research_config.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "research_question": "Do sparse features that predict a concept also causally influence model behaviour?",
3
+ "model_id": "Qwen/Qwen3-1.7B-Base",
4
+ "sae_repo_id": "Qwen/SAE-Res-Qwen3-1.7B-Base-W32K-L0_50",
5
+ "layers": [4, 14, 26],
6
+ "sae_top_k": 50,
7
+ "sae_width": 32768,
8
+ "concepts": [
9
+ "code",
10
+ "mathematics",
11
+ "positive_sentiment",
12
+ "negative_sentiment",
13
+ "french_language",
14
+ "factual_entities",
15
+ "uncertainty"
16
+ ],
17
+ "discovery_prompts": 224,
18
+ "paraphrase_pairs_per_concept": 16,
19
+ "causal_tasks": 28,
20
+ "split_seed": 42,
21
+ "feature_selection": "training-split AUROC with activation-rate contrast tie-break",
22
+ "held_out_metrics": ["AUROC", "F1"],
23
+ "causal_interventions": ["ablate", "scale_2x"],
24
+ "negative_control": "norm-matched random residual direction",
25
+ "primary_causal_metric": "target first-token log-probability delta"
26
+ }
scripts/release_check.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections import Counter
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+
9
+ REQUIRED = [
10
+ 'README.md',
11
+ 'app.py',
12
+ 'requirements.txt',
13
+ 'research_config.json',
14
+ 'featurelens/runtime.py',
15
+ 'featurelens/sae.py',
16
+ 'featurelens/interventions.py',
17
+ 'experiments/run_all.py',
18
+ 'data/prompts.jsonl',
19
+ 'data/causal_tasks.jsonl',
20
+ ]
21
+
22
+
23
+ def load_jsonl(path: Path) -> list[dict]:
24
+ return [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line]
25
+
26
+
27
+ def main() -> None:
28
+ missing = [path for path in REQUIRED if not (ROOT / path).exists()]
29
+ if missing:
30
+ raise SystemExit(f'Missing required files: {missing}')
31
+
32
+ config = json.loads((ROOT / 'research_config.json').read_text(encoding='utf-8'))
33
+ assert config['layers'] == [4, 14, 26]
34
+ assert config['model_id'] == 'Qwen/Qwen3-1.7B-Base'
35
+ assert config['sae_width'] == 32768
36
+
37
+ prompts = load_jsonl(ROOT / 'data' / 'prompts.jsonl')
38
+ causal = load_jsonl(ROOT / 'data' / 'causal_tasks.jsonl')
39
+ assert len(prompts) == config['discovery_prompts']
40
+ assert len(causal) == config['causal_tasks']
41
+ concept_counts = Counter(row['concept'] for row in prompts)
42
+ assert len(concept_counts) == len(config['concepts'])
43
+ assert len(set(concept_counts.values())) == 1
44
+
45
+ oversized = []
46
+ for path in ROOT.rglob('*'):
47
+ if path.is_file() and '.git' not in path.parts and path.stat().st_size > 5_000_000:
48
+ oversized.append(str(path.relative_to(ROOT)))
49
+ if oversized:
50
+ raise SystemExit(f'Repository contains unexpectedly large tracked candidates: {oversized}')
51
+
52
+ readme = (ROOT / 'README.md').read_text(encoding='utf-8')
53
+ assert 'sdk: gradio' in readme
54
+ assert 'Qwen/Qwen3-1.7B-Base' in readme
55
+ assert 'norm-matched random' in readme.lower()
56
+
57
+ print('FeatureLens release check: PASS')
58
+ print(f' discovery prompts: {len(prompts)}')
59
+ print(f' causal tasks: {len(causal)}')
60
+ print(f' layers: {config["layers"]}')
61
+
62
+
63
+ if __name__ == '__main__':
64
+ main()
tests/test_catalog.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from featurelens.catalog import FeatureCatalog
6
+
7
+
8
+ def test_catalog_returns_best_hint(tmp_path: Path) -> None:
9
+ (tmp_path / 'feature_catalog.csv').write_text(
10
+ 'layer,concept,feature_id,auroc\n14,math,123,0.80\n14,code,123,0.65\n',
11
+ encoding='utf-8',
12
+ )
13
+ catalog = FeatureCatalog(tmp_path)
14
+ assert catalog.hint(14, 123) == 'math (AUROC 0.80)'
15
+ assert catalog.hint(4, 123) == 'unlabeled'
tests/test_data.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections import Counter
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+
9
+
10
+ def read_jsonl(path: Path):
11
+ return [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line]
12
+
13
+
14
+ def test_discovery_dataset_is_balanced_and_paired() -> None:
15
+ rows = read_jsonl(ROOT / 'data' / 'prompts.jsonl')
16
+ assert len(rows) == 224
17
+ concept_counts = Counter(row['concept'] for row in rows)
18
+ assert set(concept_counts.values()) == {32}
19
+ pair_counts = Counter(row['pair_id'] for row in rows)
20
+ assert set(pair_counts.values()) == {2}
21
+
22
+
23
+ def test_causal_dataset_covers_every_discovery_concept() -> None:
24
+ discovery = read_jsonl(ROOT / 'data' / 'prompts.jsonl')
25
+ causal = read_jsonl(ROOT / 'data' / 'causal_tasks.jsonl')
26
+ assert len(causal) == 28
27
+ assert {row['concept'] for row in causal} == {row['concept'] for row in discovery}
tests/test_interventions.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+ from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
6
+
7
+
8
+ def test_ablation_delta() -> None:
9
+ spec = InterventionSpec('ablate', 0.0)
10
+ assert spec.delta_activation(3.5) == -3.5
11
+
12
+
13
+ def test_scale_delta() -> None:
14
+ spec = InterventionSpec('scale', 2.0)
15
+ assert spec.delta_activation(3.5) == 3.5
16
+ assert InterventionSpec('scale', 0.0).delta_activation(3.5) == -3.5
17
+
18
+
19
+ def test_injection_delta() -> None:
20
+ spec = InterventionSpec('inject', -4.0)
21
+ assert spec.delta_activation(123.0) == -4.0
22
+
23
+
24
+ def test_residual_delta_is_decoder_direction_times_coefficient_delta() -> None:
25
+ direction = torch.tensor([1.0, 2.0, -1.0])
26
+ delta = residual_delta(direction, 3.0, InterventionSpec('ablate', 0.0))
27
+ assert torch.allclose(delta, torch.tensor([-3.0, -6.0, 3.0]))
28
+
29
+
30
+ def test_random_control_matches_norm_and_is_deterministic() -> None:
31
+ delta = torch.tensor([3.0, 4.0, 0.0])
32
+ a = normalized_random_control(delta, seed=7)
33
+ b = normalized_random_control(delta, seed=7)
34
+ assert torch.allclose(a, b)
35
+ assert torch.allclose(torch.linalg.vector_norm(a), torch.linalg.vector_norm(delta), atol=1e-5)
tests/test_metrics.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+ from featurelens.metrics import js_divergence_from_logits, reconstruction_metrics, safe_log_probability
6
+
7
+
8
+ def test_reconstruction_metrics_perfect_match() -> None:
9
+ x = torch.tensor([1.0, 2.0, 3.0])
10
+ metrics = reconstruction_metrics(x, x.clone())
11
+ assert metrics['mse'] == 0.0
12
+ assert metrics['nmse'] == 0.0
13
+ assert abs(metrics['cosine'] - 1.0) < 1e-6
14
+
15
+
16
+ def test_js_divergence_zero_for_identical_logits() -> None:
17
+ logits = torch.tensor([1.0, 2.0, -1.0])
18
+ assert abs(js_divergence_from_logits(logits, logits)) < 1e-8
19
+
20
+
21
+ def test_safe_log_probability_is_finite_at_zero() -> None:
22
+ assert safe_log_probability(0.0) < 0.0
tests/test_sae.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+ from featurelens.sae import SAEWeights
6
+
7
+
8
+ def make_sae() -> SAEWeights:
9
+ w_enc_t = torch.tensor(
10
+ [
11
+ [1.0, 0.0, -1.0, 0.5, 0.0],
12
+ [0.0, 1.0, 0.0, 0.5, -1.0],
13
+ [0.0, 0.0, 1.0, 0.0, 1.0],
14
+ ]
15
+ )
16
+ w_dec = torch.tensor(
17
+ [
18
+ [1.0, 0.0, 0.0, 0.5, 0.0],
19
+ [0.0, 1.0, 0.0, 0.5, 0.0],
20
+ [0.0, 0.0, 1.0, 0.0, 1.0],
21
+ ]
22
+ )
23
+ return SAEWeights(
24
+ layer=0,
25
+ w_enc_t=w_enc_t,
26
+ w_dec=w_dec,
27
+ b_enc=torch.zeros(5),
28
+ b_dec=torch.zeros(3),
29
+ top_k=2,
30
+ )
31
+
32
+
33
+ def test_encode_keeps_topk_relu_features() -> None:
34
+ sae = make_sae()
35
+ encoding = sae.encode(torch.tensor([2.0, 1.0, -1.0]))
36
+ assert encoding.indices.shape == (2,)
37
+ assert set(encoding.indices.tolist()) == {0, 3}
38
+ assert encoding.active_count == 2
39
+ assert encoding.activation_for(0) == 2.0
40
+ assert encoding.activation_for(4) == 0.0
41
+
42
+
43
+ def test_decode_sparse_uses_selected_decoder_columns() -> None:
44
+ sae = make_sae()
45
+ encoding = sae.encode(torch.tensor([2.0, 1.0, -1.0]))
46
+ reconstructed = sae.decode_sparse(encoding)
47
+ assert reconstructed.shape == (3,)
48
+ assert torch.isfinite(reconstructed).all()
49
+
50
+
51
+ def test_batched_encode_and_decode() -> None:
52
+ sae = make_sae()
53
+ hidden = torch.tensor([[2.0, 1.0, -1.0], [0.0, 2.0, 2.0]])
54
+ encoding = sae.encode(hidden)
55
+ decoded = sae.decode_sparse(encoding)
56
+ assert encoding.indices.shape == (2, 2)
57
+ assert decoded.shape == hidden.shape
tests/test_split.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from experiments.split import grouped_concept_split
4
+
5
+
6
+ def test_grouped_split_keeps_paraphrase_pairs_together() -> None:
7
+ rows = []
8
+ for concept in ('a', 'b'):
9
+ for pair in range(4):
10
+ for variant in range(2):
11
+ rows.append(
12
+ {
13
+ 'concept': concept,
14
+ 'pair_id': f'{concept}-{pair}',
15
+ 'variant': variant,
16
+ }
17
+ )
18
+ train, test = grouped_concept_split(rows, test_fraction=0.25, seed=42)
19
+ train_pairs = {rows[idx]['pair_id'] for idx in train}
20
+ test_pairs = {rows[idx]['pair_id'] for idx in test}
21
+ assert train_pairs.isdisjoint(test_pairs)
22
+ assert {rows[idx]['concept'] for idx in train} == {'a', 'b'}
23
+ assert {rows[idx]['concept'] for idx in test} == {'a', 'b'}