ArchitSharma commited on
Commit
3a2b2e4
·
1 Parent(s): 0481a55

Release FeatureLens v0.14.0

Browse files
CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ## v0.13.0
2
 
3
  - Added deterministic **32-resample candidate-support diagnostics** from the same concept-discovery activation batch; each displayed feature now reports shortlist support and median resample rank without another model forward.
@@ -5,7 +19,6 @@
5
  - Added **pairwise target-preference shifts** derived from the same cross-target scores: Δ(A−B) = Δmean(A) − Δmean(B), with a table and plot and no additional inference.
6
  - Kept HF acceptance quota-aware: only concept discovery and cross-target profiling are touched GPU paths.
7
 
8
- # Changelog
9
 
10
  ## v0.12.0
11
 
 
1
+ # Changelog
2
+
3
+ ## v0.14.0
4
+
5
+ - Transitioned the project from live-feature expansion toward the full offline empirical study.
6
+ - Changed offline SAE concept evidence from final-token-only activations to **prompt-wide max-pooled activations across non-padding tokens**, while saving separate final-token sparse activation matrices for local diagnostics.
7
+ - Added `experiments/analyze_stability.py` with 128 deterministic balanced activation resamples and per-feature shortlist support/rank summaries.
8
+ - Added `experiments/analyze_study.py` to join held-out AUROC/F1, paraphrase robustness, candidate stability, feature activity, and random-normalized target/JS causal specificity by controlled concept.
9
+ - Added descriptive cross-concept association-vs-causality correlations and new association/candidate-stability report figures.
10
+ - Expanded the public **Offline study** tab into an artifact-backed results dashboard that remains explicitly empty until real study artifacts are committed.
11
+ - Added `python experiments/run_all.py --resume` for interrupted/preemptible GPU sessions and `python experiments/run_analysis_only.py` for CPU-only re-analysis once inference artifacts exist.
12
+ - Added `scripts/validate_artifacts.py` to verify prompt-wide activation provenance and public study artifact schemas before commit.
13
+ - Added dedicated offline-study methodology/validation documentation and automated tests for prompt-wide pooling, stability scoring, task-paired specificity, study UI states, and correlation guardrails.
14
+
15
  ## v0.13.0
16
 
17
  - Added deterministic **32-resample candidate-support diagnostics** from the same concept-discovery activation batch; each displayed feature now reports shortlist support and median resample rank without another model forward.
 
19
  - Added **pairwise target-preference shifts** derived from the same cross-target scores: Δ(A−B) = Δmean(A) − Δmean(B), with a table and plot and no additional inference.
20
  - Kept HF acceptance quota-aware: only concept discovery and cross-target profiling are touched GPU paths.
21
 
 
22
 
23
  ## v0.12.0
24
 
README.md CHANGED
@@ -13,7 +13,7 @@ license: mit
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
- > **v0.13:** a stability-and-target-selectivity release that adds deterministic resample support to live discovery and extracts effect concentration, signed bias, and pairwise target-preference shifts from the existing cross-target GPU call.
17
 
18
  **Research question:**
19
 
@@ -253,9 +253,9 @@ The offline pipeline computes:
253
 
254
  Feature selection is performed using the **training split only**. Paraphrase pairs never cross the train/test boundary.
255
 
256
- ## Run the offline benchmark
257
 
258
- A CUDA machine is strongly recommended.
259
 
260
  ```bash
261
  python -m venv .venv
@@ -264,33 +264,72 @@ pip install -r requirements.txt
264
  python experiments/run_all.py
265
  ```
266
 
267
- The pipeline is:
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  ```text
270
  build_dataset
271
  → collect_activations
 
 
272
  → evaluate_features
 
 
 
273
  → run_causal
274
  → run_feature_sets
 
 
 
 
275
  → make_report
 
276
  ```
277
 
278
  Outputs are generated under `artifacts/`:
279
 
280
  ```text
281
  artifacts/
282
- ├── activations/
283
  ├── feature_catalog.csv
284
  ├── layer_metrics.csv
285
  ├── stability.csv
 
286
  ├── causal_results.csv
287
  ├── feature_set_results.csv
 
 
288
  ├── summary.json
289
  ├── report.md
290
  └── figures/
 
 
 
 
 
 
291
  ```
292
 
293
- `summary.json` and `report.md` are generated from measured artifacts. No scientific benchmark numbers are fabricated in the repository.
 
 
 
 
 
 
 
 
294
 
295
  ## Hugging Face deployment
296
 
@@ -335,19 +374,24 @@ FeatureLens/
335
  │ ├── metrics.py
336
  │ ├── stats.py
337
  │ ├── selection.py
338
- ── catalog.py
 
339
  ├── experiments/
340
  │ ├── build_dataset.py
341
  │ ├── collect_activations.py
342
  │ ├── evaluate_features.py
343
  │ ├── run_causal.py
344
  │ ├── run_feature_sets.py
 
 
345
  │ ├── make_report.py
 
346
  │ └── run_all.py
347
  ├── data/
348
  ├── tests/
349
  ├── scripts/
350
  │ ├── release_check.py
 
351
  │ └── ui_smoke.py
352
  ├── docs/
353
  └── research_config.json
@@ -381,6 +425,17 @@ v0.12 bundles several related improvements instead of adding one isolated widget
381
  - **Cross-target causal profile** screens up to three candidate ablations across two to five exact continuations. It reports target-wise mean/sequence log-probability deltas, next-token JS, the strongest target per feature, and a target-profile ratio. This stage intentionally omits random controls; controlled candidate specificity remains the matched-random causal test.
382
  - HF validation remains GPU-budget-aware: rerun only the touched discovery path and the new cross-target path. Unchanged paraphrase, trajectory, set-size, dose-response, cue, and focus paths stay covered by automated tests.
383
 
 
 
 
 
 
 
 
 
 
 
 
384
  ## Validation
385
 
386
  ```bash
@@ -407,7 +462,7 @@ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.12 guide uses the **exact
407
  ## Resume-ready description
408
 
409
  > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
410
- > Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, concept-guided candidate discovery, token/prompt-wide, completion-cue, and cue × context feature evidence, reconstruction-preserving single and multi-feature interventions, full-continuation and contrastive preference scoring, dose-response analysis, decoder-geometry/non-additivity diagnostics, discovery-to-causality rank analysis, multi-candidate random-controlled specificity screening, split-half discovery stability, cross-target causal profiling, controlled evidence-pattern synthesis, and norm-matched random-control ensembles.
411
 
412
  ## Acknowledgements
413
 
 
13
 
14
  # FeatureLens — Causal Interpretability Workbench
15
 
16
+ > **v0.14:** an offline-study transition release: prompt-wide held-out SAE discovery, activation-resample stability, cross-concept association-vs-causality synthesis, a resume-safe study runner, and an artifact-backed public study dashboard.
17
 
18
  **Research question:**
19
 
 
253
 
254
  Feature selection is performed using the **training split only**. Paraphrase pairs never cross the train/test boundary.
255
 
256
+ ## Run the offline study
257
 
258
+ A CUDA machine is strongly recommended for activation collection and causal intervention stages. v0.14 changes the offline SAE concept representation to **prompt-wide max-pooled activation across all non-padding prompt tokens**. Final-token sparse activations are saved separately for local diagnostics; the dense residual linear probe remains a final-token baseline.
259
 
260
  ```bash
261
  python -m venv .venv
 
264
  python experiments/run_all.py
265
  ```
266
 
267
+ For interruptible/preemptible GPU sessions, use:
268
+
269
+ ```bash
270
+ python experiments/run_all.py --resume
271
+ ```
272
+
273
+ Completed stages are skipped when their expected artifacts already exist. After the expensive model/SAE stages have completed, all CPU-only evaluation/report logic can be rerun without another inference pass:
274
+
275
+ ```bash
276
+ python experiments/run_analysis_only.py
277
+ ```
278
+
279
+ The full pipeline is:
280
 
281
  ```text
282
  build_dataset
283
  → collect_activations
284
+ ├─ prompt-wide SAE feature maxima
285
+ └─ final-token sparse features + final-token residuals
286
  → evaluate_features
287
+ ├─ held-out AUROC/F1
288
+ ├─ dense residual linear probe
289
+ └─ prompt-wide paraphrase stability
290
  → run_causal
291
  → run_feature_sets
292
+ → analyze_stability
293
+ └─ 128 balanced activation resamples
294
+ → analyze_study
295
+ └─ association/stability ↔ random-normalized causal evidence
296
  → make_report
297
+ → validate_artifacts
298
  ```
299
 
300
  Outputs are generated under `artifacts/`:
301
 
302
  ```text
303
  artifacts/
304
+ ├── activations/ # large; keep gitignored
305
  ├── feature_catalog.csv
306
  ├── layer_metrics.csv
307
  ├── stability.csv
308
+ ├── selection_stability.csv
309
  ├── causal_results.csv
310
  ├── feature_set_results.csv
311
+ ├── study_feature_summary.csv
312
+ ├── study_summary.json
313
  ├── summary.json
314
  ├── report.md
315
  └── figures/
316
+ ├── feature_auroc.png
317
+ ├── layer_diagnostics.png
318
+ ├── causal_effects.png
319
+ ├── feature_set_effects.png
320
+ ├── association_vs_causality.png
321
+ └── candidate_stability.png
322
  ```
323
 
324
+ Validate the small public artifacts before committing them:
325
+
326
+ ```bash
327
+ python -m scripts.validate_artifacts
328
+ ```
329
+
330
+ `summary.json`, `study_summary.json`, and `report.md` are generated from measured artifacts. No scientific benchmark numbers are fabricated in the repository. The live **Offline study** tab automatically turns into a results dashboard once these small CSV/JSON/figure artifacts are committed.
331
+
332
+ See [`docs/OFFLINE_STUDY.md`](docs/OFFLINE_STUDY.md) for the staged workflow and interpretation guardrails.
333
 
334
  ## Hugging Face deployment
335
 
 
374
  │ ├── metrics.py
375
  │ ├── stats.py
376
  │ ├── selection.py
377
+ ── catalog.py
378
+ │ └── study.py
379
  ├── experiments/
380
  │ ├── build_dataset.py
381
  │ ├── collect_activations.py
382
  │ ├── evaluate_features.py
383
  │ ├── run_causal.py
384
  │ ├── run_feature_sets.py
385
+ │ ├── analyze_stability.py
386
+ │ ├── analyze_study.py
387
  │ ├── make_report.py
388
+ │ ├── run_analysis_only.py
389
  │ └── run_all.py
390
  ├── data/
391
  ├── tests/
392
  ├── scripts/
393
  │ ├── release_check.py
394
+ │ ├── validate_artifacts.py
395
  │ └── ui_smoke.py
396
  ├── docs/
397
  └── research_config.json
 
425
  - **Cross-target causal profile** screens up to three candidate ablations across two to five exact continuations. It reports target-wise mean/sequence log-probability deltas, next-token JS, the strongest target per feature, and a target-profile ratio. This stage intentionally omits random controls; controlled candidate specificity remains the matched-random causal test.
426
  - HF validation remains GPU-budget-aware: rerun only the touched discovery path and the new cross-target path. Unchanged paraphrase, trajectory, set-size, dose-response, cue, and focus paths stay covered by automated tests.
427
 
428
+ ## v0.14 offline-study transition
429
+
430
+ v0.14 deliberately stops expanding the live intervention surface and strengthens the empirical study behind it.
431
+
432
+ - **Prompt-wide offline SAE evidence:** concept-feature discovery and paraphrase stability use each feature's maximum activation across all non-padding prompt tokens. This avoids treating an arbitrary final token as the semantic representation of an entire prompt. Final-token sparse activations remain available separately for local diagnostics.
433
+ - **Activation-resample candidate selection stability:** `analyze_stability.py` performs 128 deterministic balanced resamples of the saved prompt-wide activations and records shortlist support plus median/mean resample rank. No model inference is repeated.
434
+ - **Association vs random-normalized causality across concepts:** `analyze_study.py` joins held-out AUROC/F1, paraphrase stability, candidate resample support, causal active rate, target specificity, and JS specificity for the selected feature of every controlled concept. Cross-concept Spearman correlations are descriptive because there are only seven concepts.
435
+ - **Artifact-backed Offline study tab:** the public app remains honest when no benchmark has been run, then automatically displays committed study tables and figures when real artifacts exist.
436
+ - **Resume-safe full runner:** `python experiments/run_all.py --resume` skips completed stages; `python experiments/run_analysis_only.py` reruns only CPU evaluation/stability/report logic once expensive inference artifacts exist.
437
+ - **Artifact schema validation:** `python -m scripts.validate_artifacts` checks that public study outputs are non-empty, prompt-wide v0.14 activations were used, and required causal/stability columns exist.
438
+
439
  ## Validation
440
 
441
  ```bash
 
462
  ## Resume-ready description
463
 
464
  > **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
465
+ > Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, concept-guided candidate discovery, token/prompt-wide, completion-cue, and cue × context feature evidence, reconstruction-preserving single and multi-feature interventions, full-continuation and contrastive preference scoring, dose-response analysis, decoder-geometry/non-additivity diagnostics, discovery-to-causality rank analysis, multi-candidate random-controlled specificity screening, split-half/resample discovery stability, cross-target causal profiling, controlled evidence-pattern synthesis, prompt-wide held-out SAE evaluation, association-vs-random-normalized-causality study synthesis, and norm-matched random-control ensembles.
466
 
467
  ## Acknowledgements
468
 
app.py CHANGED
@@ -6,6 +6,7 @@ import pandas as pd
6
  from featurelens.config import SETTINGS
7
  from featurelens.hf_runtime import gpu
8
  from featurelens.runtime import RUNTIME
 
9
 
10
  # Restrained, print-inspired palette. The app deliberately avoids saturated dashboard colors.
11
  INK_TEAL = "#708B86"
@@ -15,6 +16,8 @@ INK_PLUM = "#82768F"
15
  INK_STONE = "#8E8A83"
16
  INK_BLUEGREY = "#71808A"
17
 
 
 
18
  CSS = r"""
19
  .gradio-container {
20
  width: min(96vw, 1600px) !important;
@@ -3094,14 +3097,75 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
3094
  height=330,
3095
  )
3096
 
3097
- with gr.Tab("Offline benchmark"):
3098
- gr.Markdown(RUNTIME.catalog.benchmark_markdown())
3099
- gr.Markdown(
3100
- "The offline pipeline remains the source of held-out feature/concept AUROC + F1, reconstruction "
3101
- "quality, paraphrase stability, dense residual linear probes, and causal intervention conclusions. "
3102
- "The live workbench uses batch-context null references and random-control ensembles; "
3103
- "the saved report should be generated only from real experiment artifacts."
3104
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3105
 
3106
  with gr.Tab("Method"):
3107
  gr.Markdown(
 
6
  from featurelens.config import SETTINGS
7
  from featurelens.hf_runtime import gpu
8
  from featurelens.runtime import RUNTIME
9
+ from featurelens.study import OfflineStudy
10
 
11
  # Restrained, print-inspired palette. The app deliberately avoids saturated dashboard colors.
12
  INK_TEAL = "#708B86"
 
16
  INK_STONE = "#8E8A83"
17
  INK_BLUEGREY = "#71808A"
18
 
19
+ STUDY = OfflineStudy()
20
+
21
  CSS = r"""
22
  .gradio-container {
23
  width: min(96vw, 1600px) !important;
 
3097
  height=330,
3098
  )
3099
 
3100
+ with gr.Tab("Offline study"):
3101
+ gr.Markdown(STUDY.overview_markdown())
3102
+ gr.Markdown(STUDY.readiness_markdown(), elem_classes=["small-note"])
3103
+
3104
+ offline_study = STUDY.dataframe("study_feature_summary.csv")
3105
+ offline_stability = STUDY.dataframe("selection_stability.csv")
3106
+ offline_layers = STUDY.dataframe("layer_metrics.csv")
3107
+
3108
+ if not offline_study.empty:
3109
+ with gr.Row(equal_height=False):
3110
+ with gr.Column(scale=3):
3111
+ _table_heading("Selected feature evidence by concept")
3112
+ gr.Dataframe(
3113
+ value=offline_study,
3114
+ interactive=False,
3115
+ show_label=False,
3116
+ buttons=["fullscreen"],
3117
+ elem_classes=["result-table"],
3118
+ wrap=False,
3119
+ max_height=420,
3120
+ )
3121
+ with gr.Column(scale=2):
3122
+ gr.Image(
3123
+ value=STUDY.figure("association_vs_causality.png"),
3124
+ label="Association evidence vs causal specificity",
3125
+ interactive=False,
3126
+ show_label=True,
3127
+ height=360,
3128
+ )
3129
+
3130
+ with gr.Row(equal_height=False):
3131
+ with gr.Column(scale=3):
3132
+ _table_heading("Candidate selection stability")
3133
+ stability_preview = offline_stability.sort_values(
3134
+ ["resample_support", "full_score"], ascending=[False, False]
3135
+ ).head(40)
3136
+ gr.Dataframe(
3137
+ value=stability_preview,
3138
+ interactive=False,
3139
+ show_label=False,
3140
+ buttons=["fullscreen"],
3141
+ elem_classes=["result-table"],
3142
+ wrap=False,
3143
+ max_height=420,
3144
+ )
3145
+ with gr.Column(scale=2):
3146
+ gr.Image(
3147
+ value=STUDY.figure("candidate_stability.png"),
3148
+ label="Selected-feature resample support",
3149
+ interactive=False,
3150
+ show_label=True,
3151
+ height=360,
3152
+ )
3153
+
3154
+ _table_heading("Layer diagnostics")
3155
+ gr.Dataframe(
3156
+ value=offline_layers,
3157
+ interactive=False,
3158
+ show_label=False,
3159
+ buttons=["fullscreen"],
3160
+ elem_classes=["result-table"],
3161
+ wrap=False,
3162
+ max_height=300,
3163
+ )
3164
+ else:
3165
+ gr.Markdown(
3166
+ "The public study tables and figures appear automatically after the small offline artifacts "
3167
+ "are generated and committed. No placeholder scientific numbers are shown."
3168
+ )
3169
 
3170
  with gr.Tab("Method"):
3171
  gr.Markdown(
artifacts/README.md CHANGED
@@ -2,22 +2,43 @@
2
 
3
  This directory intentionally ships without invented empirical results.
4
 
5
- Run:
6
 
7
  ```bash
8
  python experiments/run_all.py
9
  ```
10
 
11
- to create:
12
 
13
- - activation caches;
 
 
 
 
 
 
 
 
 
 
 
 
14
  - `feature_catalog.csv`;
15
  - `layer_metrics.csv`;
16
  - `stability.csv`;
 
17
  - `causal_results.csv`;
18
  - `feature_set_results.csv`;
 
 
19
  - `summary.json`;
20
  - `report.md`;
21
- - report figures.
 
 
 
 
 
 
22
 
23
- Large residual/activation arrays are ignored by Git. Commit only small CSV/JSON/report/figure outputs if you want the live Space to display benchmark-derived feature hints and measured conclusions.
 
2
 
3
  This directory intentionally ships without invented empirical results.
4
 
5
+ Run the full study on a CUDA machine:
6
 
7
  ```bash
8
  python experiments/run_all.py
9
  ```
10
 
11
+ For an interruptible session:
12
 
13
+ ```bash
14
+ python experiments/run_all.py --resume
15
+ ```
16
+
17
+ If the expensive activation and causal artifacts already exist, regenerate only CPU analysis/report outputs with:
18
+
19
+ ```bash
20
+ python experiments/run_analysis_only.py
21
+ ```
22
+
23
+ v0.14 produces:
24
+
25
+ - prompt-wide activation caches plus separate final-token sparse activations;
26
  - `feature_catalog.csv`;
27
  - `layer_metrics.csv`;
28
  - `stability.csv`;
29
+ - `selection_stability.csv`;
30
  - `causal_results.csv`;
31
  - `feature_set_results.csv`;
32
+ - `study_feature_summary.csv`;
33
+ - `study_summary.json`;
34
  - `summary.json`;
35
  - `report.md`;
36
+ - report figures including association-vs-causality and candidate-stability plots.
37
+
38
+ Validate the public artifact set with:
39
+
40
+ ```bash
41
+ python -m scripts.validate_artifacts
42
+ ```
43
 
44
+ `artifacts/activations/` contains large residual/sparse caches and stays gitignored. Commit only the small CSV/JSON/report/figure outputs if you want the live **Offline study** tab to display measured results and benchmark-derived feature hints.
docs/METHODOLOGY.md CHANGED
@@ -583,3 +583,21 @@ For one feature with target effects `Δ_t`, v0.13 normalizes `|Δ_t|` into a pro
583
  ### Pairwise preference shifts
584
 
585
  For every unordered target pair `(A, B)`, FeatureLens derives `Δ_pref(A,B) = Δmean(A) − Δmean(B)` from the already-computed teacher-forced mean-log-probability effects. This is the intervention-induced change in token-normalized preference for A relative to B. It does not add a model call and does not replace matched-random specificity controls.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583
  ### Pairwise preference shifts
584
 
585
  For every unordered target pair `(A, B)`, FeatureLens derives `Δ_pref(A,B) = Δmean(A) − Δmean(B)` from the already-computed teacher-forced mean-log-probability effects. This is the intervention-induced change in token-normalized preference for A relative to B. It does not add a model call and does not replace matched-random specificity controls.
586
+
587
+ ## v0.14 offline-study methodology
588
+
589
+ ### Prompt-wide SAE concept evidence
590
+
591
+ Offline concept-feature evaluation now max-pools each SAE feature across every non-padding token in a prompt. This aligns the held-out study with the live prompt-wide diagnostics and avoids assigning an entire prompt's concept evidence to an arbitrary final token. Separate final-token SAE matrices are still saved for local analyses.
592
+
593
+ ### Activation-resample selection stability
594
+
595
+ `experiments/analyze_stability.py` performs deterministic balanced bootstrap resamples over the already-saved prompt-wide activation matrix. Within each resample, candidates are ranked using the live-compatible balanced score `positive selectivity × target activation rate × log1p(target mean)`. The output records shortlist support and resample-rank summaries. This is a sensitivity analysis, not a confidence interval.
596
+
597
+ ### Study-level association vs causality
598
+
599
+ `experiments/analyze_study.py` selects one feature per concept using the original train-only AUROC discipline and joins held-out AUROC/F1 with paraphrase robustness, activation-resample support, causal-task activity, target-specificity, and JS-specificity. Random-normalized causal quantities are paired at the task level before aggregation. Cross-concept Spearman correlations use only seven concepts and are therefore reported descriptively.
600
+
601
+ ### Reproducibility and artifact boundary
602
+
603
+ The full GPU + CPU pipeline supports `--resume`. Once expensive inference outputs exist, `experiments/run_analysis_only.py` reruns only CPU evaluation/stability/report stages. `scripts/validate_artifacts.py` enforces the public artifact schema and confirms that v0.14 prompt-wide activation metadata was used before results are surfaced in the public Offline study tab.
docs/OFFLINE_STUDY.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FeatureLens offline study
2
+
3
+ ## Goal
4
+
5
+ The offline study answers the project question at dataset scale:
6
+
7
+ > Do sparse features that predict a controlled concept on held-out prompts also produce behaviorally specific causal effects?
8
+
9
+ The live app is exploratory. The offline study is where FeatureLens makes held-out, random-controlled, uncertainty-aware claims.
10
+
11
+ ## Representation choice
12
+
13
+ v0.14 uses **prompt-wide max-pooled SAE activation** for concept evidence. For each prompt and SAE feature, the stored value is the maximum activation across non-padding prompt tokens. This change is motivated by the live finding that a strong final-token activation can reflect a lexical cue rather than the prompt's semantic concept.
14
+
15
+ The collector also saves `features_final_layer{layer}.npz` so local final-token analyses remain reproducible. Dense residual linear probes continue to use the final prompt-token residual and are therefore reported as a separate baseline rather than as an identical pooling scheme.
16
+
17
+ ## Full pipeline
18
+
19
+ ```bash
20
+ python experiments/run_all.py
21
+ ```
22
+
23
+ Stages:
24
+
25
+ 1. `build_dataset` — materialize 224 discovery prompts and 28 causal tasks.
26
+ 2. `collect_activations` — Qwen3 residual capture; prompt-wide and final-token SAE feature artifacts.
27
+ 3. `evaluate_features` — grouped train/test split, train-only feature selection, held-out AUROC/F1, dense residual probe, paraphrase stability.
28
+ 4. `run_causal` — selected-feature ablation/amplification with exact continuation scoring and norm-matched random ensembles.
29
+ 5. `run_feature_sets` — top-1/3/5 joint ablations and random controls.
30
+ 6. `analyze_stability` — 128 balanced activation resamples from saved prompt-wide features.
31
+ 7. `analyze_study` — join predictive, robustness, stability, and random-normalized causal evidence by concept.
32
+ 8. `make_report` — measured figures, summary JSON, and narrative report.
33
+ 9. `validate_artifacts` — schema/completeness guard before committing public results.
34
+
35
+ ## Resume after interruption
36
+
37
+ ```bash
38
+ python experiments/run_all.py --resume
39
+ ```
40
+
41
+ The runner checks expected stage outputs and skips completed stages. This is intended for preemptible or quota-limited GPU sessions.
42
+
43
+ ## CPU-only re-analysis
44
+
45
+ After activation and causal inference artifacts exist:
46
+
47
+ ```bash
48
+ python experiments/run_analysis_only.py
49
+ ```
50
+
51
+ This reruns feature evaluation, candidate stability, study synthesis, figures, report generation, and artifact validation without another model forward pass.
52
+
53
+ ## Main outputs
54
+
55
+ `study_feature_summary.csv` contains one selected feature per controlled concept with:
56
+
57
+ - held-out AUROC and F1;
58
+ - training activation rates;
59
+ - activation-resample selection support;
60
+ - paraphrase TopK Jaccard and sparse cosine;
61
+ - causal-task feature-active rate;
62
+ - mean absolute and signed target effect;
63
+ - norm-matched random mean absolute target effect;
64
+ - target-specificity ratio, paired advantage, bootstrap CI, sign-flip p-value;
65
+ - equivalent next-token JS specificity metrics.
66
+
67
+ `study_summary.json` adds descriptive cross-concept Spearman correlations such as held-out AUROC versus target-specificity ratio. There are only seven controlled concepts, so these correlations are **descriptive**, not significance claims.
68
+
69
+ ## Interpretation guardrails
70
+
71
+ - Prompt-wide max pooling detects whether a feature appears anywhere in the prompt; it discards token order.
72
+ - A high held-out AUROC remains correlational evidence.
73
+ - Candidate resample support measures shortlist sensitivity under the configured activation-resampling scheme, not feature truth or semantic purity.
74
+ - A target-specificity ratio above 1 means the SAE edit moved the exact target more than the mean norm-matched random edit; uncertainty and task coverage still matter.
75
+ - JS specificity asks a different question from target specificity: a feature can reshape the local distribution without specifically controlling the chosen target.
76
+ - Cross-concept correlations have n=7 and are descriptive.
77
+ - Large activation caches should not be committed to the repository.
docs/VALIDATION.md CHANGED
@@ -1,8 +1,10 @@
1
- # FeatureLens v0.13 validation
2
 
3
- v0.13 changes only **concept-guided discovery post-processing** and **cross-target profile post-processing**. Both changes reuse activations/scores already produced by those GPU calls. Do not rerun unrelated live experiments.
4
 
5
- ## Local release gate
 
 
6
 
7
  ```bash
8
  python3 -m pytest -q && \
@@ -12,78 +14,50 @@ python3 scripts/ui_smoke.py && \
12
  python3 scripts/release_check.py
13
  ```
14
 
15
- Expected release checker tail:
16
-
17
- ```text
18
- FeatureLens release check: PASS
19
- discovery prompts: 224
20
- causal tasks: 28
21
- layers: [4, 14, 26]
22
- feature-set sizes: [1, 3, 5]
23
- random controls: 8
24
- release: v0.13.0
25
- ```
26
-
27
- ## HF GPU call 1 — discovery stability
28
-
29
- Exact path: **Feature evidence → A. Concept-guided candidate discovery**.
30
-
31
- Use:
32
 
33
- - **Target concept:** `mathematics`
34
- - **Residual layer:** `14`
35
- - **Prompts per concept:** `4`
36
- - **Candidate features:** `12`
37
- - **Candidate ranking:** `Causal-ready at current token`
38
- - Current Workbench prompt: `The derivative of x squared is`
39
- - Current Workbench token index: `-1`
40
 
41
- Click **Discover concept-associated candidates**.
42
 
43
- Record only the new stability evidence:
 
 
 
44
 
45
- - split-half shared count and Jaccard;
46
- - summary mean resample support;
47
- - number of displayed candidates with ≥75% resample support;
48
- - for the top five rows: **Feature id**, **Resample shortlist support**, **Median resample rank**.
49
 
50
- No other discovery table columns need to be recopied unless the candidate ranking unexpectedly changes.
51
 
52
- ## HF GPU call 2 richer cross-target profile
 
 
 
 
53
 
54
- Exact path: **Feature evidence C. Cross-target causal profile**.
55
 
56
- Use:
57
 
58
- - **Features for cross-target profile:** `25992`, `16369`, `21670`
59
- - **Exact target continuations:**
60
 
61
- ```text
62
- 2x
63
- x
64
- 0
65
- x^2
66
  ```
67
 
68
- Click **Run cross-target causal profile**.
69
-
70
- Send:
71
-
72
- 1. **Target-profile summary** with the new columns **Normalized effect entropy**, **Effect concentration**, **Signed bias**, and **Profile pattern**.
73
- 2. The top six rows of **Pairwise target preference shifts** by `|Preference shift|`.
74
- 3. A screenshot of the pairwise plot if convenient.
75
 
76
- ## Do not rerun for v0.13
 
 
77
 
78
- Do **not** spend ZeroGPU quota on:
79
 
80
- - controlled candidate specificity;
81
- - candidate triage;
82
- - dose response;
83
- - paraphrase identity;
84
- - layer trajectory;
85
- - 1/3/5 feature-set sweep;
86
- - cue or cue × context tests;
87
- - focus/zoom behavior.
88
 
89
- Those inference paths are unchanged in v0.13.
 
1
+ # FeatureLens v0.14 validation
2
 
3
+ v0.14 changes the **offline study path and Offline study tab**. It does not change the live Qwen/SAE intervention callbacks validated in v0.13, so do not spend ZeroGPU quota rerunning paraphrase, dose-response, controlled specificity, cross-target, layer trajectory, or feature-set regressions.
4
 
5
+ ## A. Local release gate
6
+
7
+ From the repository root:
8
 
9
  ```bash
10
  python3 -m pytest -q && \
 
14
  python3 scripts/release_check.py
15
  ```
16
 
17
+ Expected automated test count for v0.14: **74**.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
+ ## B. HF acceptance — zero GPU calls
 
 
 
 
 
 
20
 
21
+ After pushing v0.14:
22
 
23
+ 1. Open the Space and confirm startup is clean.
24
+ 2. Open **Offline study**.
25
+ 3. Before real artifacts are committed, verify it says **Offline study not materialized yet** and does not show fabricated tables or metrics.
26
+ 4. Confirm the rest of the live tabs render normally. Do not run GPU experiments solely for this release.
27
 
28
+ ## C. Offline-study synthetic/software acceptance
 
 
 
29
 
30
+ The automated suite covers:
31
 
32
+ - prompt-wide max pooling while ignoring padding;
33
+ - balanced candidate score behavior;
34
+ - random-ensemble pairing by causal task;
35
+ - descriptive Spearman guardrails;
36
+ - offline-study missing/complete UI state.
37
 
38
+ The development integration gate additionally runs the CPU evaluation/stability/study/report stack on synthetic saved activations and validates the resulting artifact schema.
39
 
40
+ ## D. Real offline-study acceptance
41
 
42
+ When a CUDA run is available:
 
43
 
44
+ ```bash
45
+ python experiments/run_all.py --resume
 
 
 
46
  ```
47
 
48
+ Then:
 
 
 
 
 
 
49
 
50
+ ```bash
51
+ python -m scripts.validate_artifacts
52
+ ```
53
 
54
+ Required checks:
55
 
56
+ - activation metadata says `prompt-wide max activation across non-padding tokens`;
57
+ - `features_layer4/14/26.npz` exist for prompt-wide concept evidence;
58
+ - `features_final_layer4/14/26.npz` exist for local final-token diagnostics;
59
+ - `feature_catalog.csv`, `selection_stability.csv`, `causal_results.csv`, `feature_set_results.csv`, `study_feature_summary.csv`, `study_summary.json`, `summary.json`, and `report.md` are non-empty;
60
+ - report figures include `association_vs_causality.png` and `candidate_stability.png`;
61
+ - no large activation/model files are staged for Git.
 
 
62
 
63
+ After committing only the small study artifacts, restart the Space and verify **Offline study** automatically displays the measured study summary, selected-feature table, stability table, layer diagnostics, and figures.
experiments/analyze_stability.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
12
+ from experiments.common import ARTIFACT_DIR
13
+
14
+
15
+ def parse_args() -> argparse.Namespace:
16
+ parser = argparse.ArgumentParser(
17
+ description='Estimate prompt-wide candidate selection stability from saved SAE activations.'
18
+ )
19
+ parser.add_argument('--activation-dir', type=Path, default=ARTIFACT_DIR / 'activations')
20
+ parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'selection_stability.csv')
21
+ parser.add_argument('--resamples', type=int, default=128)
22
+ parser.add_argument('--top-features', type=int, default=20)
23
+ parser.add_argument('--seed', type=int, default=42)
24
+ return parser.parse_args()
25
+
26
+
27
+ def _vector_mean(matrix: sp.csr_matrix, indices: np.ndarray) -> np.ndarray:
28
+ if indices.size == 0:
29
+ return np.zeros(matrix.shape[1], dtype=np.float32)
30
+ return np.asarray(matrix[indices].mean(axis=0)).ravel().astype(np.float32, copy=False)
31
+
32
+
33
+ def _vector_rate(binary: sp.csr_matrix, indices: np.ndarray) -> np.ndarray:
34
+ if indices.size == 0:
35
+ return np.zeros(binary.shape[1], dtype=np.float32)
36
+ return np.asarray(binary[indices].mean(axis=0)).ravel().astype(np.float32, copy=False)
37
+
38
+
39
+ def balanced_candidate_score(
40
+ target_mean: np.ndarray,
41
+ other_mean: np.ndarray,
42
+ target_rate: np.ndarray,
43
+ ) -> np.ndarray:
44
+ """Live-compatible selectivity × coverage × log-magnitude candidate score."""
45
+ target = np.asarray(target_mean, dtype=np.float64)
46
+ other = np.asarray(other_mean, dtype=np.float64)
47
+ rate = np.asarray(target_rate, dtype=np.float64)
48
+ difference = target - other
49
+ denom = np.abs(target) + np.abs(other) + 1e-12
50
+ selectivity = np.where(difference > 0.0, difference / denom, 0.0)
51
+ return selectivity * np.clip(rate, 0.0, 1.0) * np.log1p(np.maximum(target, 0.0))
52
+
53
+
54
+ def _rank_top(scores: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]:
55
+ positive = np.flatnonzero(scores > 0.0)
56
+ if positive.size == 0:
57
+ return np.empty(0, dtype=int), np.empty(0, dtype=float)
58
+ k = min(int(k), int(positive.size))
59
+ candidate_scores = scores[positive]
60
+ local = np.argpartition(candidate_scores, -k)[-k:]
61
+ ids = positive[local]
62
+ ordered = np.argsort(scores[ids])[::-1]
63
+ ids = ids[ordered]
64
+ return ids.astype(int), scores[ids].astype(float)
65
+
66
+
67
+ def _sample_indices(
68
+ concept_indices: dict[str, np.ndarray],
69
+ rng: np.random.Generator,
70
+ ) -> dict[str, np.ndarray]:
71
+ return {
72
+ concept: rng.choice(indices, size=indices.size, replace=True)
73
+ for concept, indices in concept_indices.items()
74
+ }
75
+
76
+
77
+ def _concept_statistics(
78
+ matrix: sp.csr_matrix,
79
+ binary: sp.csr_matrix,
80
+ concept_indices: dict[str, np.ndarray],
81
+ ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
82
+ means = {
83
+ concept: _vector_mean(matrix, indices)
84
+ for concept, indices in concept_indices.items()
85
+ }
86
+ rates = {
87
+ concept: _vector_rate(binary, indices)
88
+ for concept, indices in concept_indices.items()
89
+ }
90
+ return means, rates
91
+
92
+
93
+ def _score_for_concept(
94
+ concept: str,
95
+ means: dict[str, np.ndarray],
96
+ rates: dict[str, np.ndarray],
97
+ ) -> np.ndarray:
98
+ other_concepts = [name for name in means if name != concept]
99
+ other_mean = np.mean(np.stack([means[name] for name in other_concepts]), axis=0)
100
+ return balanced_candidate_score(means[concept], other_mean, rates[concept])
101
+
102
+
103
+ def main() -> None:
104
+ args = parse_args()
105
+ if args.resamples < 1:
106
+ raise ValueError('--resamples must be at least 1.')
107
+ if args.top_features < 1:
108
+ raise ValueError('--top-features must be at least 1.')
109
+
110
+ metadata = json.loads((args.activation_dir / 'metadata.json').read_text(encoding='utf-8'))
111
+ pooling = metadata.get('feature_pooling', '')
112
+ if 'prompt-wide' not in pooling:
113
+ raise RuntimeError(
114
+ 'Selection stability requires v0.14 prompt-wide activation artifacts. '
115
+ 'Rerun experiments.collect_activations before this analysis.'
116
+ )
117
+
118
+ rows = metadata['rows']
119
+ layers = [int(layer) for layer in metadata['layers']]
120
+ concepts = sorted({row['concept'] for row in rows})
121
+ concept_indices = {
122
+ concept: np.array(
123
+ [idx for idx, row in enumerate(rows) if row['concept'] == concept],
124
+ dtype=int,
125
+ )
126
+ for concept in concepts
127
+ }
128
+ rng = np.random.default_rng(args.seed)
129
+ output_rows: list[dict] = []
130
+
131
+ for layer in layers:
132
+ matrix = sp.load_npz(args.activation_dir / f'features_layer{layer}.npz').tocsr()
133
+ binary = matrix.copy()
134
+ binary.data = np.ones_like(binary.data, dtype=np.float32)
135
+
136
+ full_means, full_rates = _concept_statistics(matrix, binary, concept_indices)
137
+ full_scores = {
138
+ concept: _score_for_concept(concept, full_means, full_rates)
139
+ for concept in concepts
140
+ }
141
+ full_orders: dict[str, np.ndarray] = {
142
+ concept: np.argsort(scores)[::-1]
143
+ for concept, scores in full_scores.items()
144
+ }
145
+ support: dict[tuple[str, int], int] = defaultdict(int)
146
+ ranks: dict[tuple[str, int], list[int]] = defaultdict(list)
147
+
148
+ for _ in range(args.resamples):
149
+ sampled = _sample_indices(concept_indices, rng)
150
+ means, rates = _concept_statistics(matrix, binary, sampled)
151
+ for concept in concepts:
152
+ scores = _score_for_concept(concept, means, rates)
153
+ ids, _ = _rank_top(scores, args.top_features)
154
+ for rank, feature_id in enumerate(ids.tolist(), start=1):
155
+ key = (concept, int(feature_id))
156
+ support[key] += 1
157
+ ranks[key].append(rank)
158
+
159
+ for concept in concepts:
160
+ full_rank_map = np.empty(matrix.shape[1], dtype=np.int32)
161
+ full_rank_map[full_orders[concept]] = np.arange(1, matrix.shape[1] + 1, dtype=np.int32)
162
+ seen = {
163
+ feature_id
164
+ for (seen_concept, feature_id), count in support.items()
165
+ if seen_concept == concept and count > 0
166
+ }
167
+ full_ids, _ = _rank_top(full_scores[concept], max(args.top_features, 50))
168
+ seen.update(int(feature_id) for feature_id in full_ids.tolist())
169
+
170
+ for feature_id in seen:
171
+ key = (concept, feature_id)
172
+ feature_ranks = ranks.get(key, [])
173
+ output_rows.append(
174
+ {
175
+ 'layer': layer,
176
+ 'concept': concept,
177
+ 'feature_id': feature_id,
178
+ 'full_score': float(full_scores[concept][feature_id]),
179
+ 'full_rank': int(full_rank_map[feature_id]),
180
+ 'resample_support': float(support.get(key, 0) / args.resamples),
181
+ 'median_resample_rank': (
182
+ float(np.median(feature_ranks)) if feature_ranks else float('nan')
183
+ ),
184
+ 'mean_resample_rank': (
185
+ float(np.mean(feature_ranks)) if feature_ranks else float('nan')
186
+ ),
187
+ 'resamples': int(args.resamples),
188
+ 'top_features_per_resample': int(args.top_features),
189
+ }
190
+ )
191
+ print(f'Stability analysis complete for layer {layer}', flush=True)
192
+
193
+ args.output.parent.mkdir(parents=True, exist_ok=True)
194
+ fieldnames = [
195
+ 'layer',
196
+ 'concept',
197
+ 'feature_id',
198
+ 'full_score',
199
+ 'full_rank',
200
+ 'resample_support',
201
+ 'median_resample_rank',
202
+ 'mean_resample_rank',
203
+ 'resamples',
204
+ 'top_features_per_resample',
205
+ ]
206
+ with args.output.open('w', newline='', encoding='utf-8') as handle:
207
+ writer = csv.DictWriter(handle, fieldnames=fieldnames)
208
+ writer.writeheader()
209
+ writer.writerows(output_rows)
210
+ print(f'Wrote {len(output_rows)} selection-stability rows to {args.output}')
211
+
212
+
213
+ if __name__ == '__main__':
214
+ main()
experiments/analyze_study.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 pandas as pd
9
+ from scipy.stats import spearmanr
10
+
11
+ from experiments.common import ARTIFACT_DIR
12
+ from featurelens.stats import paired_bootstrap_difference_ci, paired_sign_flip_pvalue
13
+
14
+
15
+ def parse_args() -> argparse.Namespace:
16
+ parser = argparse.ArgumentParser(
17
+ description='Aggregate held-out association, stability, and causal evidence by concept.'
18
+ )
19
+ parser.add_argument('--artifact-dir', type=Path, default=ARTIFACT_DIR)
20
+ parser.add_argument('--seed', type=int, default=42)
21
+ return parser.parse_args()
22
+
23
+
24
+ def selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
25
+ scored = catalog.copy()
26
+ scored['activation_contrast'] = (
27
+ scored['activation_rate_pos'].astype(float) - scored['activation_rate_neg'].astype(float)
28
+ )
29
+ ordered = scored.sort_values(
30
+ ['concept', 'train_auroc', 'activation_contrast'],
31
+ ascending=[True, False, False],
32
+ )
33
+ return ordered.groupby('concept', as_index=False).first()
34
+
35
+
36
+ def _paired_specificity(
37
+ frame: pd.DataFrame,
38
+ *,
39
+ effect_column: str,
40
+ task_column: str = 'task_id',
41
+ seed: int,
42
+ ) -> dict[str, float | list[float]]:
43
+ sae = (
44
+ frame[frame['condition'] == 'sae_feature']
45
+ .groupby(task_column, as_index=False)[effect_column]
46
+ .first()
47
+ .rename(columns={effect_column: 'sae_effect'})
48
+ )
49
+ random = (
50
+ frame[frame['condition'] == 'random_norm_matched']
51
+ .assign(_abs_effect=lambda data: np.abs(data[effect_column].astype(float)))
52
+ .groupby(task_column, as_index=False)['_abs_effect']
53
+ .mean()
54
+ .rename(columns={'_abs_effect': 'random_abs_effect'})
55
+ )
56
+ paired = sae.merge(random, on=task_column, how='inner')
57
+ if paired.empty:
58
+ return {
59
+ 'sae_abs_mean': float('nan'),
60
+ 'sae_signed_mean': float('nan'),
61
+ 'random_abs_mean': float('nan'),
62
+ 'specificity_ratio': float('nan'),
63
+ 'paired_advantage': float('nan'),
64
+ 'paired_advantage_ci_95': [float('nan'), float('nan')],
65
+ 'paired_sign_flip_pvalue': float('nan'),
66
+ 'n_tasks': 0,
67
+ }
68
+
69
+ sae_values = paired['sae_effect'].to_numpy(dtype=float)
70
+ sae_abs = np.abs(sae_values)
71
+ random_abs = paired['random_abs_effect'].to_numpy(dtype=float)
72
+ ci_low, ci_high = paired_bootstrap_difference_ci(sae_abs, random_abs, seed=seed)
73
+ return {
74
+ 'sae_abs_mean': float(np.mean(sae_abs)),
75
+ 'sae_signed_mean': float(np.mean(sae_values)),
76
+ 'random_abs_mean': float(np.mean(random_abs)),
77
+ 'specificity_ratio': float(np.mean(sae_abs) / max(float(np.mean(random_abs)), 1e-12)),
78
+ 'paired_advantage': float(np.mean(sae_abs - random_abs)),
79
+ 'paired_advantage_ci_95': [float(ci_low), float(ci_high)],
80
+ 'paired_sign_flip_pvalue': float(
81
+ paired_sign_flip_pvalue(sae_abs, random_abs, seed=seed + 1)
82
+ ),
83
+ 'n_tasks': int(len(paired)),
84
+ }
85
+
86
+
87
+ def _safe_spearman(x: pd.Series, y: pd.Series) -> dict[str, float | int]:
88
+ a = pd.to_numeric(x, errors='coerce').to_numpy(dtype=float)
89
+ b = pd.to_numeric(y, errors='coerce').to_numpy(dtype=float)
90
+ mask = np.isfinite(a) & np.isfinite(b)
91
+ if int(mask.sum()) < 3:
92
+ return {'rho': float('nan'), 'pvalue': float('nan'), 'n': int(mask.sum())}
93
+ if np.unique(a[mask]).size < 2 or np.unique(b[mask]).size < 2:
94
+ return {'rho': float('nan'), 'pvalue': float('nan'), 'n': int(mask.sum())}
95
+ result = spearmanr(a[mask], b[mask])
96
+ return {
97
+ 'rho': float(result.statistic),
98
+ 'pvalue': float(result.pvalue),
99
+ 'n': int(mask.sum()),
100
+ }
101
+
102
+
103
+ def main() -> None:
104
+ args = parse_args()
105
+ artifact_dir = args.artifact_dir
106
+ catalog = pd.read_csv(artifact_dir / 'feature_catalog.csv')
107
+ causal = pd.read_csv(artifact_dir / 'causal_results.csv')
108
+ paraphrase = pd.read_csv(artifact_dir / 'stability.csv')
109
+ stability_path = artifact_dir / 'selection_stability.csv'
110
+ selection_stability = pd.read_csv(stability_path) if stability_path.exists() else pd.DataFrame()
111
+
112
+ selected = selected_features(catalog)
113
+ rows: list[dict] = []
114
+
115
+ for concept_idx, selected_row in selected.sort_values('concept').reset_index(drop=True).iterrows():
116
+ concept = str(selected_row['concept'])
117
+ layer = int(selected_row['layer'])
118
+ feature_id = int(selected_row['feature_id'])
119
+ causal_concept = causal[
120
+ (causal['concept'] == concept) & (causal['intervention'] == 'ablate')
121
+ ].copy()
122
+ target = _paired_specificity(
123
+ causal_concept,
124
+ effect_column='target_mean_logprob_delta',
125
+ seed=args.seed + 100 * concept_idx,
126
+ )
127
+ js = _paired_specificity(
128
+ causal_concept,
129
+ effect_column='js_divergence',
130
+ seed=args.seed + 100 * concept_idx + 17,
131
+ )
132
+ sae_rows = causal_concept[causal_concept['condition'] == 'sae_feature']
133
+
134
+ para_rows = paraphrase[
135
+ (paraphrase['concept'] == concept) & (paraphrase['layer'].astype(int) == layer)
136
+ ]
137
+ selection_row = pd.DataFrame()
138
+ if not selection_stability.empty:
139
+ selection_row = selection_stability[
140
+ (selection_stability['concept'] == concept)
141
+ & (selection_stability['layer'].astype(int) == layer)
142
+ & (selection_stability['feature_id'].astype(int) == feature_id)
143
+ ]
144
+
145
+ rows.append(
146
+ {
147
+ 'concept': concept,
148
+ 'layer': layer,
149
+ 'feature_id': feature_id,
150
+ 'train_auroc': float(selected_row['train_auroc']),
151
+ 'heldout_auroc': float(selected_row['auroc']),
152
+ 'heldout_f1': float(selected_row['f1']),
153
+ 'activation_rate_pos_train': float(selected_row['activation_rate_pos']),
154
+ 'activation_rate_neg_train': float(selected_row['activation_rate_neg']),
155
+ 'candidate_resample_support': (
156
+ float(selection_row.iloc[0]['resample_support'])
157
+ if not selection_row.empty
158
+ else 0.0
159
+ ),
160
+ 'candidate_median_resample_rank': (
161
+ float(selection_row.iloc[0]['median_resample_rank'])
162
+ if not selection_row.empty
163
+ else float('nan')
164
+ ),
165
+ 'mean_paraphrase_topk_jaccard': (
166
+ float(para_rows['topk_jaccard'].mean()) if not para_rows.empty else float('nan')
167
+ ),
168
+ 'mean_paraphrase_sparse_cosine': (
169
+ float(para_rows['sparse_cosine'].mean()) if not para_rows.empty else float('nan')
170
+ ),
171
+ 'causal_feature_active_rate': (
172
+ float(np.mean(sae_rows['feature_activation'].astype(float) > 0.0))
173
+ if not sae_rows.empty
174
+ else float('nan')
175
+ ),
176
+ 'target_sae_abs_mean': target['sae_abs_mean'],
177
+ 'target_sae_signed_mean': target['sae_signed_mean'],
178
+ 'target_random_abs_mean': target['random_abs_mean'],
179
+ 'target_specificity_ratio': target['specificity_ratio'],
180
+ 'target_paired_advantage': target['paired_advantage'],
181
+ 'target_paired_ci_low': float(target['paired_advantage_ci_95'][0]),
182
+ 'target_paired_ci_high': float(target['paired_advantage_ci_95'][1]),
183
+ 'target_sign_flip_pvalue': target['paired_sign_flip_pvalue'],
184
+ 'js_sae_mean': js['sae_abs_mean'],
185
+ 'js_random_mean': js['random_abs_mean'],
186
+ 'js_specificity_ratio': js['specificity_ratio'],
187
+ 'js_paired_advantage': js['paired_advantage'],
188
+ 'js_paired_ci_low': float(js['paired_advantage_ci_95'][0]),
189
+ 'js_paired_ci_high': float(js['paired_advantage_ci_95'][1]),
190
+ 'js_sign_flip_pvalue': js['paired_sign_flip_pvalue'],
191
+ 'causal_tasks': int(target['n_tasks']),
192
+ }
193
+ )
194
+
195
+ study = pd.DataFrame(rows)
196
+ study_path = artifact_dir / 'study_feature_summary.csv'
197
+ study.to_csv(study_path, index=False)
198
+
199
+ correlations = {
200
+ 'heldout_auroc_vs_target_specificity': _safe_spearman(
201
+ study['heldout_auroc'], study['target_specificity_ratio']
202
+ ),
203
+ 'heldout_auroc_vs_js_specificity': _safe_spearman(
204
+ study['heldout_auroc'], study['js_specificity_ratio']
205
+ ),
206
+ 'heldout_f1_vs_target_specificity': _safe_spearman(
207
+ study['heldout_f1'], study['target_specificity_ratio']
208
+ ),
209
+ 'candidate_resample_support_vs_target_specificity': _safe_spearman(
210
+ study['candidate_resample_support'], study['target_specificity_ratio']
211
+ ),
212
+ }
213
+
214
+ most_predictive = study.sort_values('heldout_auroc', ascending=False).iloc[0]
215
+ most_target_specific = study.sort_values('target_specificity_ratio', ascending=False).iloc[0]
216
+ most_js_specific = study.sort_values('js_specificity_ratio', ascending=False).iloc[0]
217
+ median_support = float(study['candidate_resample_support'].median())
218
+
219
+ summary = {
220
+ 'n_concepts': int(len(study)),
221
+ 'selected_feature_pooling': 'prompt-wide max SAE activation across non-padding prompt tokens',
222
+ 'dense_probe_pooling': 'final prompt token residual',
223
+ 'median_selected_feature_resample_support': median_support,
224
+ 'most_predictive_concept': {
225
+ 'concept': str(most_predictive['concept']),
226
+ 'heldout_auroc': float(most_predictive['heldout_auroc']),
227
+ },
228
+ 'highest_target_specificity': {
229
+ 'concept': str(most_target_specific['concept']),
230
+ 'ratio': float(most_target_specific['target_specificity_ratio']),
231
+ },
232
+ 'highest_js_specificity': {
233
+ 'concept': str(most_js_specific['concept']),
234
+ 'ratio': float(most_js_specific['js_specificity_ratio']),
235
+ },
236
+ 'correlations': correlations,
237
+ 'guardrail': (
238
+ 'Cross-concept Spearman correlations are descriptive because the study contains only seven '
239
+ 'controlled concepts. Causal specificity remains evaluated per concept against matched random '
240
+ 'controls rather than inferred from correlation alone.'
241
+ ),
242
+ }
243
+ (artifact_dir / 'study_summary.json').write_text(
244
+ json.dumps(summary, indent=2),
245
+ encoding='utf-8',
246
+ )
247
+ print(f'Wrote {study_path}')
248
+ print(f'Wrote {artifact_dir / "study_summary.json"}')
249
+
250
+
251
+ if __name__ == '__main__':
252
+ main()
experiments/collect_activations.py CHANGED
@@ -12,7 +12,7 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
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:
@@ -26,7 +26,7 @@ def parse_args() -> argparse.Namespace:
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] = []
@@ -40,6 +40,48 @@ def _build_sparse(encodings: list, n_rows: int, width: int) -> sp.csr_matrix:
40
  return sp.csr_matrix((values, (row_ids, col_ids)), shape=(n_rows, width), dtype=np.float32)
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def _make_capture_hook(
44
  captured: dict[int, torch.Tensor],
45
  layer: int,
@@ -81,7 +123,8 @@ def main() -> None:
81
  )
82
 
83
  residuals: dict[int, list[np.ndarray]] = {layer: [] for layer in args.layers}
84
- encodings: dict[int, list] = {layer: [] for layer in args.layers}
 
85
  recon_stats: dict[int, list[dict[str, float]]] = {layer: [] for layer in args.layers}
86
 
87
  for start in range(0, len(rows), args.batch_size):
@@ -110,19 +153,27 @@ def main() -> None:
110
 
111
  for layer in args.layers:
112
  sae = sae_store.get(layer)
113
- final_token_residuals = captured[layer][:, -1, :]
114
- batch_encoding = sae.encode(final_token_residuals)
 
 
 
 
 
 
 
115
  for row_idx in range(final_token_residuals.shape[0]):
116
  residual = final_token_residuals[row_idx]
117
- from featurelens.sae import SparseEncoding
118
-
119
- encoding = SparseEncoding(
120
- indices=batch_encoding.indices[row_idx],
121
- values=batch_encoding.values[row_idx],
122
  )
123
- reconstructed = sae.decode_sparse(encoding)
124
- residuals[layer].append(residual.detach().float().cpu().numpy().astype(np.float16))
125
- encodings[layer].append(encoding)
 
 
 
126
  recon_stats[layer].append(reconstruction_metrics(residual, reconstructed))
127
 
128
  print(f'Processed {min(start + args.batch_size, len(rows))}/{len(rows)} prompts', flush=True)
@@ -130,18 +181,43 @@ def main() -> None:
130
  for layer in args.layers:
131
  residual_array = np.stack(residuals[layer], axis=0)
132
  np.save(args.output_dir / f'residuals_layer{layer}.npy', residual_array)
133
- sparse = _build_sparse(encodings[layer], len(rows), SETTINGS.sae_width)
134
- sp.save_npz(args.output_dir / f'features_layer{layer}.npz', sparse, compressed=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  summary = {
136
  'layer': layer,
137
  'n_samples': len(rows),
138
- 'mean_cosine': float(np.mean([x['cosine'] for x in recon_stats[layer]])),
139
- 'mean_nmse': float(np.mean([x['nmse'] for x in recon_stats[layer]])),
140
- 'median_nmse': float(np.median([x['nmse'] for x in recon_stats[layer]])),
141
- 'mean_active_features': float(np.mean(np.diff(sparse.indptr))),
 
142
  }
 
 
143
  (args.output_dir / f'reconstruction_layer{layer}.json').write_text(
144
- json.dumps(summary, indent=2), encoding='utf-8'
 
145
  )
146
 
147
  metadata = {
@@ -151,11 +227,18 @@ def main() -> None:
151
  'top_k': SETTINGS.sae_top_k,
152
  'width': SETTINGS.sae_width,
153
  'n_samples': len(rows),
 
 
 
 
154
  'rows': rows,
155
  }
156
- (args.output_dir / 'metadata.json').write_text(json.dumps(metadata, indent=2), encoding='utf-8')
 
 
 
157
  print(f'Activation artifacts written to {args.output_dir}')
158
 
159
 
160
  if __name__ == '__main__':
161
- main()
 
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, SparseEncoding
16
 
17
 
18
  def parse_args() -> argparse.Namespace:
 
26
  return parser.parse_args()
27
 
28
 
29
+ def _build_sparse(encodings: list[SparseEncoding], n_rows: int, width: int) -> sp.csr_matrix:
30
  row_ids: list[int] = []
31
  col_ids: list[int] = []
32
  values: list[float] = []
 
40
  return sp.csr_matrix((values, (row_ids, col_ids)), shape=(n_rows, width), dtype=np.float32)
41
 
42
 
43
+ def _promptwide_max_encoding(
44
+ token_encoding: SparseEncoding,
45
+ attention_mask: torch.Tensor,
46
+ ) -> list[SparseEncoding]:
47
+ """Max-pool sparse feature activations across non-padding tokens for each prompt."""
48
+ indices = token_encoding.indices.detach().cpu()
49
+ values = token_encoding.values.detach().float().cpu()
50
+ mask = attention_mask.detach().bool().cpu()
51
+ pooled: list[SparseEncoding] = []
52
+
53
+ for row_idx in range(indices.shape[0]):
54
+ feature_max: dict[int, float] = {}
55
+ valid_positions = torch.nonzero(mask[row_idx], as_tuple=False).reshape(-1).tolist()
56
+ for token_idx in valid_positions:
57
+ token_ids = indices[row_idx, token_idx].reshape(-1).tolist()
58
+ token_values = values[row_idx, token_idx].reshape(-1).tolist()
59
+ for feature_id, activation in zip(token_ids, token_values, strict=True):
60
+ activation = float(activation)
61
+ if activation <= 0.0:
62
+ continue
63
+ feature_id = int(feature_id)
64
+ if activation > feature_max.get(feature_id, 0.0):
65
+ feature_max[feature_id] = activation
66
+
67
+ if feature_max:
68
+ ordered = sorted(feature_max.items())
69
+ pooled.append(
70
+ SparseEncoding(
71
+ indices=torch.tensor([item[0] for item in ordered], dtype=torch.long),
72
+ values=torch.tensor([item[1] for item in ordered], dtype=torch.float32),
73
+ )
74
+ )
75
+ else:
76
+ pooled.append(
77
+ SparseEncoding(
78
+ indices=torch.empty(0, dtype=torch.long),
79
+ values=torch.empty(0, dtype=torch.float32),
80
+ )
81
+ )
82
+ return pooled
83
+
84
+
85
  def _make_capture_hook(
86
  captured: dict[int, torch.Tensor],
87
  layer: int,
 
123
  )
124
 
125
  residuals: dict[int, list[np.ndarray]] = {layer: [] for layer in args.layers}
126
+ final_encodings: dict[int, list[SparseEncoding]] = {layer: [] for layer in args.layers}
127
+ promptwide_encodings: dict[int, list[SparseEncoding]] = {layer: [] for layer in args.layers}
128
  recon_stats: dict[int, list[dict[str, float]]] = {layer: [] for layer in args.layers}
129
 
130
  for start in range(0, len(rows), args.batch_size):
 
153
 
154
  for layer in args.layers:
155
  sae = sae_store.get(layer)
156
+ hidden = captured[layer]
157
+ final_token_residuals = hidden[:, -1, :]
158
+ final_batch_encoding = sae.encode(final_token_residuals)
159
+ token_batch_encoding = sae.encode(hidden)
160
+ promptwide_batch = _promptwide_max_encoding(
161
+ token_batch_encoding,
162
+ batch['attention_mask'],
163
+ )
164
+
165
  for row_idx in range(final_token_residuals.shape[0]):
166
  residual = final_token_residuals[row_idx]
167
+ final_encoding = SparseEncoding(
168
+ indices=final_batch_encoding.indices[row_idx],
169
+ values=final_batch_encoding.values[row_idx],
 
 
170
  )
171
+ reconstructed = sae.decode_sparse(final_encoding)
172
+ residuals[layer].append(
173
+ residual.detach().float().cpu().numpy().astype(np.float16)
174
+ )
175
+ final_encodings[layer].append(final_encoding)
176
+ promptwide_encodings[layer].append(promptwide_batch[row_idx])
177
  recon_stats[layer].append(reconstruction_metrics(residual, reconstructed))
178
 
179
  print(f'Processed {min(start + args.batch_size, len(rows))}/{len(rows)} prompts', flush=True)
 
181
  for layer in args.layers:
182
  residual_array = np.stack(residuals[layer], axis=0)
183
  np.save(args.output_dir / f'residuals_layer{layer}.npy', residual_array)
184
+
185
+ promptwide_sparse = _build_sparse(
186
+ promptwide_encodings[layer],
187
+ len(rows),
188
+ SETTINGS.sae_width,
189
+ )
190
+ sp.save_npz(
191
+ args.output_dir / f'features_layer{layer}.npz',
192
+ promptwide_sparse,
193
+ compressed=True,
194
+ )
195
+
196
+ final_sparse = _build_sparse(
197
+ final_encodings[layer],
198
+ len(rows),
199
+ SETTINGS.sae_width,
200
+ )
201
+ sp.save_npz(
202
+ args.output_dir / f'features_final_layer{layer}.npz',
203
+ final_sparse,
204
+ compressed=True,
205
+ )
206
+
207
  summary = {
208
  'layer': layer,
209
  'n_samples': len(rows),
210
+ 'mean_cosine': float(np.mean([item['cosine'] for item in recon_stats[layer]])),
211
+ 'mean_nmse': float(np.mean([item['nmse'] for item in recon_stats[layer]])),
212
+ 'median_nmse': float(np.median([item['nmse'] for item in recon_stats[layer]])),
213
+ 'mean_active_features_final_token': float(np.mean(np.diff(final_sparse.indptr))),
214
+ 'mean_active_features_promptwide': float(np.mean(np.diff(promptwide_sparse.indptr))),
215
  }
216
+ # Preserve the legacy key for report compatibility. It refers to final-token TopK activity.
217
+ summary['mean_active_features'] = summary['mean_active_features_final_token']
218
  (args.output_dir / f'reconstruction_layer{layer}.json').write_text(
219
+ json.dumps(summary, indent=2),
220
+ encoding='utf-8',
221
  )
222
 
223
  metadata = {
 
227
  'top_k': SETTINGS.sae_top_k,
228
  'width': SETTINGS.sae_width,
229
  'n_samples': len(rows),
230
+ 'feature_pooling': 'prompt-wide max activation across non-padding tokens',
231
+ 'feature_file_pattern': 'features_layer{layer}.npz',
232
+ 'final_token_feature_file_pattern': 'features_final_layer{layer}.npz',
233
+ 'dense_residual_pooling': 'final prompt token',
234
  'rows': rows,
235
  }
236
+ (args.output_dir / 'metadata.json').write_text(
237
+ json.dumps(metadata, indent=2),
238
+ encoding='utf-8',
239
+ )
240
  print(f'Activation artifacts written to {args.output_dir}')
241
 
242
 
243
  if __name__ == '__main__':
244
+ main()
experiments/evaluate_features.py CHANGED
@@ -72,6 +72,11 @@ 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)
 
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
+ if 'prompt-wide' not in str(metadata.get('feature_pooling', '')):
76
+ raise RuntimeError(
77
+ 'v0.14 evaluation requires prompt-wide activation artifacts. '
78
+ 'Rerun experiments.collect_activations before evaluating features.'
79
+ )
80
  rows = metadata['rows']
81
  layers = [int(x) for x in metadata['layers']]
82
  train_idx, test_idx = grouped_concept_split(rows, seed=args.seed)
experiments/make_report.py CHANGED
@@ -118,6 +118,36 @@ def _save_plots(
118
  plt.close(figure)
119
 
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  def _paired_stats(
122
  frame: pd.DataFrame,
123
  *,
@@ -175,8 +205,17 @@ def main() -> None:
175
  causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
176
  feature_set_path = args.artifact_dir / 'feature_set_results.csv'
177
  feature_sets = pd.read_csv(feature_set_path) if feature_set_path.exists() else None
 
 
 
 
 
 
 
 
178
  selected = _selected_features(catalog)
179
  _save_plots(args.artifact_dir, selected, layers, causal, feature_sets)
 
180
 
181
  mean_auc = float(selected['auroc'].mean())
182
  median_auc = float(selected['auroc'].median())
@@ -299,6 +338,18 @@ def main() -> None:
299
  f'sign-flip p={float(largest_set["pvalue"]):.4f}.'
300
  )
301
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  summary = {
303
  'headline': headline,
304
  'highlights': highlights,
@@ -321,6 +372,7 @@ def main() -> None:
321
  'causal_prompt_feature_active_rate': active_rate,
322
  'sae_top1_change_rate': top1_change,
323
  'feature_set_results': {str(k): value for k, value in set_summary.items()},
 
324
  },
325
  }
326
  (args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
@@ -347,7 +399,9 @@ def main() -> None:
347
  '- Model: Qwen3-1.7B-Base.',
348
  '- SAEs: Qwen-Scope residual-stream TopK SAEs at configured early/middle/late layers.',
349
  '- Discovery set: controlled concept prompts with paired paraphrases.',
 
350
  '- Split discipline: paraphrase groups stay entirely in train or held-out test.',
 
351
  '- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
352
  '- Linear baseline: multinomial logistic regression on the dense residual stream.',
353
  '- Single-feature causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
@@ -367,6 +421,19 @@ def main() -> None:
367
  ]
368
  if feature_sets is not None and not feature_sets.empty:
369
  lines.extend(['', '![Feature-set causal effects](figures/feature_set_effects.png)'])
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  lines.extend(
371
  [
372
  '',
@@ -376,7 +443,7 @@ def main() -> None:
376
  '',
377
  '## Reproducibility',
378
  '',
379
- 'Run `python experiments/run_all.py` from the repository root. Raw activation matrices, splits, selected features, single-feature rows, feature-set rows, figures, and this report are materialized under `artifacts/`.',
380
  '',
381
  ]
382
  )
 
118
  plt.close(figure)
119
 
120
 
121
+
122
+ def _save_study_plots(artifact_dir: Path, study: pd.DataFrame) -> None:
123
+ if study.empty:
124
+ return
125
+ fig_dir = artifact_dir / 'figures'
126
+ fig_dir.mkdir(parents=True, exist_ok=True)
127
+
128
+ figure = plt.figure(figsize=(7.2, 4.6))
129
+ ax = figure.add_subplot(111)
130
+ ax.scatter(study['heldout_auroc'], study['target_specificity_ratio'])
131
+ for row in study.itertuples():
132
+ ax.annotate(str(row.concept), (row.heldout_auroc, row.target_specificity_ratio), fontsize=8)
133
+ ax.set_xlabel('Held-out feature AUROC')
134
+ ax.set_ylabel('Target causal specificity ratio')
135
+ ax.set_title('Association evidence vs random-normalized causality')
136
+ figure.tight_layout()
137
+ figure.savefig(fig_dir / 'association_vs_causality.png', dpi=160)
138
+ plt.close(figure)
139
+
140
+ figure = plt.figure(figsize=(7.4, 4.6))
141
+ ax = figure.add_subplot(111)
142
+ ordered = study.sort_values('candidate_resample_support')
143
+ ax.barh(ordered['concept'], ordered['candidate_resample_support'])
144
+ ax.set_xlim(0, 1.02)
145
+ ax.set_xlabel('Selection support across activation resamples')
146
+ ax.set_title('Selected-feature candidate stability')
147
+ figure.tight_layout()
148
+ figure.savefig(fig_dir / 'candidate_stability.png', dpi=160)
149
+ plt.close(figure)
150
+
151
  def _paired_stats(
152
  frame: pd.DataFrame,
153
  *,
 
205
  causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
206
  feature_set_path = args.artifact_dir / 'feature_set_results.csv'
207
  feature_sets = pd.read_csv(feature_set_path) if feature_set_path.exists() else None
208
+ study_path = args.artifact_dir / 'study_feature_summary.csv'
209
+ study = pd.read_csv(study_path) if study_path.exists() else pd.DataFrame()
210
+ study_summary_path = args.artifact_dir / 'study_summary.json'
211
+ study_summary = (
212
+ json.loads(study_summary_path.read_text(encoding='utf-8'))
213
+ if study_summary_path.exists()
214
+ else {}
215
+ )
216
  selected = _selected_features(catalog)
217
  _save_plots(args.artifact_dir, selected, layers, causal, feature_sets)
218
+ _save_study_plots(args.artifact_dir, study)
219
 
220
  mean_auc = float(selected['auroc'].mean())
221
  median_auc = float(selected['auroc'].median())
 
338
  f'sign-flip p={float(largest_set["pvalue"]):.4f}.'
339
  )
340
 
341
+ if study_summary:
342
+ correlations = study_summary.get('correlations', {})
343
+ assoc_target = correlations.get('heldout_auroc_vs_target_specificity', {})
344
+ assoc_js = correlations.get('heldout_auroc_vs_js_specificity', {})
345
+ highlights.extend(
346
+ [
347
+ f'Selected-feature median activation-resample support: {float(study_summary.get("median_selected_feature_resample_support", float("nan"))):.1%}.',
348
+ f'Across concepts, held-out AUROC vs target-specificity Spearman ρ={float(assoc_target.get("rho", float("nan"))):+.3f} (n={int(assoc_target.get("n", 0))}); descriptive only.',
349
+ f'Across concepts, held-out AUROC vs JS-specificity Spearman ρ={float(assoc_js.get("rho", float("nan"))):+.3f} (n={int(assoc_js.get("n", 0))}); descriptive only.',
350
+ ]
351
+ )
352
+
353
  summary = {
354
  'headline': headline,
355
  'highlights': highlights,
 
372
  'causal_prompt_feature_active_rate': active_rate,
373
  'sae_top1_change_rate': top1_change,
374
  'feature_set_results': {str(k): value for k, value in set_summary.items()},
375
+ 'study_summary': study_summary,
376
  },
377
  }
378
  (args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
 
399
  '- Model: Qwen3-1.7B-Base.',
400
  '- SAEs: Qwen-Scope residual-stream TopK SAEs at configured early/middle/late layers.',
401
  '- Discovery set: controlled concept prompts with paired paraphrases.',
402
+ '- SAE concept evidence: prompt-wide maximum activation per feature across non-padding prompt tokens; final-token activations are saved separately for local diagnostics.',
403
  '- Split discipline: paraphrase groups stay entirely in train or held-out test.',
404
+ '- Candidate stability: deterministic balanced activation resamples estimate how often selected features survive small changes in the discovery sample.',
405
  '- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
406
  '- Linear baseline: multinomial logistic regression on the dense residual stream.',
407
  '- Single-feature causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
 
421
  ]
422
  if feature_sets is not None and not feature_sets.empty:
423
  lines.extend(['', '![Feature-set causal effects](figures/feature_set_effects.png)'])
424
+ if not study.empty:
425
+ lines.extend(
426
+ [
427
+ '',
428
+ '![Association vs causality](figures/association_vs_causality.png)',
429
+ '',
430
+ '![Candidate stability](figures/candidate_stability.png)',
431
+ '',
432
+ '## Association vs causality across concepts',
433
+ '',
434
+ "The offline study joins each concept-selected feature's held-out AUROC/F1 and activation-resample support with random-normalized causal specificity on the held-out causal task set. Cross-concept correlations are descriptive because there are only seven controlled concepts.",
435
+ ]
436
+ )
437
  lines.extend(
438
  [
439
  '',
 
443
  '',
444
  '## Reproducibility',
445
  '',
446
+ 'Run `python experiments/run_all.py` from the repository root for the full GPU + CPU study. If model/SAE activations and causal rows already exist, run `python experiments/run_analysis_only.py` to regenerate CPU evaluation, stability, study synthesis, figures, validation, and this report without another model download or inference pass.',
447
  '',
448
  ]
449
  )
experiments/run_all.py CHANGED
@@ -1,26 +1,93 @@
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('run_feature_sets.py')
23
- run('make_report.py')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  print('\nFeatureLens experiment pipeline complete. See artifacts/report.md')
25
 
26
 
 
1
  from __future__ import annotations
2
 
3
+ import argparse
4
  import subprocess
5
  import sys
6
  from pathlib import Path
7
 
8
+ from featurelens.config import SETTINGS
9
+
10
  ROOT = Path(__file__).resolve().parents[1]
11
 
12
 
13
+ def parse_args() -> argparse.Namespace:
14
+ parser = argparse.ArgumentParser(description='Run the full FeatureLens offline study.')
15
+ parser.add_argument(
16
+ '--resume',
17
+ action='store_true',
18
+ help='Skip stages whose expected outputs already exist.',
19
+ )
20
+ return parser.parse_args()
21
+
22
+
23
+ def run(module: str, *, outputs: list[Path], resume: bool) -> None:
24
+ if resume and outputs and all(path.exists() for path in outputs):
25
+ print(f'\nSKIP {module}: expected outputs already exist.', flush=True)
26
+ return
27
+ command = [sys.executable, '-m', module]
28
  print('\n$', ' '.join(command), flush=True)
29
  subprocess.run(command, cwd=ROOT, check=True)
30
 
31
 
32
  def main() -> None:
33
+ args = parse_args()
34
+ artifact_dir = ROOT / 'artifacts'
35
+ activation_dir = artifact_dir / 'activations'
36
+
37
+ run(
38
+ 'experiments.build_dataset',
39
+ outputs=[ROOT / 'data' / 'prompts.jsonl', ROOT / 'data' / 'causal_tasks.jsonl'],
40
+ resume=args.resume,
41
+ )
42
+ run(
43
+ 'experiments.collect_activations',
44
+ outputs=[
45
+ activation_dir / 'metadata.json',
46
+ *[activation_dir / f'features_layer{layer}.npz' for layer in SETTINGS.layers],
47
+ *[activation_dir / f'features_final_layer{layer}.npz' for layer in SETTINGS.layers],
48
+ ],
49
+ resume=args.resume,
50
+ )
51
+ run(
52
+ 'experiments.evaluate_features',
53
+ outputs=[
54
+ artifact_dir / 'feature_catalog.csv',
55
+ artifact_dir / 'layer_metrics.csv',
56
+ artifact_dir / 'stability.csv',
57
+ artifact_dir / 'split.json',
58
+ ],
59
+ resume=args.resume,
60
+ )
61
+ run(
62
+ 'experiments.run_causal',
63
+ outputs=[artifact_dir / 'causal_results.csv'],
64
+ resume=args.resume,
65
+ )
66
+ run(
67
+ 'experiments.run_feature_sets',
68
+ outputs=[artifact_dir / 'feature_set_results.csv'],
69
+ resume=args.resume,
70
+ )
71
+ run(
72
+ 'experiments.analyze_stability',
73
+ outputs=[artifact_dir / 'selection_stability.csv'],
74
+ resume=args.resume,
75
+ )
76
+ run(
77
+ 'experiments.analyze_study',
78
+ outputs=[artifact_dir / 'study_feature_summary.csv', artifact_dir / 'study_summary.json'],
79
+ resume=args.resume,
80
+ )
81
+ run(
82
+ 'experiments.make_report',
83
+ outputs=[artifact_dir / 'summary.json', artifact_dir / 'report.md'],
84
+ resume=args.resume,
85
+ )
86
+ subprocess.run(
87
+ [sys.executable, '-m', 'scripts.validate_artifacts'],
88
+ cwd=ROOT,
89
+ check=True,
90
+ )
91
  print('\nFeatureLens experiment pipeline complete. See artifacts/report.md')
92
 
93
 
experiments/run_analysis_only.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(module: str, *args: str) -> None:
11
+ command = [sys.executable, '-m', module, *args]
12
+ print('\n$', ' '.join(command), flush=True)
13
+ subprocess.run(command, cwd=ROOT, check=True)
14
+
15
+
16
+ def main() -> None:
17
+ run('experiments.evaluate_features')
18
+ run('experiments.analyze_stability')
19
+ run('experiments.analyze_study')
20
+ run('experiments.make_report')
21
+ run('scripts.validate_artifacts')
22
+ print('\nFeatureLens CPU analysis pipeline complete. See artifacts/report.md')
23
+
24
+
25
+ if __name__ == '__main__':
26
+ main()
featurelens/study.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+
8
+
9
+ class OfflineStudy:
10
+ """Read committed offline-study artifacts without invoking the model."""
11
+
12
+ REQUIRED = (
13
+ 'summary.json',
14
+ 'study_summary.json',
15
+ 'study_feature_summary.csv',
16
+ 'selection_stability.csv',
17
+ 'feature_catalog.csv',
18
+ 'layer_metrics.csv',
19
+ 'stability.csv',
20
+ 'causal_results.csv',
21
+ 'feature_set_results.csv',
22
+ 'report.md',
23
+ )
24
+
25
+ def __init__(self, artifact_dir: str | Path = 'artifacts') -> None:
26
+ self.artifact_dir = Path(artifact_dir)
27
+
28
+ @property
29
+ def missing(self) -> list[str]:
30
+ return [name for name in self.REQUIRED if not (self.artifact_dir / name).exists()]
31
+
32
+ @property
33
+ def complete(self) -> bool:
34
+ return not self.missing
35
+
36
+ def _json(self, name: str) -> dict:
37
+ path = self.artifact_dir / name
38
+ if not path.exists():
39
+ return {}
40
+ return json.loads(path.read_text(encoding='utf-8'))
41
+
42
+ def dataframe(self, name: str) -> pd.DataFrame:
43
+ path = self.artifact_dir / name
44
+ if not path.exists():
45
+ return pd.DataFrame()
46
+ return pd.read_csv(path)
47
+
48
+ def figure(self, name: str) -> str | None:
49
+ path = self.artifact_dir / 'figures' / name
50
+ return str(path) if path.exists() else None
51
+
52
+ def overview_markdown(self) -> str:
53
+ if not self.complete:
54
+ missing = ', '.join(f'`{name}`' for name in self.missing[:6])
55
+ suffix = '…' if len(self.missing) > 6 else ''
56
+ return (
57
+ '### Offline study not materialized yet\n\n'
58
+ 'The live workbench is usable now, but held-out study artifacts have not been committed. '
59
+ f'Missing: {missing}{suffix}\n\n'
60
+ 'Run `python experiments/run_all.py` on a CUDA machine for the full study. If activation and '
61
+ 'causal artifacts already exist, use `python experiments/run_analysis_only.py` to rerun the '
62
+ 'CPU-only evaluation, stability analysis, evidence synthesis, figures, and report. '
63
+ 'Then run `python -m scripts.validate_artifacts` before committing the small CSV/JSON/report '
64
+ 'outputs. Do not commit `artifacts/activations/`.'
65
+ )
66
+
67
+ summary = self._json('summary.json')
68
+ study = self._json('study_summary.json')
69
+ correlations = study.get('correlations', {})
70
+ target_corr = correlations.get('heldout_auroc_vs_target_specificity', {})
71
+ js_corr = correlations.get('heldout_auroc_vs_js_specificity', {})
72
+ return (
73
+ '### Offline study results\n\n'
74
+ f"{summary.get('headline', 'Benchmark completed.')}\n\n"
75
+ f"{summary.get('interpretation', '')}\n\n"
76
+ '**Study-level diagnostics**\n\n'
77
+ f"- Selected-feature median resample support: **{float(study.get('median_selected_feature_resample_support', float('nan'))):.1%}**.\n"
78
+ f"- Held-out AUROC ↔ target-specificity Spearman ρ: **{float(target_corr.get('rho', float('nan'))):+.3f}** (n={int(target_corr.get('n', 0))}).\n"
79
+ f"- Held-out AUROC ↔ JS-specificity Spearman ρ: **{float(js_corr.get('rho', float('nan'))):+.3f}** (n={int(js_corr.get('n', 0))}).\n\n"
80
+ 'The cross-concept correlations are descriptive because there are only seven controlled concepts. '
81
+ 'Per-concept causal claims remain anchored to matched random-control comparisons.'
82
+ )
83
+
84
+ def readiness_markdown(self) -> str:
85
+ if self.complete:
86
+ return '**Artifact status:** complete and ready for the public study view.'
87
+ return (
88
+ f'**Artifact status:** {len(self.REQUIRED) - len(self.missing)}/{len(self.REQUIRED)} required '
89
+ f'files present; {len(self.missing)} missing.'
90
+ )
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "featurelens"
3
- version = "0.13.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.14.0"
4
  description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
5
  requires-python = ">=3.10"
6
 
research_config.json CHANGED
@@ -90,7 +90,7 @@
90
  "contrastive_continuation_preference_test",
91
  "feature_decoder_geometry"
92
  ],
93
- "concept_candidate_discovery_metric": "balanced exploratory score = selectivity × target activation rate × log1p(target mean); causal-ready mode additionally requires current-token activity and log-scales that activation; raw mean-difference remains available as a scale-sensitive comparison",
94
  "completion_cue_scan": "final-token feature activation after controlled suffix/cue substitution",
95
  "live_features_v0_6": [
96
  "start_here_plain_language_onboarding",
@@ -177,5 +177,24 @@
177
  "pairwise_target_preference_shifts",
178
  "zero_extra_gpu_evidence_synthesis",
179
  "touched_path_only_hf_validation"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  ]
181
  }
 
90
  "contrastive_continuation_preference_test",
91
  "feature_decoder_geometry"
92
  ],
93
+ "concept_candidate_discovery_metric": "balanced exploratory score = selectivity \u00d7 target activation rate \u00d7 log1p(target mean); causal-ready mode additionally requires current-token activity and log-scales that activation; raw mean-difference remains available as a scale-sensitive comparison",
94
  "completion_cue_scan": "final-token feature activation after controlled suffix/cue substitution",
95
  "live_features_v0_6": [
96
  "start_here_plain_language_onboarding",
 
177
  "pairwise_target_preference_shifts",
178
  "zero_extra_gpu_evidence_synthesis",
179
  "touched_path_only_hf_validation"
180
+ ],
181
+ "offline_feature_pooling": "prompt-wide max SAE activation across non-padding prompt tokens; final-token sparse activations saved separately",
182
+ "offline_selection_resamples": 128,
183
+ "offline_study_outputs": [
184
+ "selection_stability.csv",
185
+ "study_feature_summary.csv",
186
+ "study_summary.json",
187
+ "summary.json",
188
+ "report.md"
189
+ ],
190
+ "offline_features_v0_14": [
191
+ "promptwide_offline_sae_feature_pooling",
192
+ "separate_final_token_sparse_activation_artifacts",
193
+ "activation_resample_candidate_stability",
194
+ "cross_concept_association_vs_random_normalized_causality",
195
+ "offline_study_dashboard",
196
+ "resume_safe_full_study_runner",
197
+ "cpu_only_analysis_rerun",
198
+ "offline_artifact_schema_validation"
199
  ]
200
  }
scripts/release_check.py CHANGED
@@ -18,13 +18,20 @@ REQUIRED = [
18
  'featurelens/interventions.py',
19
  'featurelens/metrics.py',
20
  'featurelens/stats.py',
 
21
  'experiments/run_all.py',
22
  'experiments/run_causal.py',
23
  'experiments/run_feature_sets.py',
 
 
 
24
  'data/prompts.jsonl',
25
  'data/causal_tasks.jsonl',
26
  'docs/VALIDATION.md',
 
27
  'scripts/ui_smoke.py',
 
 
28
  ]
29
 
30
 
@@ -231,6 +238,27 @@ def check_config(config: dict) -> None:
231
  raise SystemExit(
232
  'research_config.json live_features_v0_13 mismatch: ' f'{sorted(actual_live_v13)}'
233
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  if config.get('discovery_resample_replicates') != 32:
235
  raise SystemExit('Discovery live resample count must be 32.')
236
  if config.get('cross_target_feature_limit') != 3 or config.get('cross_target_target_limit') != 5:
@@ -330,16 +358,22 @@ def check_readme() -> None:
330
  'pairwise target preference',
331
  'effect concentration',
332
  'signed bias',
 
 
 
 
 
 
333
  ]
334
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
335
  if missing:
336
- raise SystemExit(f'README.md is missing required v0.13 content: {missing}')
337
 
338
 
339
  def check_pyproject() -> None:
340
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
341
- if 'version = "0.13.0"' not in text:
342
- raise SystemExit('pyproject.toml must declare version 0.13.0.')
343
 
344
 
345
  def main() -> None:
@@ -357,7 +391,7 @@ def main() -> None:
357
  print(f' layers: {config["layers"]}')
358
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
359
  print(f' random controls: {config["live_random_controls"]}')
360
- print(' release: v0.13.0')
361
 
362
 
363
  if __name__ == '__main__':
 
18
  'featurelens/interventions.py',
19
  'featurelens/metrics.py',
20
  'featurelens/stats.py',
21
+ 'featurelens/study.py',
22
  'experiments/run_all.py',
23
  'experiments/run_causal.py',
24
  'experiments/run_feature_sets.py',
25
+ 'experiments/analyze_stability.py',
26
+ 'experiments/analyze_study.py',
27
+ 'experiments/run_analysis_only.py',
28
  'data/prompts.jsonl',
29
  'data/causal_tasks.jsonl',
30
  'docs/VALIDATION.md',
31
+ 'docs/OFFLINE_STUDY.md',
32
  'scripts/ui_smoke.py',
33
+ 'tests/test_offline_study.py',
34
+ 'scripts/validate_artifacts.py',
35
  ]
36
 
37
 
 
238
  raise SystemExit(
239
  'research_config.json live_features_v0_13 mismatch: ' f'{sorted(actual_live_v13)}'
240
  )
241
+ required_offline_v14 = {
242
+ 'promptwide_offline_sae_feature_pooling',
243
+ 'separate_final_token_sparse_activation_artifacts',
244
+ 'activation_resample_candidate_stability',
245
+ 'cross_concept_association_vs_random_normalized_causality',
246
+ 'offline_study_dashboard',
247
+ 'resume_safe_full_study_runner',
248
+ 'cpu_only_analysis_rerun',
249
+ 'offline_artifact_schema_validation',
250
+ }
251
+ actual_offline_v14 = set(config.get('offline_features_v0_14', []))
252
+ if actual_offline_v14 != required_offline_v14:
253
+ raise SystemExit(
254
+ 'research_config.json offline_features_v0_14 mismatch: '
255
+ f'{sorted(actual_offline_v14)}'
256
+ )
257
+ if config.get('offline_selection_resamples') != 128:
258
+ raise SystemExit('Offline selection resamples must be 128.')
259
+ if 'prompt-wide' not in str(config.get('offline_feature_pooling', '')):
260
+ raise SystemExit('Offline feature pooling must be prompt-wide.')
261
+
262
  if config.get('discovery_resample_replicates') != 32:
263
  raise SystemExit('Discovery live resample count must be 32.')
264
  if config.get('cross_target_feature_limit') != 3 or config.get('cross_target_target_limit') != 5:
 
358
  'pairwise target preference',
359
  'effect concentration',
360
  'signed bias',
361
+ 'prompt-wide',
362
+ 'offline study',
363
+ 'selection stability',
364
+ 'run_analysis_only',
365
+ '--resume',
366
+ 'validate_artifacts',
367
  ]
368
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
369
  if missing:
370
+ raise SystemExit(f'README.md is missing required v0.14 content: {missing}')
371
 
372
 
373
  def check_pyproject() -> None:
374
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
375
+ if 'version = "0.14.0"' not in text:
376
+ raise SystemExit('pyproject.toml must declare version 0.14.0.')
377
 
378
 
379
  def main() -> None:
 
391
  print(f' layers: {config["layers"]}')
392
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
393
  print(f' random controls: {config["live_random_controls"]}')
394
+ print(' release: v0.14.0')
395
 
396
 
397
  if __name__ == '__main__':
scripts/validate_artifacts.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ ARTIFACT_DIR = ROOT / 'artifacts'
10
+
11
+ REQUIRED = [
12
+ 'activations/metadata.json',
13
+ 'feature_catalog.csv',
14
+ 'layer_metrics.csv',
15
+ 'stability.csv',
16
+ 'selection_stability.csv',
17
+ 'causal_results.csv',
18
+ 'feature_set_results.csv',
19
+ 'study_feature_summary.csv',
20
+ 'study_summary.json',
21
+ 'summary.json',
22
+ 'report.md',
23
+ ]
24
+
25
+
26
+ def _require_columns(path: Path, columns: set[str]) -> None:
27
+ frame = pd.read_csv(path)
28
+ missing = columns.difference(frame.columns)
29
+ if missing:
30
+ raise SystemExit(f'{path.relative_to(ROOT)} missing columns: {sorted(missing)}')
31
+ if frame.empty:
32
+ raise SystemExit(f'{path.relative_to(ROOT)} is empty.')
33
+
34
+
35
+ def main() -> None:
36
+ missing = [name for name in REQUIRED if not (ARTIFACT_DIR / name).exists()]
37
+ if missing:
38
+ raise SystemExit(f'Missing offline-study artifacts: {missing}')
39
+
40
+ metadata = json.loads((ARTIFACT_DIR / 'activations' / 'metadata.json').read_text(encoding='utf-8'))
41
+ pooling = str(metadata.get('feature_pooling', ''))
42
+ if 'prompt-wide' not in pooling:
43
+ raise SystemExit(
44
+ 'Activation metadata is not v0.14 prompt-wide. Rerun experiments.collect_activations.'
45
+ )
46
+
47
+ _require_columns(
48
+ ARTIFACT_DIR / 'feature_catalog.csv',
49
+ {'layer', 'concept', 'feature_id', 'train_auroc', 'auroc', 'f1'},
50
+ )
51
+ _require_columns(
52
+ ARTIFACT_DIR / 'selection_stability.csv',
53
+ {'layer', 'concept', 'feature_id', 'resample_support', 'median_resample_rank'},
54
+ )
55
+ _require_columns(
56
+ ARTIFACT_DIR / 'causal_results.csv',
57
+ {
58
+ 'task_id',
59
+ 'concept',
60
+ 'feature_id',
61
+ 'intervention',
62
+ 'condition',
63
+ 'target_mean_logprob_delta',
64
+ 'js_divergence',
65
+ },
66
+ )
67
+ _require_columns(
68
+ ARTIFACT_DIR / 'study_feature_summary.csv',
69
+ {
70
+ 'concept',
71
+ 'layer',
72
+ 'feature_id',
73
+ 'heldout_auroc',
74
+ 'heldout_f1',
75
+ 'candidate_resample_support',
76
+ 'target_specificity_ratio',
77
+ 'js_specificity_ratio',
78
+ },
79
+ )
80
+
81
+ summary = json.loads((ARTIFACT_DIR / 'study_summary.json').read_text(encoding='utf-8'))
82
+ if int(summary.get('n_concepts', 0)) < 1:
83
+ raise SystemExit('study_summary.json has no concepts.')
84
+
85
+ required_figures = [
86
+ 'feature_auroc.png',
87
+ 'layer_diagnostics.png',
88
+ 'causal_effects.png',
89
+ 'feature_set_effects.png',
90
+ 'association_vs_causality.png',
91
+ 'candidate_stability.png',
92
+ ]
93
+ missing_figures = [
94
+ name for name in required_figures if not (ARTIFACT_DIR / 'figures' / name).exists()
95
+ ]
96
+ if missing_figures:
97
+ raise SystemExit(f'Missing report figures: {missing_figures}')
98
+
99
+ print('FeatureLens offline artifact validation: PASS')
100
+ print(f" concepts: {summary['n_concepts']}")
101
+ print(f" feature pooling: {summary['selected_feature_pooling']}")
102
+ print(' report: artifacts/report.md')
103
+
104
+
105
+ if __name__ == '__main__':
106
+ main()
tests/test_offline_study.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import torch
9
+
10
+ from experiments.analyze_stability import balanced_candidate_score
11
+ from experiments.analyze_study import _paired_specificity, _safe_spearman
12
+ from experiments.collect_activations import _promptwide_max_encoding
13
+ from featurelens.sae import SparseEncoding
14
+ from featurelens.study import OfflineStudy
15
+
16
+
17
+ def test_promptwide_max_encoding_ignores_padding_and_max_pools() -> None:
18
+ encoding = SparseEncoding(
19
+ indices=torch.tensor(
20
+ [
21
+ [[1, 2], [1, 3], [2, 4]],
22
+ [[5, 6], [5, 7], [7, 8]],
23
+ ]
24
+ ),
25
+ values=torch.tensor(
26
+ [
27
+ [[1.0, 2.0], [4.0, 3.0], [5.0, 1.0]],
28
+ [[9.0, 9.0], [2.0, 4.0], [7.0, 6.0]],
29
+ ]
30
+ ),
31
+ )
32
+ mask = torch.tensor([[1, 1, 1], [0, 1, 1]])
33
+ pooled = _promptwide_max_encoding(encoding, mask)
34
+
35
+ row0 = dict(zip(pooled[0].indices.tolist(), pooled[0].values.tolist(), strict=True))
36
+ row1 = dict(zip(pooled[1].indices.tolist(), pooled[1].values.tolist(), strict=True))
37
+ assert row0 == {1: 4.0, 2: 5.0, 3: 3.0, 4: 1.0}
38
+ assert row1 == {5: 2.0, 7: 7.0, 8: 6.0}
39
+
40
+
41
+ def test_balanced_candidate_score_rewards_selectivity_not_raw_scale() -> None:
42
+ target = np.array([30.0, 1000.0])
43
+ other = np.array([0.0, 950.0])
44
+ rate = np.array([1.0, 1.0])
45
+ score = balanced_candidate_score(target, other, rate)
46
+ assert score[0] > score[1]
47
+
48
+
49
+ def test_paired_specificity_uses_random_ensemble_mean_per_task() -> None:
50
+ frame = pd.DataFrame(
51
+ [
52
+ {'task_id': 'a', 'condition': 'sae_feature', 'target_mean_logprob_delta': 0.4},
53
+ {'task_id': 'a', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': 0.1},
54
+ {'task_id': 'a', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': -0.1},
55
+ {'task_id': 'b', 'condition': 'sae_feature', 'target_mean_logprob_delta': -0.2},
56
+ {'task_id': 'b', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': 0.05},
57
+ {'task_id': 'b', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': -0.15},
58
+ ]
59
+ )
60
+ result = _paired_specificity(
61
+ frame,
62
+ effect_column='target_mean_logprob_delta',
63
+ seed=7,
64
+ )
65
+ assert np.isclose(result['sae_abs_mean'], 0.3)
66
+ assert np.isclose(result['random_abs_mean'], 0.1)
67
+ assert np.isclose(result['specificity_ratio'], 3.0)
68
+ assert result['n_tasks'] == 2
69
+
70
+
71
+ def test_safe_spearman_handles_small_samples() -> None:
72
+ small = _safe_spearman(pd.Series([1.0, 2.0]), pd.Series([2.0, 1.0]))
73
+ assert small['n'] == 2
74
+ assert np.isnan(small['rho'])
75
+ enough = _safe_spearman(pd.Series([1.0, 2.0, 3.0]), pd.Series([3.0, 2.0, 1.0]))
76
+ assert enough['n'] == 3
77
+ assert np.isclose(enough['rho'], -1.0)
78
+
79
+
80
+ def test_offline_study_reports_missing_and_complete(tmp_path: Path) -> None:
81
+ study = OfflineStudy(tmp_path)
82
+ assert not study.complete
83
+ assert 'not materialized yet' in study.overview_markdown()
84
+
85
+ for name in OfflineStudy.REQUIRED:
86
+ path = tmp_path / name
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ if name.endswith('.json'):
89
+ if name == 'study_summary.json':
90
+ payload = {
91
+ 'median_selected_feature_resample_support': 0.8,
92
+ 'correlations': {
93
+ 'heldout_auroc_vs_target_specificity': {'rho': -0.2, 'n': 7},
94
+ 'heldout_auroc_vs_js_specificity': {'rho': 0.4, 'n': 7},
95
+ },
96
+ }
97
+ else:
98
+ payload = {'headline': 'Synthetic headline.', 'interpretation': 'Synthetic interpretation.'}
99
+ path.write_text(json.dumps(payload), encoding='utf-8')
100
+ elif name.endswith('.csv'):
101
+ path.write_text('x\n1\n', encoding='utf-8')
102
+ else:
103
+ path.write_text('# report\n', encoding='utf-8')
104
+
105
+ study = OfflineStudy(tmp_path)
106
+ assert study.complete
107
+ text = study.overview_markdown()
108
+ assert 'Synthetic headline.' in text
109
+ assert '80.0%' in text