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

Release FeatureLens v0.2.0

Browse files
.gitignore CHANGED
@@ -10,3 +10,5 @@ artifacts/*.npy
10
  artifacts/*.npz
11
  *.pt
12
  *.safetensors
 
 
 
10
  artifacts/*.npz
11
  *.pt
12
  *.safetensors
13
+ pycache/
14
+ venv/
CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-08-13
4
+
5
+ ### Added
6
+
7
+ - Live norm-matched random residual control for causal interventions.
8
+ - Target-token and JS-divergence causal specificity ratios.
9
+ - Feature-scale dose-response sweep (0× through 3×).
10
+ - Cross-layer representation trajectory for layers 4, 14 and 26.
11
+ - Native Gradio activation, dose-response and trajectory plots.
12
+ - Bootstrap 95% confidence intervals in the generated offline report.
13
+ - Paired sign-flip randomization test for SAE-vs-random causal-effect differences.
14
+ - Hosted/local validation matrix.
15
+
16
+ ### Changed
17
+
18
+ - Space SDK and dependency pin to Gradio 6.24.0.
19
+ - Explicit client-side rendering (`ssr_mode=False`) to avoid the observed SSR async-user warning.
20
+ - Shorter ZeroGPU callback durations and a smaller default generation budget.
21
+ - More explicit inactive-feature warning and error surfacing.
22
+ - Dark/light-mode-safe UI styling using Gradio theme variables.
23
+
24
+ ### Removed
25
+
26
+ - Build/cache directories from release artifacts.
README.md CHANGED
@@ -4,7 +4,8 @@ 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
@@ -12,6 +13,8 @@ license: mit
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?
@@ -41,6 +44,10 @@ The Gradio app supports:
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
 
@@ -78,7 +85,9 @@ The evaluation computes:
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
 
@@ -149,6 +158,7 @@ FeatureLens/
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
@@ -174,6 +184,14 @@ FeatureLens/
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
 
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: gradio
7
+ python_version: "3.12.12"
8
+ sdk_version: "6.24.0"
9
  app_file: app.py
10
  pinned: false
11
  license: mit
 
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
+ > **v0.2:** live norm-matched controls, causal dose-response sweeps, layer trajectories, statistical uncertainty, and a hardened ZeroGPU deployment.
17
+
18
  **FeatureLens asks one concrete question:**
19
 
20
  > Do sparse features that predict a concept also causally influence model behaviour?
 
44
  - feature **ablation**, **scaling**, or **injection**;
45
  - baseline vs modified greedy generation;
46
  - first-token probability table and Jensen-Shannon divergence;
47
+ - **live norm-matched random residual control** for every causal edit;
48
+ - target-token causal specificity ratio against that matched control;
49
+ - **dose-response sweep** over 0×, 0.5×, 1×, 1.5×, 2× and 3× feature scaling;
50
+ - **layer trajectory** diagnostics across layers 4, 14 and 26;
51
  - optional target-continuation probability / log-probability delta;
52
  - optional concept hints loaded from real offline benchmark artifacts.
53
 
 
85
  - layer-wise multinomial linear probes on dense residual states;
86
  - selected-feature ablation and 2× amplification;
87
  - target first-token probability, log-probability, rank and JS divergence;
88
+ - **norm-matched random residual-direction controls**;
89
+ - bootstrap 95% confidence intervals for aggregate metrics;
90
+ - a paired sign-flip randomization test for SAE-vs-random causal-effect differences.
91
 
92
  Feature selection uses the **training split**. Held-out AUROC/F1 are reported afterward. Paraphrases from the same pair never cross the train/test boundary.
93
 
 
158
  │ ├── interventions.py # causal deltas + random controls
159
  │ ├── runtime.py # hooks, generation, probability deltas
160
  │ ├── metrics.py # reconstruction + divergence metrics
161
+ │ ├── stats.py # bootstrap CIs + paired randomization test
162
  │ └── catalog.py # offline result integration
163
  ├── experiments/
164
  │ ├── build_dataset.py
 
184
  - A target string may tokenize into multiple tokens. The live workbench explicitly labels its target metric as the **first-token** probability in that case.
185
  - A causal effect can depend strongly on prompt, layer, feature scale, and downstream task. The report therefore includes raw per-task rows rather than only aggregate means.
186
 
187
+ ## v0.2 deployment hardening
188
+
189
+ The Space pins **Gradio 6.24.0** in both the Space metadata and Python dependencies and launches with `ssr_mode=False`. This deliberately avoids the SSR execution path that produced an un-awaited `get_current_user` coroutine warning in the initial deployment while keeping the app fully functional in client-side rendering mode.
190
+
191
+ The ZeroGPU callbacks also use shorter declared GPU durations than v0.1 and generation defaults to 16 new tokens, reducing queue cost for ordinary demo usage.
192
+
193
+ See [`docs/VALIDATION.md`](docs/VALIDATION.md) for the pre-push and hosted test matrix.
194
+
195
  ## Reproducibility and checks
196
 
197
  ```bash
app.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import gradio as gr
4
 
5
  from featurelens.config import SETTINGS
@@ -7,69 +8,115 @@ 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,
@@ -80,209 +127,359 @@ def run_intervention(
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)
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import pandas as pd
4
  import gradio as gr
5
 
6
  from featurelens.config import SETTINGS
 
8
  from featurelens.runtime import RUNTIME
9
 
10
  CSS = """
11
+ .gradio-container { max-width: 1320px !important; }
12
+ .hero { padding: 8px 2px 2px; }
13
+ .hero h1 { margin: 0; font-size: 2.35rem; letter-spacing: -0.045em; }
14
+ .hero p { margin: .35rem 0 0; opacity: .72; font-size: 1rem; }
15
+ .research-q { border-left: 4px solid var(--primary-500); padding: 10px 14px; margin: 10px 0 14px; border-radius: 0 10px 10px 0; background: var(--background-fill-secondary); }
16
+ .badges { display:flex; flex-wrap:wrap; gap:7px; margin:8px 0 3px; }
17
+ .badge { border:1px solid var(--border-color-primary); background:var(--background-fill-secondary); border-radius:999px; padding:4px 9px; font-size:12px; }
18
+ .step { font-size: .78rem; text-transform: uppercase; letter-spacing:.09em; opacity:.62; font-weight:700; margin-top:2px; }
19
  .token-wrap { display:flex; flex-wrap:wrap; gap:5px; padding:8px 2px; line-height:1.7; }
20
+ .token { background:var(--background-fill-secondary); border:1px solid var(--border-color-primary); border-radius:7px; padding:2px 7px; font-family:ui-monospace,SFMono-Regular,monospace; font-size:12px; }
21
+ .token.selected { border:2px solid var(--primary-500); color:var(--primary-600); font-weight:600; }
22
  .token sup { opacity:.55; margin-right:4px; }
23
+ .small-note { opacity:.65; font-size:12px; }
24
+ .callout { border:1px solid var(--border-color-primary); background:var(--background-fill-secondary); border-radius:12px; padding:10px 12px; }
25
  """
26
 
27
+ THEME = gr.themes.Soft(primary_hue="blue", neutral_hue="slate")
28
+
29
+
30
+ def _raise_ui_error(exc: Exception) -> None:
31
+ raise gr.Error(f"{type(exc).__name__}: {exc}") from exc
32
+
33
 
34
  def _analysis_metrics_markdown(result) -> str:
35
  return (
36
  f"**Layer {result.layer} · token {result.token_index}** \n"
37
  f"Active SAE features: **{int(result.metrics['active_features'])}/{SETTINGS.sae_top_k}** \n"
38
+ f"Reconstruction cosine: **{result.metrics['cosine']:.4f}** · "
39
+ f"NMSE: **{result.metrics['nmse']:.4f}** \n"
40
+ f"Top-5 activation mass: **{result.metrics['top5_mass_fraction']:.1%}**"
41
  )
42
 
43
 
44
  def _intervention_metrics_markdown(result) -> str:
45
+ target = "No target continuation supplied — specificity is shown using JS divergence only."
46
  if result.baseline_target_prob is not None:
47
+ warning = ""
48
  if result.target_token_count > 1:
49
  warning = (
50
  f" \n_Note: target text tokenizes to {result.target_token_count} tokens; "
51
+ "the displayed causal metric is for its first token._"
52
  )
53
  target = (
54
  f"Target first token: `{result.target_token}` \n"
55
  f"Baseline p: **{result.baseline_target_prob:.6f}** · "
56
+ f"SAE edit p: **{result.modified_target_prob:.6f}** · "
57
+ f"Random-control p: **{result.random_target_prob:.6f}** \n"
58
+ f"SAE Δlog p: **{result.target_logprob_delta:+.4f}** · "
59
+ f"random Δlog p: **{result.random_target_logprob_delta:+.4f}** · "
60
+ f"specificity ratio: **{result.target_specificity_ratio:.2f}×**{warning}"
61
+ )
62
+ inactive = ""
63
+ if abs(result.feature_activation) < 1e-12:
64
+ inactive = (
65
+ " \n⚠️ **Selected feature is inactive at this token.** Ablate/scale therefore produces "
66
+ "a zero feature delta; use `inject` to test the decoder direction directly."
67
  )
68
  return (
69
+ f"Original feature activation: **{result.feature_activation:.4f}** · "
70
+ f"Δ coefficient: **{result.delta_activation:+.4f}** \n"
71
+ f"Perturbation L2: **{result.perturbation_norm:.4f}** \n"
72
+ f"Next-token JS: **{result.js_divergence:.6f}** · "
73
+ f"random-control JS: **{result.random_js_divergence:.6f}** · "
74
+ f"specificity: **{result.js_specificity_ratio:.2f}×** \n\n"
75
+ f"{target}{inactive}"
76
  )
77
 
78
 
79
+ def _dose_metrics_markdown(result) -> str:
80
+ note = ""
81
+ if result.target_token_count > 1:
82
+ note = (
83
+ f" Target text spans {result.target_token_count} tokens; the curve measures its first token."
84
+ )
85
+ inactive = ""
86
+ if abs(result.feature_activation) < 1e-12:
87
+ inactive = " **The feature is inactive here, so a multiplicative sweep is flat by construction.**"
88
  return (
89
+ f"Feature activation at baseline: **{result.feature_activation:.4f}** · "
90
+ f"target first token: `{result.target_token}`.{note}{inactive}"
 
 
91
  )
92
 
93
 
94
+ @gpu(duration=30)
95
+ def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
96
+ try:
97
+ if not prompt.strip():
98
+ raise ValueError("Enter a prompt first.")
99
+ result = RUNTIME.analyze(prompt, int(layer), int(token_index), int(top_n))
100
+ choices = [str(int(row[1])) for row in result.rows]
101
+ feature_update = gr.update(choices=choices, value=choices[0] if choices else None)
102
+ chart_df = pd.DataFrame(
103
+ {
104
+ "Feature": [str(int(row[1])) for row in result.rows],
105
+ "Activation": [float(row[2]) for row in result.rows],
106
+ }
107
+ )
108
+ return (
109
+ RUNTIME.token_html(result.tokens, result.token_index),
110
+ result.rows,
111
+ chart_df,
112
+ feature_update,
113
+ _analysis_metrics_markdown(result),
114
+ )
115
+ except Exception as exc:
116
+ _raise_ui_error(exc)
117
+
118
+
119
+ @gpu(duration=45)
120
  def run_intervention(
121
  prompt: str,
122
  layer: int,
 
127
  target_text: str,
128
  max_new_tokens: int,
129
  ):
 
 
 
 
130
  try:
131
+ if not prompt.strip():
132
+ raise ValueError("Enter a prompt first.")
133
+ if feature_id is None or str(feature_id).strip() == "":
134
+ raise ValueError("Choose or enter a feature id.")
135
  fid = int(float(feature_id))
136
+ result = RUNTIME.intervene(
137
+ text=prompt,
138
+ layer=int(layer),
139
+ token_index=int(token_index),
140
+ feature_id=fid,
141
+ mode=mode,
142
+ coefficient=float(coefficient),
143
+ target_text=target_text,
144
+ max_new_tokens=int(max_new_tokens),
145
+ )
146
+ return (
147
+ result.baseline_text,
148
+ result.modified_text,
149
+ _intervention_metrics_markdown(result),
150
+ result.top_token_rows,
151
+ )
152
+ except Exception as exc:
153
+ _raise_ui_error(exc)
154
+
155
+
156
+ @gpu(duration=40)
157
+ def run_dose_response(
158
+ prompt: str,
159
+ layer: int,
160
+ token_index: int,
161
+ feature_id: str,
162
+ target_text: str,
163
+ ):
164
+ try:
165
+ if not prompt.strip():
166
+ raise ValueError("Enter a prompt first.")
167
+ if feature_id is None or str(feature_id).strip() == "":
168
+ raise ValueError("Choose or enter a feature id.")
169
+ if not target_text.strip():
170
+ raise ValueError("Enter a target continuation before running a dose-response sweep.")
171
+ result = RUNTIME.dose_response(
172
+ text=prompt,
173
+ layer=int(layer),
174
+ token_index=int(token_index),
175
+ feature_id=int(float(feature_id)),
176
+ target_text=target_text,
177
+ )
178
+ columns = [
179
+ "Multiplier",
180
+ "Δ feature coefficient",
181
+ "Perturbation L2",
182
+ "Baseline p(target)",
183
+ "Modified p(target)",
184
+ "Δ log p(target)",
185
+ "JS divergence",
186
+ ]
187
+ table = pd.DataFrame(result.rows, columns=columns)
188
+ plot = table[["Multiplier", "Δ log p(target)"]].copy()
189
+ return table, plot, _dose_metrics_markdown(result)
190
+ except Exception as exc:
191
+ _raise_ui_error(exc)
192
+
193
+
194
+ @gpu(duration=35)
195
+ def run_layer_sweep(prompt: str, token_index: int):
196
+ try:
197
+ if not prompt.strip():
198
+ raise ValueError("Enter a prompt first.")
199
+ result = RUNTIME.layer_sweep(prompt, int(token_index))
200
+ columns = [
201
+ "Layer",
202
+ "Reconstruction cosine",
203
+ "NMSE",
204
+ "Active features",
205
+ "Top activation",
206
+ "Top-5 mass",
207
+ "Activation entropy",
208
+ ]
209
+ table = pd.DataFrame(result.rows, columns=columns)
210
+ long = table.melt(
211
+ id_vars=["Layer"],
212
+ value_vars=["Reconstruction cosine", "Top-5 mass", "Activation entropy"],
213
+ var_name="Metric",
214
+ value_name="Value",
215
+ )
216
+ return RUNTIME.token_html(result.tokens, result.token_index), table, long
217
+ except Exception as exc:
218
+ _raise_ui_error(exc)
219
 
220
 
221
  def mode_help(mode: str):
222
+ if mode == "ablate":
223
  return gr.update(
224
  value=0.0,
225
  interactive=False,
226
+ label="Coefficient (unused for ablation)",
227
+ info="Sets the selected active feature coefficient to zero.",
228
  )
229
+ if mode == "scale":
230
  return gr.update(
231
  value=2.0,
232
  interactive=True,
233
+ label="Feature multiplier",
234
+ info="1 = unchanged, 0 = ablate, 2 = double the original coefficient.",
235
  )
236
  return gr.update(
237
  value=5.0,
238
  interactive=True,
239
+ label="Additive feature coefficient",
240
+ info="Adds this amount along the decoder direction, even when the feature is inactive.",
241
  )
242
 
243
 
244
+ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as demo:
245
  gr.HTML(
246
+ '<div class="hero"><h1>FeatureLens <span style="font-size:.42em;opacity:.55">v0.2</span></h1>'
247
+ '<p>Causal sparse-feature interpretability for Qwen3-1.7B inspect → intervene → control → measure.</p></div>'
248
+ '<div class="research-q"><b>Research question:</b> Do sparse features that predict a concept also '
249
+ 'causally influence the model’s behaviour?</div>'
250
+ '<div class="badges">'
251
+ '<span class="badge">Qwen3-1.7B-Base</span><span class="badge">Qwen-Scope SAE</span>'
252
+ '<span class="badge">32,768 features</span><span class="badge">TopK=50</span>'
253
+ '<span class="badge">norm-matched controls</span><span class="badge">ZeroGPU</span></div>'
254
  )
255
 
256
+ with gr.Tab("Workbench"):
257
+ gr.HTML('<div class="step">Step 1 · Choose a prompt and residual location</div>')
258
  with gr.Row(equal_height=False):
259
+ with gr.Column(scale=5):
260
  prompt = gr.Textbox(
261
+ label="Prompt",
262
+ lines=5,
263
+ value="The derivative of x squared is",
264
+ placeholder="Enter a prompt to inspect…",
265
  )
266
+ gr.Examples(
267
+ examples=[
268
+ ["The derivative of x squared is"],
269
+ ["In Python, reverse a list using"],
270
+ ["Je voudrais réserver une table pour"],
271
+ ["I am not fully certain, but the answer may be"],
272
+ ],
273
+ inputs=[prompt],
274
+ label="Controlled examples",
 
 
 
 
 
 
 
275
  )
 
 
276
  with gr.Column(scale=3):
277
+ layer = gr.Dropdown(
278
+ choices=list(SETTINGS.layers),
279
+ value=SETTINGS.layers[1] if len(SETTINGS.layers) > 1 else SETTINGS.layers[0],
280
+ label="Residual layer",
281
+ info="Early / middle / late checkpoints are intentionally sampled.",
 
282
  )
283
+ token_index = gr.Number(
284
+ value=-1,
285
+ precision=0,
286
+ label="Prompt token index",
287
+ info="-1 = final prompt token. Inspect once to see all token positions.",
288
+ )
289
+ top_n = gr.Slider(5, 20, value=12, step=1, label="Top features")
290
+ analyze_btn = gr.Button("Inspect sparse features", variant="primary")
291
+
292
+ token_view = gr.HTML('<div class="small-note">Token positions appear here after inspection.</div>')
293
+ analysis_metrics = gr.Markdown()
294
+ with gr.Row(equal_height=False):
295
+ feature_table = gr.Dataframe(
296
+ headers=["Rank", "Feature id", "Activation", "Offline concept hint"],
297
+ datatype=["number", "number", "number", "str"],
298
+ interactive=False,
299
+ label="Strongest TopK features",
300
+ wrap=True,
301
+ scale=3,
302
+ )
303
+ feature_plot = gr.BarPlot(
304
+ x="Feature",
305
+ y="Activation",
306
+ title="Activation profile",
307
+ x_title="Feature id",
308
+ y_title="Activation",
309
+ scale=2,
310
+ )
311
 
312
+ gr.HTML('<div class="step">Step 2 · Intervene and compare against a matched control</div>')
313
  gr.Markdown(
314
+ "FeatureLens changes only the selected SAE coefficient and adds that decoder-vector delta "
315
+ "to the **original** residual. The live negative control applies a deterministic random "
316
+ "residual perturbation with the **same L2 norm**."
317
  )
318
  with gr.Row(equal_height=False):
319
  with gr.Column(scale=2):
320
  feature_id = gr.Dropdown(
321
  choices=[],
322
  allow_custom_value=True,
323
+ label="Feature id",
324
+ info="Inspection populates the strongest active features; custom IDs are also allowed.",
325
  )
326
  mode = gr.Radio(
327
+ choices=["ablate", "scale", "inject"],
328
+ value="ablate",
329
+ label="Intervention",
330
  )
331
  coefficient = gr.Number(
332
  value=0.0,
333
  interactive=False,
334
+ label="Coefficient (unused for ablation)",
335
  )
336
  target_text = gr.Textbox(
337
+ label="Optional target continuation",
338
+ placeholder="e.g. 2x",
339
+ info="Supplying this enables target-token Δlog p and causal-specificity metrics.",
340
  )
341
  max_new = gr.Slider(
342
  4,
343
  SETTINGS.max_new_tokens,
344
+ value=min(16, SETTINGS.max_new_tokens),
345
  step=1,
346
+ label="Max new tokens",
347
  )
348
+ intervene_btn = gr.Button("Run causal test", variant="primary")
349
  intervention_metrics = gr.Markdown()
 
350
  with gr.Column(scale=3):
351
  with gr.Row():
352
+ baseline_out = gr.Textbox(label="Baseline generation", lines=7, interactive=False)
353
+ modified_out = gr.Textbox(label="SAE-intervened generation", lines=7, interactive=False)
354
  token_prob_table = gr.Dataframe(
355
+ headers=["Token", "Baseline p", "SAE-edit p", "Δ probability"],
356
+ datatype=["str", "number", "number", "number"],
357
  interactive=False,
358
+ label="Next-token distribution shift",
359
+ )
360
+
361
+ gr.HTML('<div class="step">Step 3 · Test dose–response</div>')
362
+ with gr.Accordion("Causal dose–response sweep", open=False):
363
+ gr.Markdown(
364
+ "A single coefficient can be cherry-picked. This sweep scales the same active feature "
365
+ "through **0×, 0.5×, 1×, 1.5×, 2× and 3×** and measures target-token Δlog p. "
366
+ "A coherent monotonic response is stronger causal evidence than one isolated edit."
367
+ )
368
+ dose_btn = gr.Button("Run dose–response sweep")
369
+ dose_metrics = gr.Markdown()
370
+ with gr.Row():
371
+ dose_table = gr.Dataframe(interactive=False, label="Dose-response measurements", scale=3)
372
+ dose_plot = gr.LinePlot(
373
+ x="Multiplier",
374
+ y="Δ log p(target)",
375
+ title="Causal dose–response",
376
+ x_title="Feature multiplier",
377
+ y_title="Δ log p(target)",
378
+ scale=2,
379
  )
380
 
381
  analyze_btn.click(
382
  analyze_prompt,
383
  inputs=[prompt, layer, token_index, top_n],
384
+ outputs=[token_view, feature_table, feature_plot, feature_id, analysis_metrics],
385
  )
386
  mode.change(mode_help, inputs=[mode], outputs=[coefficient])
387
  intervene_btn.click(
388
  run_intervention,
389
+ inputs=[prompt, layer, token_index, feature_id, mode, coefficient, target_text, max_new],
 
 
 
 
 
 
 
 
 
390
  outputs=[baseline_out, modified_out, intervention_metrics, token_prob_table],
391
  )
392
+ dose_btn.click(
393
+ run_dose_response,
394
+ inputs=[prompt, layer, token_index, feature_id, target_text],
395
+ outputs=[dose_table, dose_plot, dose_metrics],
396
+ )
397
+
398
+ with gr.Tab("Layer trajectory"):
399
+ gr.Markdown(
400
+ "### Follow the representation across early, middle and late residual streams\n"
401
+ "This is **not** a cross-layer feature-ID comparison — SAE dictionaries are layer-specific. "
402
+ "Instead, it compares reconstruction quality and sparsity/concentration statistics at the "
403
+ "same prompt token across layers 4, 14 and 26."
404
+ )
405
+ with gr.Row():
406
+ trajectory_prompt = gr.Textbox(
407
+ label="Prompt",
408
+ lines=5,
409
+ value="The derivative of x squared is",
410
+ scale=4,
411
+ )
412
+ trajectory_token = gr.Number(
413
+ value=-1,
414
+ precision=0,
415
+ label="Prompt token index",
416
+ info="-1 = final token",
417
+ scale=1,
418
+ )
419
+ trajectory_btn = gr.Button("Compare layers", variant="primary")
420
+ trajectory_tokens = gr.HTML()
421
+ with gr.Row():
422
+ trajectory_table = gr.Dataframe(interactive=False, label="Layer diagnostics", scale=3)
423
+ trajectory_plot = gr.LinePlot(
424
+ x="Layer",
425
+ y="Value",
426
+ color="Metric",
427
+ title="Representation trajectory",
428
+ x_title="Layer",
429
+ y_title="Normalized value",
430
+ scale=2,
431
+ )
432
+ trajectory_btn.click(
433
+ run_layer_sweep,
434
+ inputs=[trajectory_prompt, trajectory_token],
435
+ outputs=[trajectory_tokens, trajectory_table, trajectory_plot],
436
+ )
437
 
438
+ with gr.Tab("Offline benchmark"):
439
+ gr.Markdown(RUNTIME.catalog.benchmark_markdown())
440
  gr.Markdown(
441
+ "The offline pipeline evaluates held-out feature/concept AUROC + F1, reconstruction quality, "
442
+ "paraphrase stability, dense residual linear probes, causal ablation/amplification and "
443
+ "norm-matched random-direction controls. Results are loaded from `artifacts/`; the app never "
444
+ "ships invented benchmark numbers."
445
  )
446
 
447
+ with gr.Tab("Method"):
448
  gr.Markdown(
449
  r"""
450
+ ### Reconstruction-preserving intervention
451
 
452
+ For residual vector $h$, selected sparse coefficient $z_i$, decoder direction $d_i$, and scale $\alpha$:
453
 
454
  - **Ablate:** $h' = h - z_i d_i$
455
+ - **Scale:** $h' = h + (\alpha - 1) z_i d_i$
456
+ - **Inject:** $h' = h + \delta d_i$
457
+
458
+ The app edits the original residual rather than replacing it with the full SAE reconstruction, so SAE
459
+ reconstruction error is not introduced as a causal confound.
460
 
461
+ ### Evidence ladder
 
 
462
 
463
+ 1. **Reconstruction:** does the SAE represent the residual reasonably well?
464
+ 2. **Prediction:** does a feature predict a controlled concept on held-out paraphrase groups?
465
+ 3. **Intervention:** does changing it alter downstream behaviour?
466
+ 4. **Specificity:** is that effect larger than a norm-matched random residual perturbation?
467
+ 5. **Dose-response:** does effect size change coherently as the feature coefficient is varied?
468
 
469
+ A high AUROC alone remains correlational evidence.
 
 
470
  """
471
  )
472
 
473
  gr.Markdown(
474
+ "<small>Built with PyTorch, Transformers, Qwen3-1.7B-Base and Qwen-Scope residual-stream SAEs. "
475
+ "FeatureLens is independent of thesis code and thesis datasets.</small>"
476
  )
477
 
478
+ if __name__ == "__main__":
479
+ # Explicit CSR avoids an HF/Gradio SSR auth-coroutine warning seen in 6.23.x-era Space builds.
480
+ demo.queue(default_concurrency_limit=1, max_size=8).launch(
481
+ css=CSS,
482
+ theme=THEME,
483
+ ssr_mode=False,
484
+ show_error=True,
485
+ )
docs/VALIDATION.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FeatureLens validation matrix
2
+
3
+ FeatureLens has two distinct validation targets: software correctness and scientific validity. Passing one does not imply the other.
4
+
5
+ ## Before every push
6
+
7
+ ```bash
8
+ python -m ruff check app.py featurelens experiments tests scripts
9
+ python -m pytest -q
10
+ python -m compileall -q app.py featurelens experiments scripts
11
+ python scripts/release_check.py
12
+ ```
13
+
14
+ Also run `git status` and confirm that no `__pycache__/`, `pycache/`, `.pytest_cache/`, model weights, or `artifacts/activations/` files are staged.
15
+
16
+ ## Local UI smoke test
17
+
18
+ ```bash
19
+ python app.py
20
+ ```
21
+
22
+ Verify that the app opens without an SSR banner and without `get_current_user ... was never awaited` warnings. The app intentionally launches with `ssr_mode=False`.
23
+
24
+ On a CUDA machine, test one example through each path:
25
+
26
+ 1. Inspect features on layer 14, final token.
27
+ 2. Ablate the strongest active feature.
28
+ 3. Supply a target continuation and confirm SAE-edit and random-control metrics both appear.
29
+ 4. Run the dose-response sweep and confirm the 1× row has approximately zero intervention effect.
30
+ 5. Run the layer trajectory and confirm rows for 4, 14 and 26.
31
+
32
+ ## Hugging Face Space smoke test
33
+
34
+ After a push/rebuild:
35
+
36
+ 1. Confirm Space hardware is ZeroGPU and status reaches **Running**.
37
+ 2. Check startup logs for model/SAE download or load failures.
38
+ 3. Confirm the launch line does **not** say `with SSR`.
39
+ 4. Run `The derivative of x squared is` at layer 14, token `-1`.
40
+ 5. Confirm the feature dropdown is populated after inspection.
41
+ 6. Run an ablation with `target continuation = 2x`.
42
+ 7. Confirm baseline and modified generations render and random-control metrics appear.
43
+ 8. Run a dose-response sweep once; confirm the curve renders.
44
+ 9. Run the layer trajectory once; confirm all three layers render.
45
+ 10. Refresh the page and repeat a short inspection to catch state/cold-start regressions.
46
+
47
+ ## Scientific acceptance checks
48
+
49
+ Do not publish headline findings until the offline benchmark is run on the real Qwen3/Qwen-Scope weights.
50
+
51
+ Required checks:
52
+
53
+ - paraphrase groups do not cross train/test splits;
54
+ - feature selection uses training metrics only;
55
+ - held-out AUROC/F1 are reported separately;
56
+ - reconstruction metrics are reported per layer;
57
+ - causal tasks are separate from discovery prompts;
58
+ - SAE edits and random controls are paired by task/intervention;
59
+ - perturbation norms match within numerical tolerance;
60
+ - bootstrap confidence intervals are included;
61
+ - paired sign-flip p-value is reported as uncertainty evidence, not as proof of a mechanistic claim;
62
+ - raw causal rows remain available for inspection;
63
+ - null or mixed results are retained in the generated report.
64
+
65
+ ## Recommended manual adversarial tests
66
+
67
+ Use prompts designed to expose failure modes rather than only attractive examples:
68
+
69
+ - a mathematics prompt with a code-like token;
70
+ - English content containing one French entity name;
71
+ - negation such as `This is not a positive review`;
72
+ - an uncertainty prompt that becomes certain in its final clause;
73
+ - a custom inactive feature ID with `ablate` versus `inject`;
74
+ - a multi-token target continuation to verify the first-token warning;
75
+ - token index `0`, `-1`, and an invalid out-of-range index;
76
+ - feature ID `0`, `32767`, and an invalid `32768`.
experiments/make_report.py CHANGED
@@ -9,6 +9,11 @@ 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:
@@ -84,6 +89,7 @@ def main() -> None:
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())
@@ -93,6 +99,21 @@ def main() -> None:
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
 
@@ -128,11 +149,12 @@ def main() -> None:
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,
@@ -140,6 +162,7 @@ def main() -> None:
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']),
@@ -148,6 +171,9 @@ def main() -> None:
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
  },
@@ -181,6 +207,7 @@ def main() -> None:
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',
 
9
  import pandas as pd
10
 
11
  from experiments.common import ARTIFACT_DIR
12
+ from featurelens.stats import (
13
+ bootstrap_mean_ci,
14
+ paired_bootstrap_difference_ci,
15
+ paired_sign_flip_pvalue,
16
+ )
17
 
18
 
19
  def parse_args() -> argparse.Namespace:
 
89
 
90
  mean_auc = float(selected['auroc'].mean())
91
  median_auc = float(selected['auroc'].median())
92
+ auc_ci_low, auc_ci_high = bootstrap_mean_ci(selected['auroc'].to_numpy(), seed=42)
93
  best_layer_row = layers.sort_values('linear_probe_macro_auroc', ascending=False).iloc[0]
94
  mean_jaccard = float(stability['topk_jaccard'].mean())
95
  mean_sparse_cos = float(stability['sparse_cosine'].mean())
 
99
  sae_abs = float(np.mean(np.abs(sae['target_logprob_delta'])))
100
  random_abs = float(np.mean(np.abs(random['target_logprob_delta'])))
101
  ratio = sae_abs / max(random_abs, 1e-12)
102
+
103
+ paired = causal.pivot_table(
104
+ index=['task_id', 'intervention'],
105
+ columns='condition',
106
+ values='target_logprob_delta',
107
+ aggfunc='first',
108
+ ).dropna(subset=['sae_feature', 'random_norm_matched'])
109
+ paired_sae = np.abs(paired['sae_feature'].to_numpy(dtype=float))
110
+ paired_random = np.abs(paired['random_norm_matched'].to_numpy(dtype=float))
111
+ diff_mean = float(np.mean(paired_sae - paired_random))
112
+ diff_ci_low, diff_ci_high = paired_bootstrap_difference_ci(
113
+ paired_sae, paired_random, seed=43
114
+ )
115
+ sign_flip_p = paired_sign_flip_pvalue(paired_sae, paired_random, seed=44)
116
+
117
  active_rate = float(np.mean(sae['feature_activation'] > 0))
118
  top1_change = float(sae['top1_changed'].mean())
119
 
 
149
  'random residual controls.'
150
  )
151
  highlights = [
152
+ f'Median selected-feature held-out AUROC: {median_auc:.3f}; mean AUROC 95% bootstrap CI [{auc_ci_low:.3f}, {auc_ci_high:.3f}].',
153
  f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
154
  f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
155
  f'Selected feature active on {active_rate:.1%} of causal prompts; modified top-1 token on {top1_change:.1%}.',
156
  f'Mean absolute causal effect / random-control effect ratio: {ratio:.2f}×.',
157
+ f'Paired mean |Δlog p| advantage over random: {diff_mean:+.3f}, 95% bootstrap CI [{diff_ci_low:+.3f}, {diff_ci_high:+.3f}], sign-flip p={sign_flip_p:.4f}.',
158
  ]
159
  summary = {
160
  'headline': headline,
 
162
  'interpretation': interpretation,
163
  'metrics': {
164
  'mean_selected_feature_test_auroc': mean_auc,
165
+ 'mean_selected_feature_test_auroc_bootstrap_ci_95': [auc_ci_low, auc_ci_high],
166
  'median_selected_feature_test_auroc': median_auc,
167
  'best_linear_probe_layer': int(best_layer_row['layer']),
168
  'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
 
171
  'mean_abs_sae_target_logprob_delta': sae_abs,
172
  'mean_abs_random_target_logprob_delta': random_abs,
173
  'causal_to_random_effect_ratio': ratio,
174
+ 'paired_mean_abs_effect_advantage': diff_mean,
175
+ 'paired_mean_abs_effect_advantage_bootstrap_ci_95': [diff_ci_low, diff_ci_high],
176
+ 'paired_sign_flip_pvalue': sign_flip_p,
177
  'causal_prompt_feature_active_rate': active_rate,
178
  'sae_top1_change_rate': top1_change,
179
  },
 
207
  '- Linear baseline: multinomial logistic regression on the dense residual stream.',
208
  '- Causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
209
  '- Negative control: deterministic random residual direction matched to the SAE perturbation L2 norm.',
210
+ '- Uncertainty: bootstrap 95% confidence intervals and a paired sign-flip randomization test for SAE-vs-control effect differences.',
211
  '- Behavioural metric: first-token target probability/log-probability, rank, JS divergence, and top-1 changes.',
212
  '',
213
  '## Figures',
featurelens/runtime.py CHANGED
@@ -1,8 +1,10 @@
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
 
@@ -11,7 +13,11 @@ 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
 
@@ -28,8 +34,8 @@ def _dtype_from_name(name: str) -> torch.dtype:
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():
@@ -55,15 +61,35 @@ class InterventionResult:
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
@@ -78,8 +104,13 @@ class FeatureLensRuntime:
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
@@ -103,6 +134,7 @@ class FeatureLensRuntime:
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)
@@ -146,6 +178,25 @@ class FeatureLensRuntime:
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
@@ -174,6 +225,20 @@ class FeatureLensRuntime:
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)
@@ -185,18 +250,18 @@ class FeatureLensRuntime:
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]] = []
@@ -216,6 +281,52 @@ class FeatureLensRuntime:
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):
@@ -255,11 +366,7 @@ class FeatureLensRuntime:
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 = {}
@@ -281,6 +388,15 @@ class FeatureLensRuntime:
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)
@@ -291,10 +407,12 @@ class FeatureLensRuntime:
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)
@@ -303,9 +421,13 @@ class FeatureLensRuntime:
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,
@@ -314,17 +436,87 @@ class FeatureLensRuntime:
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
 
 
1
  from __future__ import annotations
2
 
3
+ import hashlib
4
  import html
5
+ import math
6
  import os
7
+ from collections.abc import Iterator, Sequence
8
  from contextlib import contextmanager
9
  from dataclasses import dataclass
10
 
 
13
 
14
  from .catalog import FeatureCatalog
15
  from .config import SETTINGS, Settings
16
+ from .interventions import (
17
+ InterventionSpec,
18
+ normalized_random_control,
19
+ residual_delta,
20
+ )
21
  from .metrics import js_divergence_from_logits, reconstruction_metrics, safe_log_probability
22
  from .sae import SAEStore, SparseEncoding
23
 
 
34
 
35
 
36
  def _default_device() -> torch.device:
37
+ # ZeroGPU exposes CUDA emulation at module load time. Hugging Face recommends
38
+ # placing models on CUDA at module scope so startup transfers can be optimized.
39
  if os.getenv('SPACE_ID'):
40
  return torch.device('cuda')
41
  if torch.cuda.is_available():
 
61
  delta_activation: float
62
  perturbation_norm: float
63
  js_divergence: float
64
+ random_js_divergence: float
65
+ js_specificity_ratio: float
66
  target_text: str
67
  target_token: str
68
  target_token_count: int
69
  baseline_target_prob: float | None
70
  modified_target_prob: float | None
71
+ random_target_prob: float | None
72
  target_logprob_delta: float | None
73
+ random_target_logprob_delta: float | None
74
+ target_specificity_ratio: float | None
75
  top_token_rows: list[list[object]]
76
 
77
 
78
+ @dataclass
79
+ class LayerSweepResult:
80
+ tokens: list[str]
81
+ token_index: int
82
+ rows: list[list[object]]
83
+
84
+
85
+ @dataclass
86
+ class DoseResponseResult:
87
+ feature_activation: float
88
+ target_token: str
89
+ target_token_count: int
90
+ rows: list[list[object]]
91
+
92
+
93
  class FeatureLensRuntime:
94
  def __init__(self, settings: Settings = SETTINGS) -> None:
95
  self.settings = settings
 
104
  self.catalog = FeatureCatalog()
105
  self.load_error: str | None = None
106
 
107
+ @property
108
+ def ready(self) -> bool:
109
+ return self.model is not None and self.tokenizer is not None and self.sae_store is not None
110
+
111
  def ensure_ready(self, preload_saes: bool = False) -> None:
112
+ if self.ready:
113
+ assert self.sae_store is not None
114
  if preload_saes:
115
  self.sae_store.preload()
116
  return
 
134
  )
135
  if preload_saes:
136
  self.sae_store.preload()
137
+ self.load_error = None
138
 
139
  def token_choices(self, text: str) -> list[tuple[str, int]]:
140
  self.ensure_ready(preload_saes=False)
 
178
  finally:
179
  handle.remove()
180
 
181
+ @contextmanager
182
+ def _capture_hooks(self, layers: Sequence[int], buckets: dict[int, dict]) -> Iterator[None]:
183
+ assert self.model is not None
184
+ handles = []
185
+ for layer in layers:
186
+ bucket = buckets[int(layer)]
187
+
188
+ def hook(_module, _inputs, output, *, target=bucket):
189
+ hidden = self._hidden_from_output(output)
190
+ if 'hidden' not in target:
191
+ target['hidden'] = hidden.detach()
192
+
193
+ handles.append(self.model.model.layers[int(layer)].register_forward_hook(hook))
194
+ try:
195
+ yield
196
+ finally:
197
+ for handle in handles:
198
+ handle.remove()
199
+
200
  @contextmanager
201
  def _delta_hook(self, layer: int, token_index: int, delta: torch.Tensor) -> Iterator[None]:
202
  assert self.model is not None
 
225
  finally:
226
  handle.remove()
227
 
228
+ @staticmethod
229
+ def _resolve_index(token_index: int, seq_len: int) -> int:
230
+ idx = int(token_index)
231
+ if idx < 0:
232
+ idx = seq_len + idx
233
+ if idx < 0 or idx >= seq_len:
234
+ raise IndexError(f'Token index {token_index} outside prompt length {seq_len}.')
235
+ return idx
236
+
237
+ @staticmethod
238
+ def _control_seed(text: str, layer: int, feature_id: int, mode: str, coefficient: float) -> int:
239
+ payload = f'{text}\0{layer}\0{feature_id}\0{mode}\0{coefficient:.8g}'.encode('utf-8')
240
+ return int.from_bytes(hashlib.sha256(payload).digest()[:4], 'big', signed=False)
241
+
242
  @torch.inference_mode()
243
  def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
244
  self.ensure_ready(preload_saes=False)
 
250
  with self._capture_hook(int(layer), bucket):
251
  self.model(**inputs, use_cache=False)
252
  hidden = bucket['hidden'][0]
253
+ idx = self._resolve_index(int(token_index), hidden.shape[0])
 
 
 
 
 
254
  residual = hidden[idx]
255
  sae = self.sae_store.get(int(layer))
256
  encoding = sae.encode(residual)
257
  reconstruction = sae.decode_sparse(encoding)
258
  metrics = reconstruction_metrics(residual, reconstruction)
259
  metrics['active_features'] = float(encoding.active_count)
260
+ values = encoding.values.float().clamp_min(0)
261
+ total = float(values.sum().item())
262
+ metrics['top5_mass_fraction'] = (
263
+ float(values[: min(5, values.numel())].sum().item()) / total if total > 0 else 0.0
264
+ )
265
  ids = inputs['input_ids'][0].tolist()
266
  tokens = [self.tokenizer.decode([token_id]) for token_id in ids]
267
  rows: list[list[object]] = []
 
281
  metrics=metrics,
282
  )
283
 
284
+ @torch.inference_mode()
285
+ def layer_sweep(self, text: str, token_index: int = -1) -> LayerSweepResult:
286
+ self.ensure_ready(preload_saes=True)
287
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
288
+ inputs = self._inputs(text)
289
+ buckets = {int(layer): {} for layer in self.settings.layers}
290
+ with self._capture_hooks(self.settings.layers, buckets):
291
+ self.model(**inputs, use_cache=False)
292
+
293
+ seq_len = int(inputs['input_ids'].shape[1])
294
+ idx = self._resolve_index(int(token_index), seq_len)
295
+ rows: list[list[object]] = []
296
+ for layer in self.settings.layers:
297
+ residual = buckets[int(layer)]['hidden'][0, idx]
298
+ sae = self.sae_store.get(int(layer))
299
+ encoding = sae.encode(residual)
300
+ reconstruction = sae.decode_sparse(encoding)
301
+ metrics = reconstruction_metrics(residual, reconstruction)
302
+ values = encoding.values.float().clamp_min(0)
303
+ positive = values[values > 0]
304
+ total = positive.sum()
305
+ if positive.numel() <= 1 or float(total.item()) <= 0:
306
+ entropy = 0.0
307
+ else:
308
+ probs = positive / total
309
+ entropy = float((-(probs * torch.log(probs)).sum() / math.log(positive.numel())).item())
310
+ top5_fraction = (
311
+ float(values[: min(5, values.numel())].sum().item() / total.item())
312
+ if float(total.item()) > 0
313
+ else 0.0
314
+ )
315
+ rows.append(
316
+ [
317
+ int(layer),
318
+ float(metrics['cosine']),
319
+ float(metrics['nmse']),
320
+ int(encoding.active_count),
321
+ float(values[0].item()) if values.numel() else 0.0,
322
+ top5_fraction,
323
+ entropy,
324
+ ]
325
+ )
326
+ ids = inputs['input_ids'][0].tolist()
327
+ tokens = [self.tokenizer.decode([token_id]) for token_id in ids]
328
+ return LayerSweepResult(tokens=tokens, token_index=idx, rows=rows)
329
+
330
  def token_html(self, tokens: list[str], selected_index: int) -> str:
331
  chips = []
332
  for idx, token in enumerate(tokens):
 
366
  assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
367
  inputs = self._inputs(text)
368
  prompt_len = int(inputs['input_ids'].shape[1])
369
+ idx = self._resolve_index(int(token_index), prompt_len)
 
 
 
 
370
 
371
  sae = self.sae_store.get(int(layer))
372
  capture: dict = {}
 
388
  with self._delta_hook(int(layer), idx, delta):
389
  modified = self.model.generate(**inputs, **generation_kwargs)
390
 
391
+ # Live negative control: one extra forward pass, not another full generation.
392
+ control_delta = normalized_random_control(
393
+ delta,
394
+ seed=self._control_seed(text, int(layer), int(feature_id), mode, float(coefficient)),
395
+ )
396
+ with self._delta_hook(int(layer), idx, control_delta):
397
+ random_out = self.model(**inputs, use_cache=False)
398
+ random_logits = random_out.logits[0, -1]
399
+
400
  baseline_ids = baseline.sequences[0, prompt_len:]
401
  modified_ids = modified.sequences[0, prompt_len:]
402
  baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
 
407
  baseline_logits = baseline.scores[0][0]
408
  modified_logits = modified.scores[0][0]
409
  js = js_divergence_from_logits(baseline_logits, modified_logits)
410
+ random_js = js_divergence_from_logits(baseline_logits, random_logits)
411
+ js_ratio = abs(js) / max(abs(random_js), 1e-12)
412
 
413
  target_token = ''
414
  target_count = 0
415
+ bp = mp = rp = log_delta = random_log_delta = specificity = None
416
  if target_text.strip():
417
  target_ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
418
  target_count = len(target_ids)
 
421
  target_token = self.tokenizer.decode([target_id])
422
  p = torch.softmax(baseline_logits.float(), dim=-1)
423
  q = torch.softmax(modified_logits.float(), dim=-1)
424
+ r = torch.softmax(random_logits.float(), dim=-1)
425
  bp = float(p[target_id].item())
426
  mp = float(q[target_id].item())
427
+ rp = float(r[target_id].item())
428
  log_delta = safe_log_probability(mp) - safe_log_probability(bp)
429
+ random_log_delta = safe_log_probability(rp) - safe_log_probability(bp)
430
+ specificity = abs(log_delta) / max(abs(random_log_delta), 1e-12)
431
 
432
  return InterventionResult(
433
  baseline_text=baseline_text,
 
436
  delta_activation=float(spec.delta_activation(original_activation)),
437
  perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
438
  js_divergence=float(js),
439
+ random_js_divergence=float(random_js),
440
+ js_specificity_ratio=float(js_ratio),
441
  target_text=target_text,
442
  target_token=target_token,
443
  target_token_count=target_count,
444
  baseline_target_prob=bp,
445
  modified_target_prob=mp,
446
+ random_target_prob=rp,
447
  target_logprob_delta=log_delta,
448
+ random_target_logprob_delta=random_log_delta,
449
+ target_specificity_ratio=specificity,
450
  top_token_rows=self._top_token_rows(
451
  self.tokenizer, baseline_logits, modified_logits, k=8
452
  ),
453
  )
454
 
455
+ @torch.inference_mode()
456
+ def dose_response(
457
+ self,
458
+ text: str,
459
+ layer: int,
460
+ token_index: int,
461
+ feature_id: int,
462
+ target_text: str,
463
+ multipliers: Sequence[float] = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0),
464
+ ) -> DoseResponseResult:
465
+ if not target_text.strip():
466
+ raise ValueError('Dose-response requires a target continuation.')
467
+ self.ensure_ready(preload_saes=False)
468
+ assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
469
+ inputs = self._inputs(text)
470
+ prompt_len = int(inputs['input_ids'].shape[1])
471
+ idx = self._resolve_index(int(token_index), prompt_len)
472
+ sae = self.sae_store.get(int(layer))
473
+
474
+ capture: dict = {}
475
+ with self._capture_hook(int(layer), capture):
476
+ baseline_out = self.model(**inputs, use_cache=False)
477
+ baseline_logits = baseline_out.logits[0, -1]
478
+ residual = capture['hidden'][0, idx]
479
+ encoding = sae.encode(residual)
480
+ original_activation = encoding.activation_for(int(feature_id))
481
+ direction = sae.decoder_direction(int(feature_id))
482
+
483
+ target_ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
484
+ if not target_ids:
485
+ raise ValueError('Target continuation tokenized to an empty sequence.')
486
+ target_id = int(target_ids[0])
487
+ target_token = self.tokenizer.decode([target_id])
488
+ baseline_prob = float(torch.softmax(baseline_logits.float(), dim=-1)[target_id].item())
489
+
490
+ rows: list[list[object]] = []
491
+ for multiplier in multipliers:
492
+ spec = InterventionSpec('scale', float(multiplier))
493
+ delta = residual_delta(direction, original_activation, spec)
494
+ if float(torch.linalg.vector_norm(delta.float()).item()) == 0.0:
495
+ modified_logits = baseline_logits
496
+ else:
497
+ with self._delta_hook(int(layer), idx, delta):
498
+ output = self.model(**inputs, use_cache=False)
499
+ modified_logits = output.logits[0, -1]
500
+ modified_prob = float(torch.softmax(modified_logits.float(), dim=-1)[target_id].item())
501
+ log_delta = safe_log_probability(modified_prob) - safe_log_probability(baseline_prob)
502
+ rows.append(
503
+ [
504
+ float(multiplier),
505
+ float(spec.delta_activation(original_activation)),
506
+ float(torch.linalg.vector_norm(delta.float()).item()),
507
+ baseline_prob,
508
+ modified_prob,
509
+ float(log_delta),
510
+ float(js_divergence_from_logits(baseline_logits, modified_logits)),
511
+ ]
512
+ )
513
+ return DoseResponseResult(
514
+ feature_activation=float(original_activation),
515
+ target_token=target_token,
516
+ target_token_count=len(target_ids),
517
+ rows=rows,
518
+ )
519
+
520
 
521
  RUNTIME = FeatureLensRuntime()
522
 
featurelens/stats.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ def bootstrap_mean_ci(
7
+ values: np.ndarray | list[float],
8
+ *,
9
+ confidence: float = 0.95,
10
+ n_resamples: int = 5000,
11
+ seed: int = 42,
12
+ ) -> tuple[float, float]:
13
+ """Percentile bootstrap CI for a sample mean."""
14
+ x = np.asarray(values, dtype=float)
15
+ x = x[np.isfinite(x)]
16
+ if x.size == 0:
17
+ return float('nan'), float('nan')
18
+ if x.size == 1:
19
+ value = float(x[0])
20
+ return value, value
21
+ rng = np.random.default_rng(seed)
22
+ sample_idx = rng.integers(0, x.size, size=(int(n_resamples), x.size))
23
+ means = x[sample_idx].mean(axis=1)
24
+ alpha = (1.0 - float(confidence)) / 2.0
25
+ low, high = np.quantile(means, [alpha, 1.0 - alpha])
26
+ return float(low), float(high)
27
+
28
+
29
+ def paired_bootstrap_difference_ci(
30
+ a: np.ndarray | list[float],
31
+ b: np.ndarray | list[float],
32
+ *,
33
+ confidence: float = 0.95,
34
+ n_resamples: int = 5000,
35
+ seed: int = 42,
36
+ ) -> tuple[float, float]:
37
+ """Bootstrap CI for mean(a - b), preserving paired rows."""
38
+ x = np.asarray(a, dtype=float)
39
+ y = np.asarray(b, dtype=float)
40
+ mask = np.isfinite(x) & np.isfinite(y)
41
+ x = x[mask]
42
+ y = y[mask]
43
+ if x.size == 0:
44
+ return float('nan'), float('nan')
45
+ diff = x - y
46
+ return bootstrap_mean_ci(
47
+ diff,
48
+ confidence=confidence,
49
+ n_resamples=n_resamples,
50
+ seed=seed,
51
+ )
52
+
53
+
54
+ def paired_sign_flip_pvalue(
55
+ a: np.ndarray | list[float],
56
+ b: np.ndarray | list[float],
57
+ *,
58
+ n_permutations: int = 20000,
59
+ seed: int = 42,
60
+ ) -> float:
61
+ """Two-sided paired randomization test for a non-zero mean difference."""
62
+ x = np.asarray(a, dtype=float)
63
+ y = np.asarray(b, dtype=float)
64
+ mask = np.isfinite(x) & np.isfinite(y)
65
+ diff = (x - y)[mask]
66
+ if diff.size == 0:
67
+ return float('nan')
68
+ observed = abs(float(diff.mean()))
69
+ if observed == 0.0:
70
+ return 1.0
71
+ rng = np.random.default_rng(seed)
72
+ extreme = 0
73
+ batch = 2000
74
+ remaining = int(n_permutations)
75
+ while remaining > 0:
76
+ n = min(batch, remaining)
77
+ signs = rng.choice(np.array([-1.0, 1.0]), size=(n, diff.size))
78
+ permuted = np.abs((signs * diff).mean(axis=1))
79
+ extreme += int(np.count_nonzero(permuted >= observed))
80
+ remaining -= n
81
+ return float((extreme + 1) / (int(n_permutations) + 1))
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
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
 
 
1
  [project]
2
  name = "featurelens"
3
+ version = "0.2.0"
4
  description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
5
  requires-python = ">=3.10"
6
 
requirements.txt CHANGED
@@ -1,7 +1,7 @@
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
 
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.24.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
research_config.json CHANGED
@@ -2,7 +2,11 @@
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": [
@@ -19,8 +23,27 @@
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
  }
 
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": [
6
+ 4,
7
+ 14,
8
+ 26
9
+ ],
10
  "sae_top_k": 50,
11
  "sae_width": 32768,
12
  "concepts": [
 
23
  "causal_tasks": 28,
24
  "split_seed": 42,
25
  "feature_selection": "training-split AUROC with activation-rate contrast tie-break",
26
+ "held_out_metrics": [
27
+ "AUROC",
28
+ "F1"
29
+ ],
30
+ "causal_interventions": [
31
+ "ablate",
32
+ "scale_2x"
33
+ ],
34
  "negative_control": "norm-matched random residual direction",
35
+ "primary_causal_metric": "target first-token log-probability delta",
36
+ "live_causal_controls": "norm-matched random residual direction",
37
+ "dose_response_multipliers": [
38
+ 0.0,
39
+ 0.5,
40
+ 1.0,
41
+ 1.5,
42
+ 2.0,
43
+ 3.0
44
+ ],
45
+ "statistical_inference": [
46
+ "bootstrap_95_ci",
47
+ "paired_sign_flip_test"
48
+ ]
49
  }
scripts/release_check.py CHANGED
@@ -1,64 +1,262 @@
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()
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import subprocess
5
  from collections import Counter
6
  from pathlib import Path
7
 
8
+
9
  ROOT = Path(__file__).resolve().parents[1]
10
 
11
+ # Any non-ignored repository file larger than this is suspicious.
12
+ # FeatureLens should not contain model weights, SAE checkpoints,
13
+ # activation dumps, virtual environments, etc.
14
+ MAX_FILE_SIZE_BYTES = 5_000_000 # 5 MB
15
+
16
  REQUIRED = [
17
+ "README.md",
18
+ "app.py",
19
+ "requirements.txt",
20
+ "research_config.json",
21
+ "featurelens/runtime.py",
22
+ "featurelens/sae.py",
23
+ "featurelens/interventions.py",
24
+ "featurelens/stats.py",
25
+ "experiments/run_all.py",
26
+ "data/prompts.jsonl",
27
+ "data/causal_tasks.jsonl",
28
  ]
29
 
30
 
31
  def load_jsonl(path: Path) -> list[dict]:
32
+ """Load a JSONL file into a list of dictionaries."""
33
+ return [
34
+ json.loads(line)
35
+ for line in path.read_text(encoding="utf-8").splitlines()
36
+ if line.strip()
37
+ ]
38
 
39
 
40
+ def repository_candidates() -> list[Path]:
41
+ """
42
+ Return files that are either:
43
+
44
+ - already tracked by Git, or
45
+ - untracked but not ignored by .gitignore.
46
+
47
+ This deliberately excludes files such as .venv contents when .venv/
48
+ is correctly listed in .gitignore.
49
+
50
+ Including untracked, non-ignored files is useful because it catches
51
+ accidental large files before someone runs `git add .`.
52
+ """
53
+ try:
54
+ result = subprocess.run(
55
+ [
56
+ "git",
57
+ "ls-files",
58
+ "--cached",
59
+ "--others",
60
+ "--exclude-standard",
61
+ ],
62
+ cwd=ROOT,
63
+ capture_output=True,
64
+ text=True,
65
+ check=True,
66
+ )
67
+ except FileNotFoundError as exc:
68
+ raise SystemExit(
69
+ "Git is required to run the FeatureLens release check."
70
+ ) from exc
71
+ except subprocess.CalledProcessError as exc:
72
+ stderr = exc.stderr.strip()
73
+ raise SystemExit(
74
+ f"Could not inspect repository files with Git: {stderr}"
75
+ ) from exc
76
+
77
+ candidates: list[Path] = []
78
+
79
+ for relative_path in result.stdout.splitlines():
80
+ relative_path = relative_path.strip()
81
+
82
+ if not relative_path:
83
+ continue
84
+
85
+ path = ROOT / relative_path
86
+
87
+ # A tracked file may have been deleted locally but not yet committed.
88
+ # Such a path should not be size-checked.
89
+ if path.is_file():
90
+ candidates.append(path)
91
+
92
+ return candidates
93
+
94
+
95
+ def check_required_files() -> None:
96
+ """Ensure the repository contains all files required for a release."""
97
  missing = [path for path in REQUIRED if not (ROOT / path).exists()]
98
+
99
  if missing:
100
+ raise SystemExit(f"Missing required files: {missing}")
101
+
102
+
103
+ def check_config(config: dict) -> None:
104
+ """Validate the research configuration expected by FeatureLens v0.2."""
105
+ if config.get("layers") != [4, 14, 26]:
106
+ raise SystemExit(
107
+ f'Unexpected layers: {config.get("layers")}. '
108
+ "Expected [4, 14, 26]."
109
+ )
110
+
111
+ if config.get("model_id") != "Qwen/Qwen3-1.7B-Base":
112
+ raise SystemExit(
113
+ f'Unexpected model_id: {config.get("model_id")}.'
114
+ )
115
+
116
+ if config.get("sae_width") != 32768:
117
+ raise SystemExit(
118
+ f'Unexpected sae_width: {config.get("sae_width")}. '
119
+ "Expected 32768."
120
+ )
121
+
122
+ expected_multipliers = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0]
123
+
124
+ if config.get("dose_response_multipliers") != expected_multipliers:
125
+ raise SystemExit(
126
+ "Unexpected dose_response_multipliers: "
127
+ f'{config.get("dose_response_multipliers")}. '
128
+ f"Expected {expected_multipliers}."
129
+ )
130
+
131
+
132
+ def check_datasets(config: dict) -> tuple[list[dict], list[dict]]:
133
+ """Validate discovery and causal benchmark datasets."""
134
+ prompts = load_jsonl(ROOT / "data" / "prompts.jsonl")
135
+ causal = load_jsonl(ROOT / "data" / "causal_tasks.jsonl")
136
+
137
+ expected_prompt_count = config.get("discovery_prompts")
138
+ expected_causal_count = config.get("causal_tasks")
139
+
140
+ if len(prompts) != expected_prompt_count:
141
+ raise SystemExit(
142
+ "Discovery prompt count mismatch: "
143
+ f"found {len(prompts)}, expected {expected_prompt_count}."
144
+ )
145
+
146
+ if len(causal) != expected_causal_count:
147
+ raise SystemExit(
148
+ "Causal task count mismatch: "
149
+ f"found {len(causal)}, expected {expected_causal_count}."
150
+ )
151
+
152
+ concept_counts = Counter(row["concept"] for row in prompts)
153
+
154
+ expected_concepts = config.get("concepts", [])
155
+
156
+ if set(concept_counts) != set(expected_concepts):
157
+ raise SystemExit(
158
+ "Discovery dataset concepts do not match research_config.json.\n"
159
+ f"Dataset concepts: {sorted(concept_counts)}\n"
160
+ f"Config concepts: {sorted(expected_concepts)}"
161
+ )
162
+
163
+ # The controlled discovery benchmark is intentionally balanced.
164
+ if len(set(concept_counts.values())) != 1:
165
+ raise SystemExit(
166
+ f"Discovery concepts are not balanced: {dict(concept_counts)}"
167
+ )
168
+
169
+ return prompts, causal
170
+
171
+
172
+ def check_oversized_files() -> None:
173
+ """
174
+ Reject unexpectedly large files that could be committed/pushed.
175
+
176
+ Importantly, this does NOT recursively scan .venv or other ignored
177
+ directories. Git decides what counts as a repository candidate.
178
+ """
179
+ oversized: list[str] = []
180
+
181
+ for path in repository_candidates():
182
+ size_bytes = path.stat().st_size
183
+
184
+ if size_bytes > MAX_FILE_SIZE_BYTES:
185
+ relative_path = path.relative_to(ROOT)
186
+ size_mb = size_bytes / 1_000_000
187
+
188
+ oversized.append(
189
+ f"{relative_path} ({size_mb:.1f} MB)"
190
+ )
191
+
192
  if oversized:
193
+ formatted = "\n - ".join(oversized)
194
+
195
+ raise SystemExit(
196
+ "Repository contains unexpectedly large tracked/unignored "
197
+ "candidates:\n"
198
+ f" - {formatted}\n\n"
199
+ "If a file is a legitimate local artifact, add it to .gitignore. "
200
+ "Model weights, SAE checkpoints, activation dumps, virtual "
201
+ "environments, and caches should not be committed."
202
+ )
203
+
204
+
205
+ def check_readme() -> None:
206
+ """Validate important Hugging Face Space/release metadata."""
207
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
208
+
209
+ required_strings = [
210
+ "sdk: gradio",
211
+ 'sdk_version: "6.24.0"',
212
+ "Qwen/Qwen3-1.7B-Base",
213
+ ]
214
+
215
+ missing = [
216
+ value
217
+ for value in required_strings
218
+ if value not in readme
219
+ ]
220
+
221
+ if missing:
222
+ raise SystemExit(
223
+ f"README.md is missing required metadata/content: {missing}"
224
+ )
225
+
226
+ readme_lower = readme.lower()
227
+
228
+ if "norm-matched random" not in readme_lower:
229
+ raise SystemExit(
230
+ 'README.md should describe the "norm-matched random" control.'
231
+ )
232
+
233
+ if "dose" not in readme_lower:
234
+ raise SystemExit(
235
+ "README.md should describe the dose-response experiment."
236
+ )
237
+
238
+
239
+ def main() -> None:
240
+ check_required_files()
241
+
242
+ config = json.loads(
243
+ (ROOT / "research_config.json").read_text(encoding="utf-8")
244
+ )
245
+
246
+ check_config(config)
247
+
248
+ prompts, causal = check_datasets(config)
249
+
250
+ check_oversized_files()
251
 
252
+ check_readme()
 
 
 
253
 
254
+ print("FeatureLens release check: PASS")
255
+ print(f" discovery prompts: {len(prompts)}")
256
+ print(f" causal tasks: {len(causal)}")
257
  print(f' layers: {config["layers"]}')
258
+ print(" release: v0.2.0")
259
 
260
 
261
+ if __name__ == "__main__":
262
+ main()
tests/test_stats.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ from featurelens.stats import (
4
+ bootstrap_mean_ci,
5
+ paired_bootstrap_difference_ci,
6
+ paired_sign_flip_pvalue,
7
+ )
8
+
9
+
10
+ def test_bootstrap_mean_ci_contains_sample_mean():
11
+ values = np.array([0.7, 0.8, 0.9, 0.85, 0.75])
12
+ low, high = bootstrap_mean_ci(values, n_resamples=1000, seed=1)
13
+ assert low <= values.mean() <= high
14
+
15
+
16
+ def test_paired_bootstrap_detects_positive_difference():
17
+ a = np.array([2.0, 2.2, 1.8, 2.1, 2.4])
18
+ b = np.array([0.5, 0.6, 0.4, 0.7, 0.5])
19
+ low, high = paired_bootstrap_difference_ci(a, b, n_resamples=1000, seed=2)
20
+ assert low > 0
21
+ assert high > low
22
+
23
+
24
+ def test_sign_flip_small_for_consistent_effect():
25
+ a = np.array([2.0, 2.1, 2.3, 2.2, 2.4, 2.5, 2.1, 2.2])
26
+ b = np.array([0.2, 0.4, 0.3, 0.5, 0.2, 0.4, 0.3, 0.2])
27
+ p = paired_sign_flip_pvalue(a, b, n_permutations=5000, seed=3)
28
+ assert p < 0.05
29
+
30
+
31
+ def test_sign_flip_one_for_identical_pairs():
32
+ x = np.array([1.0, 2.0, 3.0])
33
+ assert paired_sign_flip_pvalue(x, x, n_permutations=1000, seed=4) == 1.0