ArchitSharma commited on
Commit
b784950
·
1 Parent(s): 9838759

Finalize FeatureLens causal position study

Browse files
CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
 
 
 
 
3
  ## v0.15.0
4
 
5
  - Reworked the public Gradio surface around a documented **research-instrument design system** rather than SaaS/dashboard defaults.
 
1
  # Changelog
2
 
3
+ ## v0.16.0
4
+
5
+ - Added two explicit offline causal-position policies: `final_token` and `max_feature_activation`. Max-active positions are selected only from SAE activation within the prompt, never from behavioral outcomes.
6
+ - Preserved the v0.15 final-token causal result as a positional baseline and added a causal-addendum runner that computes only the new max-active policy.
7
+ - Changed primary paired causal inference to use the **causal task** as the statistical unit, averaging ablation and 2× amplification within each task before bootstrap/sign-flip inference.
8
+ - Added exact sign-flip enumeration for small effective paired samples, with deterministic Monte-Carlo fallback for larger samples.
9
+ - Separated causal **coverage** from conditional-on-active effect strength and added final-token vs max-active position-sensitivity summaries.
10
+ - Added `causal_position_summary.csv`, a position-sensitivity report figure, max-active association-vs-causality synthesis, and updated Study-tab diagnostics.
11
+ - Added `experiments/run_causal_addendum.py` and a Drive-backed Colab addendum notebook so a completed v0.15 study can be upgraded without recollecting discovery activations or rerunning feature-set inference.
12
+ - Updated artifact validation and methodology documentation for the finalized v0.16 study schema.
13
+
14
  ## v0.15.0
15
 
16
  - Reworked the public Gradio surface around a documented **research-instrument design system** rather than SaaS/dashboard defaults.
README.md CHANGED
@@ -60,15 +60,17 @@ The live app is exploratory. The offline study is the dataset-scale experiment.
60
 
61
  It uses **224 discovery prompts** arranged as 112 paraphrase pairs across seven controlled concepts, plus **28 separate causal tasks**. Concept evidence uses prompt-wide max-pooled SAE activations across non-padding tokens; final-token sparse activations are saved separately for local analyses.
62
 
 
 
63
  The study produces:
64
 
65
  - train-only feature selection with held-out AUROC/F1;
66
  - a dense final-token residual linear-probe baseline;
67
  - paraphrase stability;
68
  - 128-resample candidate-selection sensitivity;
69
- - random-controlled single-feature causal results;
70
  - top-1/3/5 feature-set causal results;
71
- - cross-concept association-versus-causality synthesis;
72
  - uncertainty-aware report figures and a measured Markdown report.
73
 
74
  Run the full pipeline with:
@@ -83,6 +85,12 @@ On a memory-constrained GPU, activation collection can be tuned without changing
83
  python -m experiments.run_all --resume --activation-batch-size 8
84
  ```
85
 
 
 
 
 
 
 
86
  After the expensive model stages exist, CPU-only analysis can be regenerated with:
87
 
88
  ```bash
@@ -97,7 +105,7 @@ python -m scripts.validate_artifacts
97
 
98
  ### Google Colab
99
 
100
- A ready-to-run notebook is included at [`notebooks/FeatureLens_Offline_Study_Colab.ipynb`](notebooks/FeatureLens_Offline_Study_Colab.ipynb). It mounts Google Drive for persistent artifacts, keeps the Hugging Face cache on the Colab VM, chooses a conservative activation batch from available VRAM, and runs the resumable pipeline.
101
 
102
  See [`docs/COLAB.md`](docs/COLAB.md) for the exact workflow.
103
 
@@ -111,7 +119,9 @@ artifacts/
111
  ├── layer_metrics.csv
112
  ├── stability.csv
113
  ├── selection_stability.csv
114
- ├── causal_results.csv
 
 
115
  ├── feature_set_results.csv
116
  ├── study_feature_summary.csv
117
  ├── study_summary.json
 
60
 
61
  It uses **224 discovery prompts** arranged as 112 paraphrase pairs across seven controlled concepts, plus **28 separate causal tasks**. Concept evidence uses prompt-wide max-pooled SAE activations across non-padding tokens; final-token sparse activations are saved separately for local analyses.
62
 
63
+ Causal evidence is reported under two position policies: the original **final-token** baseline and **max-feature-activation**, which patches the selected feature where it is most strongly represented in the prompt. Max-active positions are selected from SAE activation only, never from downstream behavioral effects. Primary uncertainty uses the causal task as the statistical unit.
64
+
65
  The study produces:
66
 
67
  - train-only feature selection with held-out AUROC/F1;
68
  - a dense final-token residual linear-probe baseline;
69
  - paraphrase stability;
70
  - 128-resample candidate-selection sensitivity;
71
+ - random-controlled single-feature causal results under both final-token and max-feature-activation patch policies;
72
  - top-1/3/5 feature-set causal results;
73
+ - causal-position coverage/sensitivity and cross-concept association-versus-causality synthesis;
74
  - uncertainty-aware report figures and a measured Markdown report.
75
 
76
  Run the full pipeline with:
 
85
  python -m experiments.run_all --resume --activation-batch-size 8
86
  ```
87
 
88
+ If you already completed the v0.15 study, upgrade it with only the positional causal addendum:
89
+
90
+ ```bash
91
+ python -m experiments.run_causal_addendum --resume
92
+ ```
93
+
94
  After the expensive model stages exist, CPU-only analysis can be regenerated with:
95
 
96
  ```bash
 
105
 
106
  ### Google Colab
107
 
108
+ A ready-to-run full-study notebook is included at [`notebooks/FeatureLens_Offline_Study_Colab.ipynb`](notebooks/FeatureLens_Offline_Study_Colab.ipynb). If the v0.15 study is already complete, use [`notebooks/FeatureLens_Causal_Addendum_Colab.ipynb`](notebooks/FeatureLens_Causal_Addendum_Colab.ipynb) instead; it runs only the max-active causal addendum and CPU synthesis.
109
 
110
  See [`docs/COLAB.md`](docs/COLAB.md) for the exact workflow.
111
 
 
119
  ├── layer_metrics.csv
120
  ├── stability.csv
121
  ├── selection_stability.csv
122
+ ├── causal_results_final_token.csv
123
+ ├── causal_results_max_active.csv
124
+ ├── causal_position_summary.csv
125
  ├── feature_set_results.csv
126
  ├── study_feature_summary.csv
127
  ├── study_summary.json
app.py CHANGED
@@ -3041,6 +3041,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
3041
 
3042
  offline_study = STUDY.dataframe("study_feature_summary.csv")
3043
  offline_stability = STUDY.dataframe("selection_stability.csv")
 
3044
  offline_layers = STUDY.dataframe("layer_metrics.csv")
3045
 
3046
  if not offline_study.empty:
@@ -3067,28 +3068,40 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
3067
 
3068
  with gr.Row(equal_height=False):
3069
  with gr.Column(scale=3):
3070
- _table_heading("Candidate selection stability")
3071
- stability_preview = offline_stability.sort_values(
3072
- ["resample_support", "full_score"], ascending=[False, False]
3073
- ).head(40)
3074
  gr.Dataframe(
3075
- value=stability_preview,
3076
  interactive=False,
3077
  show_label=False,
3078
  buttons=["fullscreen"],
3079
  elem_classes=["result-table"],
3080
  wrap=False,
3081
- max_height=420,
3082
  )
3083
  with gr.Column(scale=2):
3084
  gr.Image(
3085
- value=STUDY.figure("candidate_stability.png"),
3086
- label="Selected-feature resample support",
3087
  interactive=False,
3088
  show_label=True,
3089
  height=360,
3090
  )
3091
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3092
  _table_heading("Layer diagnostics")
3093
  gr.Dataframe(
3094
  value=offline_layers,
 
3041
 
3042
  offline_study = STUDY.dataframe("study_feature_summary.csv")
3043
  offline_stability = STUDY.dataframe("selection_stability.csv")
3044
+ offline_positions = STUDY.dataframe("causal_position_summary.csv")
3045
  offline_layers = STUDY.dataframe("layer_metrics.csv")
3046
 
3047
  if not offline_study.empty:
 
3068
 
3069
  with gr.Row(equal_height=False):
3070
  with gr.Column(scale=3):
3071
+ _table_heading("Causal position sensitivity")
3072
+ position_preview = offline_positions[offline_positions["concept"] == "__all__"] if not offline_positions.empty else offline_positions
 
 
3073
  gr.Dataframe(
3074
+ value=position_preview,
3075
  interactive=False,
3076
  show_label=False,
3077
  buttons=["fullscreen"],
3078
  elem_classes=["result-table"],
3079
  wrap=False,
3080
+ max_height=320,
3081
  )
3082
  with gr.Column(scale=2):
3083
  gr.Image(
3084
+ value=STUDY.figure("causal_position_sensitivity.png"),
3085
+ label="Final-token vs max-active intervention",
3086
  interactive=False,
3087
  show_label=True,
3088
  height=360,
3089
  )
3090
 
3091
+ _table_heading("Candidate selection stability")
3092
+ stability_preview = offline_stability.sort_values(
3093
+ ["resample_support", "full_score"], ascending=[False, False]
3094
+ ).head(40)
3095
+ gr.Dataframe(
3096
+ value=stability_preview,
3097
+ interactive=False,
3098
+ show_label=False,
3099
+ buttons=["fullscreen"],
3100
+ elem_classes=["result-table"],
3101
+ wrap=False,
3102
+ max_height=360,
3103
+ )
3104
+
3105
  _table_heading("Layer diagnostics")
3106
  gr.Dataframe(
3107
  value=offline_layers,
artifacts/README.md CHANGED
@@ -1,44 +1,39 @@
1
  # Generated artifacts
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.
 
1
  # Generated artifacts
2
 
3
+ The repository ships without invented empirical results. The finalized v0.16 study uses prompt-wide feature evidence plus two causal position policies.
4
 
5
+ Fresh full study:
6
 
7
  ```bash
8
+ python -m experiments.run_all --resume
9
  ```
10
 
11
+ Upgrade an already completed v0.15 study without recollecting discovery activations:
12
 
13
  ```bash
14
+ python -m experiments.run_causal_addendum --resume
15
  ```
16
 
17
+ The public artifact set includes:
18
 
 
 
 
 
 
 
 
19
  - `feature_catalog.csv`;
20
  - `layer_metrics.csv`;
21
  - `stability.csv`;
22
  - `selection_stability.csv`;
23
+ - `causal_results_final_token.csv`;
24
+ - `causal_results_max_active.csv`;
25
+ - `causal_position_summary.csv`;
26
  - `feature_set_results.csv`;
27
  - `study_feature_summary.csv`;
28
  - `study_summary.json`;
29
  - `summary.json`;
30
  - `report.md`;
31
+ - report figures including causal-position sensitivity and association-vs-causality.
32
 
33
+ Validate before commit:
34
 
35
  ```bash
36
  python -m scripts.validate_artifacts
37
  ```
38
 
39
+ `artifacts/activations/` remains gitignored. Commit only the small CSV/JSON/report/figure outputs.
docs/CAUSAL_ADDENDUM.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # v0.16 causal-position addendum
2
+
3
+ The completed v0.15 study used prompt-wide SAE evidence for concept discovery but intervened only at the final prompt token. v0.16 preserves that run as a baseline and adds a second causal policy.
4
+
5
+ ## Position policies
6
+
7
+ ### `final_token`
8
+
9
+ Patch the selected SAE feature at the final prompt token. This is the original v0.15 baseline.
10
+
11
+ ### `max_feature_activation`
12
+
13
+ For the selected concept feature and causal prompt:
14
+
15
+ 1. encode the selected layer at every non-padding prompt token;
16
+ 2. read the selected SAE feature's TopK activation at each token;
17
+ 3. choose the token with the largest activation;
18
+ 4. apply the SAE edit and all norm-matched random controls at that same token.
19
+
20
+ The position is selected **only from SAE activation**. The target continuation, logits, and intervention effect are never used to choose the location.
21
+
22
+ If the selected feature is inactive everywhere in the prompt, the policy records zero coverage and uses the final token as a deterministic zero-delta fallback.
23
+
24
+ ## Statistical unit
25
+
26
+ Ablation and 2× amplification are repeated interventions on the same causal prompt. v0.16 therefore uses the **causal task** as the primary paired inference unit:
27
+
28
+ - average the absolute SAE effect across ablation and amplification within each task;
29
+ - average each intervention's random-control ensemble, then average those random magnitudes within task;
30
+ - bootstrap/sign-flip the resulting one SAE-vs-random pair per causal task.
31
+
32
+ The report separately shows unconditional effects across all tasks and effects conditional on the selected feature being active at the intervention location.
33
+
34
+ ## Addendum runner
35
+
36
+ With a completed v0.15 artifact directory:
37
+
38
+ ```bash
39
+ python -m experiments.run_causal_addendum --resume
40
+ ```
41
+
42
+ The runner:
43
+
44
+ 1. migrates `causal_results.csv` to `causal_results_final_token.csv` without rerunning it;
45
+ 2. computes `causal_results_max_active.csv`;
46
+ 3. regenerates `causal_position_summary.csv`, study synthesis, figures, and report;
47
+ 4. validates the finalized artifact schema.
48
+
49
+ It does **not** rerun activation collection, feature evaluation, candidate stability, or feature-set inference.
docs/COLAB.md CHANGED
@@ -84,7 +84,9 @@ The bundle is expected to contain files such as:
84
  - `artifacts/layer_metrics.csv`
85
  - `artifacts/stability.csv`
86
  - `artifacts/selection_stability.csv`
87
- - `artifacts/causal_results.csv`
 
 
88
  - `artifacts/feature_set_results.csv`
89
  - `artifacts/study_feature_summary.csv`
90
  - `artifacts/study_summary.json`
@@ -93,3 +95,13 @@ The bundle is expected to contain files such as:
93
  - `artifacts/figures/*.png`
94
 
95
  Do not commit the `artifacts/activations/` directory.
 
 
 
 
 
 
 
 
 
 
 
84
  - `artifacts/layer_metrics.csv`
85
  - `artifacts/stability.csv`
86
  - `artifacts/selection_stability.csv`
87
+ - `artifacts/causal_results_final_token.csv`
88
+ - `artifacts/causal_results_max_active.csv`
89
+ - `artifacts/causal_position_summary.csv`
90
  - `artifacts/feature_set_results.csv`
91
  - `artifacts/study_feature_summary.csv`
92
  - `artifacts/study_summary.json`
 
95
  - `artifacts/figures/*.png`
96
 
97
  Do not commit the `artifacts/activations/` directory.
98
+
99
+ ## v0.16 causal addendum after a completed v0.15 study
100
+
101
+ If the full v0.15 Colab study already completed, **do not rerun the full notebook**. Use:
102
+
103
+ `notebooks/FeatureLens_Causal_Addendum_Colab.ipynb`
104
+
105
+ The addendum notebook copies only the small existing study outputs to a new Drive folder, preserves the final-token causal baseline, computes the 28-task `max_feature_activation` causal policy, regenerates the CPU study/report artifacts, validates them, and creates a new publishable ZIP.
106
+
107
+ The addendum does not recollect 224-prompt activations, refit feature/probe evaluations, rerun stability resampling, or rerun the 1/3/5 feature-set stage.
docs/OFFLINE_STUDY.md CHANGED
@@ -93,3 +93,22 @@ python -m experiments.run_all --resume --activation-batch-size 8 --activation-ma
93
  ```
94
 
95
  These two activation flags only affect memory/time during activation collection.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  ```
94
 
95
  These two activation flags only affect memory/time during activation collection.
96
+
97
+ ## v0.16 causal-position sensitivity
98
+
99
+ The final study reports two single-feature causal policies rather than conflating concept predictiveness with one arbitrary patch position:
100
+
101
+ - `final_token`: the original causal baseline;
102
+ - `max_feature_activation`: patch the selected feature where its SAE activation is maximal within the prompt.
103
+
104
+ The max-active token is selected before intervention from feature activation only. The behavioral target never participates in position selection.
105
+
106
+ Primary paired inference uses the **causal task** as the unit: ablation and amplification are aggregated within task before bootstrap confidence intervals and sign-flip tests. The report also separates feature coverage from conditional-on-active effect strength.
107
+
108
+ A completed v0.15 run can be upgraded without repeating discovery/model-activation collection:
109
+
110
+ ```bash
111
+ python -m experiments.run_causal_addendum --resume
112
+ ```
113
+
114
+ See [`CAUSAL_ADDENDUM.md`](CAUSAL_ADDENDUM.md).
docs/VALIDATION.md CHANGED
@@ -1,83 +1,36 @@
1
- # FeatureLens v0.15 validation
2
 
3
- v0.15 is a **public-design and offline-runner hardening release**. It does not change the already validated live Qwen/SAE inference methods. Do not spend ZeroGPU quota rerunning causal, paraphrase, layer, cue, discovery, or feature-set experiments for this release.
4
 
5
  ## Local software gate
6
 
7
- Run from the repository root:
8
-
9
  ```bash
10
- python3 -m pytest -q && \
11
- python3 -m compileall -q app.py featurelens experiments scripts && \
12
- python3 -m ruff check app.py featurelens experiments tests scripts && \
13
- python3 scripts/ui_smoke.py && \
14
  python3 scripts/release_check.py
15
  ```
16
 
17
- Then validate the Colab notebook JSON:
18
-
19
- ```bash
20
- python3 - <<'PY'
21
- import nbformat
22
- nb = nbformat.read('notebooks/FeatureLens_Offline_Study_Colab.ipynb', as_version=4)
23
- nbformat.validate(nb)
24
- print('Colab notebook: PASS')
25
- PY
26
- ```
27
-
28
- ## Hugging Face acceptance — no GPU calls
29
-
30
- After pushing, only inspect the rendered interface.
31
-
32
- ### A. Header and navigation
33
-
34
- Pass when:
35
-
36
- - the header shows **FeatureLens** and one factual subtitle;
37
- - there is no visible release/version badge;
38
- - tabs read **Guide, Workbench, Feature sets, Features, Paraphrases, Layers, Study, Method**;
39
- - tabs are visually flat rather than pill/card navigation.
40
-
41
- ### B. Guide
42
-
43
- Open **Guide**.
44
-
45
- Pass when:
46
-
47
- - there is no three-card “step 1 / step 2 / step 3” onboarding grid;
48
- - the workflow is short prose;
49
- - headings use the serif display face while controls/body copy use the neutral sans-serif face;
50
- - no gradients, glow, badge clusters, or decorative cards are visible.
51
-
52
- ### C. Workbench without running inference
53
-
54
- Open **Workbench**.
55
-
56
- Pass when:
57
-
58
- - experiment sections have a clear typographic hierarchy;
59
- - related fields sit close together and separate experiments have more breathing room;
60
- - primary experiment buttons are compact muted-teal actions, not full-width desktop banners;
61
- - **Copy TSV** is visually secondary;
62
- - table titles are clearly larger than table body text;
63
- - no duplicate context cards appear inside the tab—the global **Context** line is the context source of truth.
64
-
65
- ### D. Features tab without running inference
66
-
67
- Open **Features**.
68
-
69
- Pass when:
70
 
71
- - discovery, triage, controlled comparison, cross-target profile, and feature diagnostics read as sections of one tool rather than nested cards;
72
- - helper copy is short and does not repeatedly restate “not a semantic label” after every empty result;
73
- - the page remains usable in both normal desktop width and a narrower browser window.
74
 
75
- ### E. Study empty state
76
 
77
- Open **Study** before real artifacts are committed.
 
 
 
 
 
78
 
79
- Pass when it clearly says the offline study is not materialized and does not show fabricated metrics or placeholder result plots.
80
 
81
- ## Colab runner dry check
82
 
83
- Do not run the full model study merely to validate v0.15. Open the included notebook in Colab and execute only the first GPU-detection cell if desired. The full study should be started only when you are ready to produce the actual empirical artifacts.
 
 
 
 
 
1
+ # FeatureLens v0.16 validation
2
 
3
+ v0.16 changes the **offline causal methodology**, not the already validated live HF inference UI. Do not spend ZeroGPU quota retesting live Workbench paths.
4
 
5
  ## Local software gate
6
 
 
 
7
  ```bash
8
+ python3 -m pytest -q
9
+ python3 -m compileall -q app.py featurelens experiments scripts
10
+ python3 -m ruff check app.py featurelens experiments tests scripts
11
+ python3 scripts/ui_smoke.py
12
  python3 scripts/release_check.py
13
  ```
14
 
15
+ ## Addendum acceptance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
+ Use `notebooks/FeatureLens_Causal_Addendum_Colab.ipynb` with the completed v0.15 Drive run.
 
 
18
 
19
+ The addendum is successful when:
20
 
21
+ 1. `causal_results_final_token.csv` exists and preserves the v0.15 baseline.
22
+ 2. `causal_results_max_active.csv` completes all 28 causal tasks with 8 random controls per intervention.
23
+ 3. `causal_position_summary.csv` contains both `final_token` and `max_feature_activation` policies.
24
+ 4. `study_summary.json` declares `max_feature_activation` as the primary causal position policy and causal-task-level inference as the statistical unit.
25
+ 5. `python -m scripts.validate_artifacts` prints `PASS`.
26
+ 6. `FeatureLens_offline_results_v016.zip` is created without activation caches.
27
 
28
+ ## HF acceptance after final artifacts are committed
29
 
30
+ No GPU call is required. Open **Study** and verify:
31
 
32
+ - the measured headline is populated;
33
+ - final-token and max-active coverage/specificity are visible;
34
+ - the causal-position table and figure render;
35
+ - the association-vs-causality figure uses max-active specificity;
36
+ - no placeholder or old v0.15 significance language remains.
experiments/analyze_study.py CHANGED
@@ -11,6 +11,8 @@ from scipy.stats import spearmanr
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(
@@ -33,57 +35,111 @@ def selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
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)
@@ -93,10 +149,58 @@ def _safe_spearman(x: pd.Series, y: pd.Series) -> dict[str, float | int]:
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
 
@@ -104,33 +208,47 @@ 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
  ]
@@ -142,108 +260,120 @@ def main() -> None:
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
 
 
11
  from experiments.common import ARTIFACT_DIR
12
  from featurelens.stats import paired_bootstrap_difference_ci, paired_sign_flip_pvalue
13
 
14
+ POLICIES = ('final_token', 'max_feature_activation')
15
+
16
 
17
  def parse_args() -> argparse.Namespace:
18
  parser = argparse.ArgumentParser(
 
35
  return ordered.groupby('concept', as_index=False).first()
36
 
37
 
38
+ def causal_path(artifact_dir: Path, policy: str) -> Path:
39
+ explicit = artifact_dir / f'causal_results_{policy if policy == "final_token" else "max_active"}.csv'
40
+ if explicit.exists():
41
+ return explicit
42
+ if policy == 'final_token':
43
+ legacy = artifact_dir / 'causal_results.csv'
44
+ if legacy.exists():
45
+ return legacy
46
+ return explicit
47
+
48
+
49
+ def _task_level_specificity(
50
  frame: pd.DataFrame,
51
  *,
52
  effect_column: str,
 
53
  seed: int,
54
+ active_only: bool = False,
55
  ) -> dict[str, float | list[float]]:
56
+ if frame.empty:
57
+ return _empty_specificity()
58
+
59
+ sae_rows = frame[frame['condition'] == 'sae_feature'].copy()
60
+ if active_only:
61
+ active_column = (
62
+ 'feature_active_at_intervention'
63
+ if 'feature_active_at_intervention' in sae_rows.columns
64
+ else 'feature_activation'
65
+ )
66
+ if active_column == 'feature_activation':
67
+ active_tasks = sae_rows.loc[sae_rows[active_column].astype(float) > 0.0, 'task_id'].unique()
68
+ else:
69
+ active_tasks = sae_rows.loc[sae_rows[active_column].astype(float) > 0.0, 'task_id'].unique()
70
+ frame = frame[frame['task_id'].isin(active_tasks)].copy()
71
+ sae_rows = frame[frame['condition'] == 'sae_feature'].copy()
72
+
73
  sae = (
74
+ sae_rows.assign(
75
+ _abs_effect=lambda data: np.abs(pd.to_numeric(data[effect_column], errors='coerce'))
76
+ )
77
+ .groupby('task_id', as_index=False)
78
+ .agg(sae_abs_effect=('_abs_effect', 'mean'), sae_signed_effect=(effect_column, 'mean'))
79
  )
80
+ random_rows = frame[frame['condition'] == 'random_norm_matched'].assign(
81
+ _abs_effect=lambda data: np.abs(pd.to_numeric(data[effect_column], errors='coerce'))
 
 
 
 
82
  )
83
+ if 'intervention' in random_rows.columns:
84
+ random = (
85
+ random_rows.groupby(['task_id', 'intervention'], as_index=False)['_abs_effect']
86
+ .mean()
87
+ .groupby('task_id', as_index=False)['_abs_effect']
88
+ .mean()
89
+ .rename(columns={'_abs_effect': 'random_abs_effect'})
90
+ )
91
+ else:
92
+ random = (
93
+ random_rows.groupby('task_id', as_index=False)['_abs_effect']
94
+ .mean()
95
+ .rename(columns={'_abs_effect': 'random_abs_effect'})
96
+ )
97
+ paired = sae.merge(random, on='task_id', how='inner')
98
  if paired.empty:
99
+ return _empty_specificity()
 
 
 
 
 
 
 
 
 
100
 
101
+ sae_abs = paired['sae_abs_effect'].to_numpy(dtype=float)
102
+ sae_signed = paired['sae_signed_effect'].to_numpy(dtype=float)
103
  random_abs = paired['random_abs_effect'].to_numpy(dtype=float)
104
  ci_low, ci_high = paired_bootstrap_difference_ci(sae_abs, random_abs, seed=seed)
105
  return {
106
  'sae_abs_mean': float(np.mean(sae_abs)),
107
+ 'sae_signed_mean': float(np.mean(sae_signed)),
108
  'random_abs_mean': float(np.mean(random_abs)),
109
  'specificity_ratio': float(np.mean(sae_abs) / max(float(np.mean(random_abs)), 1e-12)),
110
  'paired_advantage': float(np.mean(sae_abs - random_abs)),
111
  'paired_advantage_ci_95': [float(ci_low), float(ci_high)],
112
+ 'paired_sign_flip_pvalue': float(paired_sign_flip_pvalue(sae_abs, random_abs, seed=seed + 1)),
 
 
113
  'n_tasks': int(len(paired)),
114
  }
115
 
116
 
117
+ def _empty_specificity() -> dict[str, float | list[float]]:
118
+ return {
119
+ 'sae_abs_mean': float('nan'),
120
+ 'sae_signed_mean': float('nan'),
121
+ 'random_abs_mean': float('nan'),
122
+ 'specificity_ratio': float('nan'),
123
+ 'paired_advantage': float('nan'),
124
+ 'paired_advantage_ci_95': [float('nan'), float('nan')],
125
+ 'paired_sign_flip_pvalue': float('nan'),
126
+ 'n_tasks': 0,
127
+ }
128
+
129
+
130
+ def _paired_specificity(
131
+ frame: pd.DataFrame,
132
+ *,
133
+ effect_column: str,
134
+ task_column: str = 'task_id',
135
+ seed: int,
136
+ ) -> dict[str, float | list[float]]:
137
+ """Backward-compatible public helper using task-level inference."""
138
+ if task_column != 'task_id':
139
+ frame = frame.rename(columns={task_column: 'task_id'})
140
+ return _task_level_specificity(frame, effect_column=effect_column, seed=seed)
141
+
142
+
143
  def _safe_spearman(x: pd.Series, y: pd.Series) -> dict[str, float | int]:
144
  a = pd.to_numeric(x, errors='coerce').to_numpy(dtype=float)
145
  b = pd.to_numeric(y, errors='coerce').to_numpy(dtype=float)
 
149
  if np.unique(a[mask]).size < 2 or np.unique(b[mask]).size < 2:
150
  return {'rho': float('nan'), 'pvalue': float('nan'), 'n': int(mask.sum())}
151
  result = spearmanr(a[mask], b[mask])
152
+ return {'rho': float(result.statistic), 'pvalue': float(result.pvalue), 'n': int(mask.sum())}
153
+
154
+
155
+ def _coverage(sae_rows: pd.DataFrame, column: str, fallback: str | None = None) -> float:
156
+ if column in sae_rows.columns:
157
+ return float(pd.to_numeric(sae_rows[column], errors='coerce').fillna(0).astype(float).gt(0).mean())
158
+ if fallback and fallback in sae_rows.columns:
159
+ return float(pd.to_numeric(sae_rows[fallback], errors='coerce').fillna(0).astype(float).gt(0).mean())
160
+ return float('nan')
161
+
162
+
163
+ def _policy_summary(frame: pd.DataFrame, policy: str, seed: int) -> dict:
164
+ target = _task_level_specificity(frame, effect_column='target_mean_logprob_delta', seed=seed)
165
+ target_active = _task_level_specificity(
166
+ frame, effect_column='target_mean_logprob_delta', seed=seed + 7, active_only=True
167
+ )
168
+ js = _task_level_specificity(frame, effect_column='js_divergence', seed=seed + 17)
169
+ js_active = _task_level_specificity(
170
+ frame, effect_column='js_divergence', seed=seed + 29, active_only=True
171
+ )
172
+ sae_rows = frame[frame['condition'] == 'sae_feature']
173
  return {
174
+ 'position_policy': policy,
175
+ 'tasks': int(target['n_tasks']),
176
+ 'feature_active_at_intervention_rate': _coverage(
177
+ sae_rows, 'feature_active_at_intervention', fallback='feature_activation'
178
+ ),
179
+ 'feature_active_at_final_token_rate': _coverage(
180
+ sae_rows, 'feature_active_at_final_token', fallback='feature_activation'
181
+ ),
182
+ 'feature_active_anywhere_rate': _coverage(
183
+ sae_rows, 'feature_active_anywhere', fallback='feature_activation'
184
+ ),
185
+ 'target_sae_abs_mean': target['sae_abs_mean'],
186
+ 'target_random_abs_mean': target['random_abs_mean'],
187
+ 'target_specificity_ratio': target['specificity_ratio'],
188
+ 'target_paired_advantage': target['paired_advantage'],
189
+ 'target_paired_ci_low': float(target['paired_advantage_ci_95'][0]),
190
+ 'target_paired_ci_high': float(target['paired_advantage_ci_95'][1]),
191
+ 'target_sign_flip_pvalue': target['paired_sign_flip_pvalue'],
192
+ 'active_target_sae_abs_mean': target_active['sae_abs_mean'],
193
+ 'active_target_random_abs_mean': target_active['random_abs_mean'],
194
+ 'active_target_specificity_ratio': target_active['specificity_ratio'],
195
+ 'active_target_paired_advantage': target_active['paired_advantage'],
196
+ 'active_target_paired_ci_low': float(target_active['paired_advantage_ci_95'][0]),
197
+ 'active_target_paired_ci_high': float(target_active['paired_advantage_ci_95'][1]),
198
+ 'active_target_sign_flip_pvalue': target_active['paired_sign_flip_pvalue'],
199
+ 'active_tasks': int(target_active['n_tasks']),
200
+ 'js_sae_mean': js['sae_abs_mean'],
201
+ 'js_random_mean': js['random_abs_mean'],
202
+ 'js_specificity_ratio': js['specificity_ratio'],
203
+ 'active_js_specificity_ratio': js_active['specificity_ratio'],
204
  }
205
 
206
 
 
208
  args = parse_args()
209
  artifact_dir = args.artifact_dir
210
  catalog = pd.read_csv(artifact_dir / 'feature_catalog.csv')
 
211
  paraphrase = pd.read_csv(artifact_dir / 'stability.csv')
212
  stability_path = artifact_dir / 'selection_stability.csv'
213
  selection_stability = pd.read_csv(stability_path) if stability_path.exists() else pd.DataFrame()
214
 
215
+ causal_by_policy: dict[str, pd.DataFrame] = {}
216
+ for policy in POLICIES:
217
+ path = causal_path(artifact_dir, policy)
218
+ if path.exists():
219
+ frame = pd.read_csv(path)
220
+ if 'position_policy' not in frame.columns:
221
+ frame['position_policy'] = policy
222
+ causal_by_policy[policy] = frame
223
+
224
+ if 'final_token' not in causal_by_policy or 'max_feature_activation' not in causal_by_policy:
225
+ missing = [policy for policy in POLICIES if policy not in causal_by_policy]
226
+ raise SystemExit(f'Missing causal position policies: {missing}')
227
+
228
  selected = selected_features(catalog)
229
+ position_rows: list[dict] = []
230
+ for policy_idx, policy in enumerate(POLICIES):
231
+ overall = _policy_summary(causal_by_policy[policy], policy, args.seed + 1000 * policy_idx)
232
+ overall['concept'] = '__all__'
233
+ position_rows.append(overall)
234
+ for concept_idx, concept in enumerate(sorted(selected['concept'].astype(str).unique())):
235
+ subset = causal_by_policy[policy][causal_by_policy[policy]['concept'] == concept].copy()
236
+ row = _policy_summary(
237
+ subset,
238
+ policy,
239
+ args.seed + 1000 * policy_idx + 100 * (concept_idx + 1),
240
+ )
241
+ row['concept'] = concept
242
+ position_rows.append(row)
243
 
244
+ position_summary = pd.DataFrame(position_rows)
245
+ position_summary.to_csv(artifact_dir / 'causal_position_summary.csv', index=False)
246
+
247
+ rows: list[dict] = []
248
  for concept_idx, selected_row in selected.sort_values('concept').reset_index(drop=True).iterrows():
249
  concept = str(selected_row['concept'])
250
  layer = int(selected_row['layer'])
251
  feature_id = int(selected_row['feature_id'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  para_rows = paraphrase[
253
  (paraphrase['concept'] == concept) & (paraphrase['layer'].astype(int) == layer)
254
  ]
 
260
  & (selection_stability['feature_id'].astype(int) == feature_id)
261
  ]
262
 
263
+ base = {
264
+ 'concept': concept,
265
+ 'layer': layer,
266
+ 'feature_id': feature_id,
267
+ 'train_auroc': float(selected_row['train_auroc']),
268
+ 'heldout_auroc': float(selected_row['auroc']),
269
+ 'heldout_f1': float(selected_row['f1']),
270
+ 'activation_rate_pos_train': float(selected_row['activation_rate_pos']),
271
+ 'activation_rate_neg_train': float(selected_row['activation_rate_neg']),
272
+ 'candidate_resample_support': (
273
+ float(selection_row.iloc[0]['resample_support']) if not selection_row.empty else 0.0
274
+ ),
275
+ 'candidate_median_resample_rank': (
276
+ float(selection_row.iloc[0]['median_resample_rank'])
277
+ if not selection_row.empty
278
+ else float('nan')
279
+ ),
280
+ 'mean_paraphrase_topk_jaccard': (
281
+ float(para_rows['topk_jaccard'].mean()) if not para_rows.empty else float('nan')
282
+ ),
283
+ 'mean_paraphrase_sparse_cosine': (
284
+ float(para_rows['sparse_cosine'].mean()) if not para_rows.empty else float('nan')
285
+ ),
286
+ }
287
+ for policy in POLICIES:
288
+ policy_frame = causal_by_policy[policy]
289
+ subset = policy_frame[policy_frame['concept'] == concept].copy()
290
+ summary = _policy_summary(
291
+ subset,
292
+ policy,
293
+ args.seed + 10_000 + 1000 * POLICIES.index(policy) + 100 * concept_idx,
294
+ )
295
+ prefix = 'final' if policy == 'final_token' else 'max_active'
296
+ for key, value in summary.items():
297
+ if key in {'position_policy'}:
298
+ continue
299
+ base[f'{prefix}_{key}'] = value
300
+ base['target_specificity_gain_max_vs_final'] = (
301
+ float(base['max_active_target_specificity_ratio'])
302
+ - float(base['final_target_specificity_ratio'])
 
 
 
 
 
 
 
 
303
  )
304
+ base['js_specificity_gain_max_vs_final'] = (
305
+ float(base['max_active_js_specificity_ratio'])
306
+ - float(base['final_js_specificity_ratio'])
307
+ )
308
+ rows.append(base)
309
 
310
  study = pd.DataFrame(rows)
311
  study_path = artifact_dir / 'study_feature_summary.csv'
312
  study.to_csv(study_path, index=False)
313
 
314
  correlations = {
315
+ 'heldout_auroc_vs_max_active_target_specificity': _safe_spearman(
316
+ study['heldout_auroc'], study['max_active_target_specificity_ratio']
317
  ),
318
+ 'heldout_auroc_vs_max_active_js_specificity': _safe_spearman(
319
+ study['heldout_auroc'], study['max_active_js_specificity_ratio']
320
  ),
321
+ 'heldout_f1_vs_max_active_target_specificity': _safe_spearman(
322
+ study['heldout_f1'], study['max_active_target_specificity_ratio']
323
  ),
324
+ 'candidate_resample_support_vs_max_active_target_specificity': _safe_spearman(
325
+ study['candidate_resample_support'], study['max_active_target_specificity_ratio']
326
  ),
327
  }
328
 
329
+ overall = position_summary[position_summary['concept'] == '__all__'].set_index('position_policy')
330
+ final_row = overall.loc['final_token']
331
+ max_row = overall.loc['max_feature_activation']
332
  most_predictive = study.sort_values('heldout_auroc', ascending=False).iloc[0]
333
+ most_target_specific = study.sort_values('max_active_target_specificity_ratio', ascending=False).iloc[0]
334
+ most_js_specific = study.sort_values('max_active_js_specificity_ratio', ascending=False).iloc[0]
 
335
 
336
  summary = {
337
  'n_concepts': int(len(study)),
338
  'selected_feature_pooling': 'prompt-wide max SAE activation across non-padding prompt tokens',
339
  'dense_probe_pooling': 'final prompt token residual',
340
+ 'primary_causal_position_policy': 'max_feature_activation',
341
+ 'causal_statistical_unit': 'causal task; ablation and amplification are averaged within task before paired inference',
342
+ 'median_selected_feature_resample_support': float(study['candidate_resample_support'].median()),
343
+ 'final_token_feature_coverage': float(final_row['feature_active_at_intervention_rate']),
344
+ 'max_active_feature_coverage': float(max_row['feature_active_at_intervention_rate']),
345
+ 'final_token_target_specificity_ratio': float(final_row['target_specificity_ratio']),
346
+ 'max_active_target_specificity_ratio': float(max_row['target_specificity_ratio']),
347
+ 'final_token_target_paired_advantage': float(final_row['target_paired_advantage']),
348
+ 'max_active_target_paired_advantage': float(max_row['target_paired_advantage']),
349
+ 'final_token_target_paired_ci_95': [
350
+ float(final_row['target_paired_ci_low']), float(final_row['target_paired_ci_high'])
351
+ ],
352
+ 'max_active_target_paired_ci_95': [
353
+ float(max_row['target_paired_ci_low']), float(max_row['target_paired_ci_high'])
354
+ ],
355
+ 'final_token_target_sign_flip_pvalue': float(final_row['target_sign_flip_pvalue']),
356
+ 'max_active_target_sign_flip_pvalue': float(max_row['target_sign_flip_pvalue']),
357
  'most_predictive_concept': {
358
  'concept': str(most_predictive['concept']),
359
  'heldout_auroc': float(most_predictive['heldout_auroc']),
360
  },
361
+ 'highest_max_active_target_specificity': {
362
  'concept': str(most_target_specific['concept']),
363
+ 'ratio': float(most_target_specific['max_active_target_specificity_ratio']),
364
  },
365
+ 'highest_max_active_js_specificity': {
366
  'concept': str(most_js_specific['concept']),
367
+ 'ratio': float(most_js_specific['max_active_js_specificity_ratio']),
368
  },
369
  'correlations': correlations,
370
  'guardrail': (
371
+ 'Max-active causal positions are selected from SAE activation only, never from behavioral outcome. '
372
+ 'Cross-concept Spearman correlations are descriptive because the study contains seven concepts.'
 
373
  ),
374
  }
375
+ (artifact_dir / 'study_summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
376
+ print(f'Wrote {artifact_dir / "causal_position_summary.csv"}')
 
 
377
  print(f'Wrote {study_path}')
378
  print(f'Wrote {artifact_dir / "study_summary.json"}')
379
 
experiments/make_report.py CHANGED
@@ -17,136 +17,130 @@ from featurelens.stats import (
17
 
18
 
19
  def parse_args() -> argparse.Namespace:
20
- parser = argparse.ArgumentParser(description='Build a truthful experiment report from saved metrics.')
21
- parser.add_argument('--artifact-dir', type=Path, default=ARTIFACT_DIR)
 
 
22
  return parser.parse_args()
23
 
24
 
25
  def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
26
  scored = catalog.copy()
27
- scored['activation_contrast'] = scored['activation_rate_pos'] - scored['activation_rate_neg']
 
 
28
  ordered = scored.sort_values(
29
- ['concept', 'train_auroc', 'activation_contrast'],
30
  ascending=[True, False, False],
31
  )
32
- return ordered.groupby('concept', as_index=False).first()
33
 
34
 
35
  def _effect_column(frame: pd.DataFrame) -> str:
36
- """Prefer the full-continuation length-normalized metric, with legacy fallback."""
37
- if 'target_mean_logprob_delta' in frame.columns:
38
- return 'target_mean_logprob_delta'
39
- return 'target_logprob_delta'
40
 
41
 
42
- def _save_plots(
43
- artifact_dir: Path,
44
- selected: pd.DataFrame,
45
- layers: pd.DataFrame,
46
- causal: pd.DataFrame,
47
- feature_sets: pd.DataFrame | None,
48
- ) -> None:
49
- fig_dir = artifact_dir / 'figures'
50
- fig_dir.mkdir(parents=True, exist_ok=True)
51
 
52
- figure = plt.figure(figsize=(7.5, 4.2))
53
- ax = figure.add_subplot(111)
54
- ordered = selected.sort_values('auroc')
55
- ax.barh(ordered['concept'], ordered['auroc'])
56
- ax.axvline(0.5, linewidth=1, linestyle='--')
57
- ax.set_xlabel('Held-out AUROC')
58
- ax.set_title('Selected SAE feature predictiveness')
59
- figure.tight_layout()
60
- figure.savefig(fig_dir / 'feature_auroc.png', dpi=160)
61
- plt.close(figure)
62
-
63
- figure = plt.figure(figsize=(7.0, 4.2))
64
- ax = figure.add_subplot(111)
65
- ax.plot(
66
- layers['layer'],
67
- layers['linear_probe_macro_auroc'],
68
- marker='o',
69
- label='Linear probe AUROC',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  )
71
- ax.plot(
72
- layers['layer'],
73
- layers['reconstruction_cosine'],
74
- marker='o',
75
- label='SAE reconstruction cosine',
 
 
 
 
 
 
 
76
  )
77
- ax.set_xlabel('Layer')
78
- ax.set_ylim(0, 1.05)
79
- ax.set_title('Layer-wise representation diagnostics')
80
- ax.legend()
81
- figure.tight_layout()
82
- figure.savefig(fig_dir / 'layer_diagnostics.png', dpi=160)
83
- plt.close(figure)
84
 
85
- causal_metric = _effect_column(causal)
86
- grouped = (
87
- causal.groupby(['intervention', 'condition'])[causal_metric]
88
- .apply(lambda values: float(np.mean(np.abs(values))))
89
- .reset_index(name='mean_abs_effect')
90
- )
91
- pivot = grouped.pivot(index='intervention', columns='condition', values='mean_abs_effect')
92
- figure = plt.figure(figsize=(7.0, 4.2))
93
- ax = figure.add_subplot(111)
94
- pivot.plot(kind='bar', ax=ax)
95
- ax.set_ylabel('Mean |Δ mean log p/token|' if causal_metric == 'target_mean_logprob_delta' else 'Mean |Δ log p(target)|')
96
- ax.set_title('Single-feature SAE edits vs norm-matched controls')
97
- ax.tick_params(axis='x', rotation=0)
98
- figure.tight_layout()
99
- figure.savefig(fig_dir / 'causal_effects.png', dpi=160)
100
- plt.close(figure)
101
 
102
- if feature_sets is not None and not feature_sets.empty:
103
- set_metric = _effect_column(feature_sets)
104
- grouped_sets = (
105
- feature_sets.groupby(['set_size', 'condition'])[set_metric]
106
- .apply(lambda values: float(np.mean(np.abs(values))))
107
- .reset_index(name='mean_abs_effect')
108
- )
109
- set_pivot = grouped_sets.pivot(index='set_size', columns='condition', values='mean_abs_effect')
110
- figure = plt.figure(figsize=(7.0, 4.2))
111
- ax = figure.add_subplot(111)
112
- set_pivot.plot(kind='line', marker='o', ax=ax)
113
- ax.set_xlabel('Jointly ablated feature count')
114
- ax.set_ylabel('Mean |Δ mean log p/token|' if set_metric == 'target_mean_logprob_delta' else 'Mean |Δ log p(target)|')
115
- ax.set_title('Distributed feature-set causal effect')
116
- figure.tight_layout()
117
- figure.savefig(fig_dir / 'feature_set_effects.png', dpi=160)
118
- plt.close(figure)
119
-
120
-
121
-
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,
@@ -156,301 +150,625 @@ def _paired_stats(
156
  random_condition: str,
157
  seed: int,
158
  ) -> dict[str, float | list[float]]:
159
- """Pair one SAE effect with the mean absolute effect of its random-control ensemble."""
160
  metric = _effect_column(frame)
161
  sae = (
162
- frame[frame['condition'] == sae_condition]
163
  .groupby(index, as_index=False)[metric]
164
  .first()
165
- .rename(columns={metric: 'sae_effect'})
166
  )
167
  random = (
168
- frame[frame['condition'] == random_condition]
169
- .assign(_abs_effect=lambda data: np.abs(data[metric].astype(float)))
170
- .groupby(index, as_index=False)['_abs_effect']
 
 
 
 
171
  .mean()
172
- .rename(columns={'_abs_effect': 'random_abs_effect'})
173
  )
174
- paired = sae.merge(random, on=index, how='inner')
175
- sae_abs = np.abs(paired['sae_effect'].to_numpy(dtype=float))
176
- random_abs = paired['random_abs_effect'].to_numpy(dtype=float)
177
- if sae_abs.size == 0:
 
 
178
  return {
179
- 'sae_abs': float('nan'),
180
- 'random_abs': float('nan'),
181
- 'ratio': float('nan'),
182
- 'paired_advantage': float('nan'),
183
- 'ci': [float('nan'), float('nan')],
184
- 'pvalue': float('nan'),
185
- 'n_pairs': 0,
186
  }
187
- diff = sae_abs - random_abs
188
- low, high = paired_bootstrap_difference_ci(sae_abs, random_abs, seed=seed)
 
 
 
 
189
  return {
190
- 'sae_abs': float(np.mean(sae_abs)),
191
- 'random_abs': float(np.mean(random_abs)),
192
- 'ratio': float(np.mean(sae_abs) / max(float(np.mean(random_abs)), 1e-12)),
193
- 'paired_advantage': float(np.mean(diff)),
194
- 'ci': [float(low), float(high)],
195
- 'pvalue': float(paired_sign_flip_pvalue(sae_abs, random_abs, seed=seed + 1)),
196
- 'n_pairs': int(sae_abs.size),
 
 
 
 
 
 
 
 
197
  }
198
 
199
 
200
- def main() -> None:
201
- args = parse_args()
202
- catalog = pd.read_csv(args.artifact_dir / 'feature_catalog.csv')
203
- layers = pd.read_csv(args.artifact_dir / 'layer_metrics.csv')
204
- stability = pd.read_csv(args.artifact_dir / 'stability.csv')
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())
222
- auc_ci_low, auc_ci_high = bootstrap_mean_ci(selected['auroc'].to_numpy(), seed=42)
223
- best_layer_row = layers.sort_values('linear_probe_macro_auroc', ascending=False).iloc[0]
224
- mean_jaccard = float(stability['topk_jaccard'].mean())
225
- mean_sparse_cos = float(stability['sparse_cosine'].mean())
226
-
227
- single = _paired_stats(
228
- causal,
229
- index=['task_id', 'intervention'],
230
- sae_condition='sae_feature',
231
- random_condition='random_norm_matched',
232
- seed=43,
233
- )
234
- sae = causal[causal['condition'] == 'sae_feature']
235
- active_rate = float(np.mean(sae['feature_activation'] > 0))
236
- top1_change = float(sae['top1_changed'].mean())
237
-
238
- set_summary: dict[int, dict[str, float | list[float]]] = {}
239
- largest_set: dict[str, float | list[float]] | None = None
240
- largest_k: int | None = None
241
- if feature_sets is not None and not feature_sets.empty:
242
- for size in sorted(int(x) for x in feature_sets['set_size'].unique()):
243
- subset = feature_sets[feature_sets['set_size'] == size]
244
- set_summary[size] = _paired_stats(
245
- subset,
246
- index=['task_id', 'set_size'],
247
- sae_condition='sae_feature_set',
248
- random_condition='random_norm_matched',
249
- seed=100 + size,
250
  )
251
- largest_k = max(set_summary)
252
- largest_set = set_summary[largest_k]
 
 
 
 
 
 
253
 
254
- sae_abs = float(single['sae_abs'])
255
- random_abs = float(single['random_abs'])
256
- ratio = float(single['ratio'])
257
 
258
- single_ci_low = float(single['ci'][0])
259
- single_p = float(single['pvalue'])
260
- single_specific = ratio >= 1.5 and single_ci_low > 0.0 and single_p < 0.05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
- if mean_auc >= 0.8 and sae_abs < 0.08:
263
- interpretation = (
264
- 'The selected sparse features were strongly predictive on held-out prompts, but single-feature '
265
- 'interventions produced only modest downstream changes. FeatureLens therefore treats the '
266
- 'representation-level signal as correlational rather than automatically causal.'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  )
268
- elif mean_auc >= 0.8 and sae_abs >= 0.08 and single_specific:
269
- interpretation = (
270
- 'The selected sparse features were strongly predictive and single-feature interventions produced '
271
- 'larger target-continuation shifts than norm-matched random residual perturbations. The paired '
272
- 'bootstrap interval excludes zero and the sign-flip test passes the configured 0.05 threshold, '
273
- 'supporting a causal-specificity claim for at least some predictive features.'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  )
275
- elif mean_auc >= 0.8 and sae_abs >= 0.08 and ratio >= 1.5:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  interpretation = (
277
- 'The selected sparse features were strongly predictive and their point-estimate intervention '
278
- 'effects exceeded norm-matched random controls, but the paired uncertainty test does not support '
279
- 'a strong causal-specificity claim at the 0.05 threshold. The result is reported as suggestive '
280
- 'rather than conclusive.'
 
281
  )
282
- elif mean_auc < 0.65:
283
  interpretation = (
284
- 'Feature/concept predictiveness was limited on held-out prompts, so strong causal claims would '
285
- 'be premature. The main result is diagnostic: concept design or feature selection should be '
286
- 'refined before interpreting intervention effects.'
 
287
  )
288
  else:
289
  interpretation = (
290
- 'The results show mixed predictive and causal evidence. FeatureLens reports association, '
291
- 'robustness, and intervention measurements separately rather than collapsing them into one score.'
 
 
292
  )
293
 
294
- if largest_set is not None and largest_k is not None:
295
- set_advantage = float(largest_set['paired_advantage'])
296
- set_ci_low = float(largest_set['ci'][0])
297
- set_p = float(largest_set['pvalue'])
298
- set_specific = set_ci_low > 0.0 and set_p < 0.05
299
- if set_advantage > float(single['paired_advantage']) + 0.02 and set_specific:
300
- interpretation += (
301
- f' Joint ablation of the top {largest_k} same-layer concept features produced a larger '
302
- 'paired advantage over random controls than the single-feature edits, with paired uncertainty '
303
- 'supporting the difference. This is consistent with causal influence being distributed across '
304
- 'a sparse feature set rather than concentrated in one unit.'
305
- )
306
- elif set_advantage > float(single['paired_advantage']) + 0.02:
307
- interpretation += (
308
- f' The top-{largest_k} joint-ablation point estimate exceeded the single-feature advantage, '
309
- 'but its paired uncertainty test does not support a strong distributed-causality claim at the '
310
- '0.05 threshold. The pattern is therefore treated as suggestive only.'
311
- )
312
- elif abs(set_advantage) <= 0.02:
313
- interpretation += (
314
- f' Expanding the intervention to the top {largest_k} same-layer features did not materially '
315
- 'increase specificity over random controls, which argues against assuming that a broader '
316
- 'concept-associated sparse subspace is automatically more causal.'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  )
318
 
319
- effect_label = 'mean log p/token' if _effect_column(causal) == 'target_mean_logprob_delta' else 'target log-probability'
 
 
 
 
 
320
  headline = (
321
- f'Selected SAE features averaged {mean_auc:.3f} held-out AUROC; single-feature SAE interventions '
322
- f'changed {effect_label} by {sae_abs:.3f} in absolute value on average versus {random_abs:.3f} '
323
- 'for norm-matched random residual controls.'
 
 
324
  )
 
325
  highlights = [
326
- f'Median selected-feature held-out AUROC: {median_auc:.3f}; mean AUROC 95% bootstrap CI [{auc_ci_low:.3f}, {auc_ci_high:.3f}].',
327
- f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
328
- f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
329
- f'Selected feature active on {active_rate:.1%} of causal prompts; modified next-token top-1 on {top1_change:.1%}.',
330
- f'Single-feature mean absolute causal effect / random-control effect ratio: {ratio:.2f}×.',
331
- f'Single-feature paired mean |effect| advantage over random: {float(single["paired_advantage"]):+.3f}, 95% bootstrap CI [{float(single["ci"][0]):+.3f}, {float(single["ci"][1]):+.3f}], sign-flip p={float(single["pvalue"]):.4f}.',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  ]
333
- if largest_set is not None and largest_k is not None:
 
 
 
334
  highlights.append(
335
- f'Top-{largest_k} joint ablation: SAE/random mean absolute effect ratio {float(largest_set["ratio"]):.2f}×; '
336
- f'paired advantage {float(largest_set["paired_advantage"]):+.3f}, 95% CI '
337
- f'[{float(largest_set["ci"][0]):+.3f}, {float(largest_set["ci"][1]):+.3f}], '
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,
356
- 'interpretation': interpretation,
357
- 'metrics': {
358
- 'mean_selected_feature_test_auroc': mean_auc,
359
- 'mean_selected_feature_test_auroc_bootstrap_ci_95': [auc_ci_low, auc_ci_high],
360
- 'median_selected_feature_test_auroc': median_auc,
361
- 'best_linear_probe_layer': int(best_layer_row['layer']),
362
- 'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
363
- 'mean_paraphrase_topk_jaccard': mean_jaccard,
364
- 'mean_paraphrase_sparse_cosine': mean_sparse_cos,
365
- 'single_feature_effect_metric': _effect_column(causal),
366
- 'mean_abs_sae_effect': sae_abs,
367
- 'mean_abs_random_effect': random_abs,
368
- 'causal_to_random_effect_ratio': ratio,
369
- 'paired_mean_abs_effect_advantage': float(single['paired_advantage']),
370
- 'paired_mean_abs_effect_advantage_bootstrap_ci_95': single['ci'],
371
- 'paired_sign_flip_pvalue': float(single['pvalue']),
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')
 
 
 
379
 
380
- lines = [
381
- '# FeatureLens experiment report',
382
- '',
383
- '## Research question',
384
- '',
385
- '**Do sparse features that predict a concept also causally influence model behaviour?**',
386
- '',
387
- '## Executive summary',
388
- '',
389
- headline,
390
- '',
391
- interpretation,
392
- '',
393
- '## Key measurements',
394
- '',
395
- *[f'- {item}' for item in highlights],
396
- '',
397
- '## Experimental design',
398
- '',
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.',
408
- '- Feature-set causal edit: joint ablation of top same-layer concept features, evaluated at k=1/3/5 by default.',
409
- '- Negative control: ensemble of deterministic random residual directions, each matched to the SAE perturbation L2 norm.',
410
- '- Target metric: exact full target continuation scored teacher-forced; mean log probability per target token is the primary length-comparable effect.',
411
- '- Secondary diagnostics: first-token probability/rank, next-token JS divergence, and top-1 changes.',
412
- '- Uncertainty: bootstrap 95% confidence intervals and paired sign-flip randomization tests.',
413
- '',
414
- '## Figures',
415
- '',
416
- '![Feature AUROC](figures/feature_auroc.png)',
417
- '',
418
- '![Layer diagnostics](figures/layer_diagnostics.png)',
419
- '',
420
- '![Single-feature causal effects](figures/causal_effects.png)',
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
- '',
440
- '## Interpretation guardrails',
441
- '',
442
- 'A high feature/concept AUROC or high paraphrase overlap is correlational evidence only. Causal evidence requires a downstream change under intervention and is interpreted relative to a norm-matched random control. Feature-set effects are not assumed stronger a priori; they are separately measured. The narrative above is generated from saved metrics, with no hard-coded result values.',
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
  )
450
- (args.artifact_dir / 'report.md').write_text('\n'.join(lines), encoding='utf-8')
 
451
  print(headline)
452
- print(f'Wrote {args.artifact_dir / "report.md"}')
453
 
454
 
455
- if __name__ == '__main__':
456
  main()
 
17
 
18
 
19
  def parse_args() -> argparse.Namespace:
20
+ parser = argparse.ArgumentParser(
21
+ description="Build a truthful experiment report from saved metrics."
22
+ )
23
+ parser.add_argument("--artifact-dir", type=Path, default=ARTIFACT_DIR)
24
  return parser.parse_args()
25
 
26
 
27
  def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
28
  scored = catalog.copy()
29
+ scored["activation_contrast"] = (
30
+ scored["activation_rate_pos"] - scored["activation_rate_neg"]
31
+ )
32
  ordered = scored.sort_values(
33
+ ["concept", "train_auroc", "activation_contrast"],
34
  ascending=[True, False, False],
35
  )
36
+ return ordered.groupby("concept", as_index=False).first()
37
 
38
 
39
  def _effect_column(frame: pd.DataFrame) -> str:
40
+ if "target_mean_logprob_delta" in frame.columns:
41
+ return "target_mean_logprob_delta"
42
+ return "target_logprob_delta"
 
43
 
44
 
45
+ def _causal_file(artifact_dir: Path, policy: str) -> Path:
46
+ if policy == "final_token":
47
+ name = "causal_results_final_token.csv"
48
+ else:
49
+ name = "causal_results_max_active.csv"
 
 
 
 
50
 
51
+ path = artifact_dir / name
52
+ if path.exists():
53
+ return path
54
+
55
+ legacy = artifact_dir / "causal_results.csv"
56
+ if policy == "final_token" and legacy.exists():
57
+ return legacy
58
+ return path
59
+
60
+
61
+ def _task_level_stats(
62
+ frame: pd.DataFrame,
63
+ *,
64
+ seed: int,
65
+ active_only: bool = False,
66
+ ) -> dict:
67
+ metric = _effect_column(frame)
68
+ sae_rows = frame[frame["condition"] == "sae_feature"].copy()
69
+
70
+ if active_only:
71
+ if "feature_active_at_intervention" in frame.columns:
72
+ active_col = "feature_active_at_intervention"
73
+ else:
74
+ active_col = "feature_activation"
75
+
76
+ active_mask = (
77
+ pd.to_numeric(sae_rows[active_col], errors="coerce").fillna(0) > 0
78
+ )
79
+ active_ids = sae_rows.loc[active_mask, "task_id"].unique()
80
+ frame = frame[frame["task_id"].isin(active_ids)].copy()
81
+ sae_rows = frame[frame["condition"] == "sae_feature"].copy()
82
+
83
+ sae = (
84
+ sae_rows.assign(
85
+ _abs=lambda data: np.abs(
86
+ pd.to_numeric(data[metric], errors="coerce")
87
+ )
88
+ )
89
+ .groupby("task_id", as_index=False)
90
+ .agg(sae_abs=("_abs", "mean"))
91
  )
92
+ random = (
93
+ frame[frame["condition"] == "random_norm_matched"]
94
+ .assign(
95
+ _abs=lambda data: np.abs(
96
+ pd.to_numeric(data[metric], errors="coerce")
97
+ )
98
+ )
99
+ .groupby(["task_id", "intervention"], as_index=False)["_abs"]
100
+ .mean()
101
+ .groupby("task_id", as_index=False)["_abs"]
102
+ .mean()
103
+ .rename(columns={"_abs": "random_abs"})
104
  )
105
+ paired = sae.merge(random, on="task_id", how="inner")
 
 
 
 
 
 
106
 
107
+ if paired.empty:
108
+ nan = float("nan")
109
+ return {
110
+ "sae_abs": nan,
111
+ "random_abs": nan,
112
+ "ratio": nan,
113
+ "advantage": nan,
114
+ "ci": [nan, nan],
115
+ "pvalue": nan,
116
+ "n_tasks": 0,
117
+ }
 
 
 
 
 
118
 
119
+ sae_effect = paired["sae_abs"].to_numpy(float)
120
+ random_effect = paired["random_abs"].to_numpy(float)
121
+ low, high = paired_bootstrap_difference_ci(
122
+ sae_effect,
123
+ random_effect,
124
+ seed=seed,
125
+ )
126
+ return {
127
+ "sae_abs": float(sae_effect.mean()),
128
+ "random_abs": float(random_effect.mean()),
129
+ "ratio": float(
130
+ sae_effect.mean() / max(float(random_effect.mean()), 1e-12)
131
+ ),
132
+ "advantage": float((sae_effect - random_effect).mean()),
133
+ "ci": [float(low), float(high)],
134
+ "pvalue": float(
135
+ paired_sign_flip_pvalue(
136
+ sae_effect,
137
+ random_effect,
138
+ seed=seed + 1,
139
+ )
140
+ ),
141
+ "n_tasks": int(len(paired)),
142
+ }
 
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
  def _paired_stats(
146
  frame: pd.DataFrame,
 
150
  random_condition: str,
151
  seed: int,
152
  ) -> dict[str, float | list[float]]:
153
+ """Legacy helper retained for report-control regression tests."""
154
  metric = _effect_column(frame)
155
  sae = (
156
+ frame[frame["condition"] == sae_condition]
157
  .groupby(index, as_index=False)[metric]
158
  .first()
159
+ .rename(columns={metric: "sae_effect"})
160
  )
161
  random = (
162
+ frame[frame["condition"] == random_condition]
163
+ .assign(
164
+ _abs_effect=lambda data: np.abs(
165
+ pd.to_numeric(data[metric], errors="coerce")
166
+ )
167
+ )
168
+ .groupby(index, as_index=False)["_abs_effect"]
169
  .mean()
170
+ .rename(columns={"_abs_effect": "random_abs_effect"})
171
  )
172
+ paired = sae.merge(random, on=index, how="inner")
173
+ sae_effect = np.abs(paired["sae_effect"].to_numpy(dtype=float))
174
+ random_effect = paired["random_abs_effect"].to_numpy(dtype=float)
175
+
176
+ if sae_effect.size == 0:
177
+ nan = float("nan")
178
  return {
179
+ "sae_abs": nan,
180
+ "random_abs": nan,
181
+ "ratio": nan,
182
+ "paired_advantage": nan,
183
+ "ci": [nan, nan],
184
+ "pvalue": nan,
185
+ "n_pairs": 0,
186
  }
187
+
188
+ low, high = paired_bootstrap_difference_ci(
189
+ sae_effect,
190
+ random_effect,
191
+ seed=seed,
192
+ )
193
  return {
194
+ "sae_abs": float(sae_effect.mean()),
195
+ "random_abs": float(random_effect.mean()),
196
+ "ratio": float(
197
+ sae_effect.mean() / max(float(random_effect.mean()), 1e-12)
198
+ ),
199
+ "paired_advantage": float((sae_effect - random_effect).mean()),
200
+ "ci": [float(low), float(high)],
201
+ "pvalue": float(
202
+ paired_sign_flip_pvalue(
203
+ sae_effect,
204
+ random_effect,
205
+ seed=seed + 1,
206
+ )
207
+ ),
208
+ "n_pairs": int(sae_effect.size),
209
  }
210
 
211
 
212
+ def _feature_set_stats(frame: pd.DataFrame, *, seed: int) -> dict:
213
+ metric = _effect_column(frame)
214
+ sae = (
215
+ frame[frame["condition"] == "sae_feature_set"]
216
+ .groupby("task_id", as_index=False)[metric]
217
+ .first()
218
+ .rename(columns={metric: "sae"})
 
 
 
 
 
 
 
 
219
  )
220
+ random = (
221
+ frame[frame["condition"] == "random_norm_matched"]
222
+ .assign(
223
+ _abs=lambda data: np.abs(
224
+ pd.to_numeric(data[metric], errors="coerce")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  )
226
+ )
227
+ .groupby("task_id", as_index=False)["_abs"]
228
+ .mean()
229
+ .rename(columns={"_abs": "random"})
230
+ )
231
+ paired = sae.merge(random, on="task_id")
232
+ sae_effect = np.abs(paired["sae"].to_numpy(float))
233
+ random_effect = paired["random"].to_numpy(float)
234
 
235
+ if sae_effect.size == 0:
236
+ return {}
 
237
 
238
+ low, high = paired_bootstrap_difference_ci(
239
+ sae_effect,
240
+ random_effect,
241
+ seed=seed,
242
+ )
243
+ return {
244
+ "sae_abs": float(sae_effect.mean()),
245
+ "random_abs": float(random_effect.mean()),
246
+ "ratio": float(
247
+ sae_effect.mean() / max(float(random_effect.mean()), 1e-12)
248
+ ),
249
+ "advantage": float((sae_effect - random_effect).mean()),
250
+ "ci": [float(low), float(high)],
251
+ "pvalue": float(
252
+ paired_sign_flip_pvalue(
253
+ sae_effect,
254
+ random_effect,
255
+ seed=seed + 1,
256
+ )
257
+ ),
258
+ "n_tasks": int(sae_effect.size),
259
+ }
260
 
261
+
262
+ def _save_figure(fig: plt.Figure, path: Path) -> None:
263
+ fig.tight_layout()
264
+ fig.savefig(path, dpi=160)
265
+ plt.close(fig)
266
+
267
+
268
+ def _save_plots(
269
+ artifact_dir: Path,
270
+ selected: pd.DataFrame,
271
+ layers: pd.DataFrame,
272
+ max_active: pd.DataFrame,
273
+ feature_sets: pd.DataFrame | None,
274
+ position: pd.DataFrame,
275
+ study: pd.DataFrame,
276
+ ) -> None:
277
+ fig_dir = artifact_dir / "figures"
278
+ fig_dir.mkdir(parents=True, exist_ok=True)
279
+
280
+ fig = plt.figure(figsize=(7.5, 4.2))
281
+ ax = fig.add_subplot(111)
282
+ ordered = selected.sort_values("auroc")
283
+ ax.barh(ordered["concept"], ordered["auroc"])
284
+ ax.axvline(0.5, linewidth=1, linestyle="--")
285
+ ax.set_xlabel("Held-out AUROC")
286
+ ax.set_title("Selected SAE feature predictiveness")
287
+ _save_figure(fig, fig_dir / "feature_auroc.png")
288
+
289
+ fig = plt.figure(figsize=(7.0, 4.2))
290
+ ax = fig.add_subplot(111)
291
+ ax.plot(
292
+ layers["layer"],
293
+ layers["linear_probe_macro_auroc"],
294
+ marker="o",
295
+ label="Linear probe AUROC",
296
+ )
297
+ ax.plot(
298
+ layers["layer"],
299
+ layers["reconstruction_cosine"],
300
+ marker="o",
301
+ label="SAE reconstruction cosine",
302
+ )
303
+ ax.set_xlabel("Layer")
304
+ ax.set_ylim(0, 1.05)
305
+ ax.set_title("Layer-wise representation diagnostics")
306
+ ax.legend()
307
+ _save_figure(fig, fig_dir / "layer_diagnostics.png")
308
+
309
+ metric = _effect_column(max_active)
310
+ grouped = (
311
+ max_active.groupby(["intervention", "condition"])[metric]
312
+ .apply(lambda values: float(np.mean(np.abs(values))))
313
+ .reset_index(name="mean_abs_effect")
314
+ )
315
+ pivot = grouped.pivot(
316
+ index="intervention",
317
+ columns="condition",
318
+ values="mean_abs_effect",
319
+ )
320
+ fig = plt.figure(figsize=(7, 4.2))
321
+ ax = fig.add_subplot(111)
322
+ pivot.plot(kind="bar", ax=ax)
323
+ ax.set_ylabel("Mean |Δ mean log p/token|")
324
+ ax.set_title("Max-active SAE edits vs norm-matched controls")
325
+ ax.tick_params(axis="x", rotation=0)
326
+ _save_figure(fig, fig_dir / "causal_effects.png")
327
+
328
+ if feature_sets is not None and not feature_sets.empty:
329
+ feature_set_metric = _effect_column(feature_sets)
330
+ grouped_sets = (
331
+ feature_sets.groupby(["set_size", "condition"])[feature_set_metric]
332
+ .apply(lambda values: float(np.mean(np.abs(values))))
333
+ .reset_index(name="mean_abs_effect")
334
  )
335
+ set_pivot = grouped_sets.pivot(
336
+ index="set_size",
337
+ columns="condition",
338
+ values="mean_abs_effect",
339
+ )
340
+ fig = plt.figure(figsize=(7, 4.2))
341
+ ax = fig.add_subplot(111)
342
+ set_pivot.plot(kind="line", marker="o", ax=ax)
343
+ ax.set_xlabel("Jointly ablated feature count")
344
+ ax.set_ylabel("Mean |Δ mean log p/token|")
345
+ ax.set_title("Final-token feature-set diagnostic")
346
+ _save_figure(fig, fig_dir / "feature_set_effects.png")
347
+
348
+ overall = position[position["concept"] == "__all__"].copy()
349
+ policy_order = ["final_token", "max_feature_activation"]
350
+ overall["position_policy"] = pd.Categorical(
351
+ overall["position_policy"],
352
+ categories=policy_order,
353
+ ordered=True,
354
+ )
355
+ overall = overall.sort_values("position_policy")
356
+ position_plot = pd.DataFrame(
357
+ {
358
+ "Policy": ["Final token", "Max feature activation"],
359
+ "SAE effect": overall["target_sae_abs_mean"].to_numpy(float),
360
+ "Random control": overall["target_random_abs_mean"].to_numpy(float),
361
+ }
362
+ )
363
+ fig = plt.figure(figsize=(7.2, 4.4))
364
+ ax = fig.add_subplot(111)
365
+ x_positions = np.arange(len(position_plot))
366
+ width = 0.34
367
+ ax.bar(
368
+ x_positions - width / 2,
369
+ position_plot["SAE effect"],
370
+ width,
371
+ label="SAE effect",
372
+ )
373
+ ax.bar(
374
+ x_positions + width / 2,
375
+ position_plot["Random control"],
376
+ width,
377
+ label="Random control",
378
+ )
379
+ ax.set_xticks(x_positions, position_plot["Policy"])
380
+ ax.set_ylabel("Task-level mean |Δ mean log p/token|")
381
+ ax.set_title("Causal position sensitivity")
382
+ ax.legend()
383
+ _save_figure(fig, fig_dir / "causal_position_sensitivity.png")
384
+
385
+ if not study.empty:
386
+ fig = plt.figure(figsize=(7.2, 4.6))
387
+ ax = fig.add_subplot(111)
388
+ ax.scatter(
389
+ study["heldout_auroc"],
390
+ study["max_active_target_specificity_ratio"],
391
  )
392
+ for row in study.itertuples():
393
+ ax.annotate(
394
+ str(row.concept),
395
+ (row.heldout_auroc, row.max_active_target_specificity_ratio),
396
+ fontsize=8,
397
+ )
398
+ ax.set_xlabel("Held-out feature AUROC")
399
+ ax.set_ylabel("Max-active target specificity ratio")
400
+ ax.set_title("Association evidence vs max-active causality")
401
+ _save_figure(fig, fig_dir / "association_vs_causality.png")
402
+
403
+
404
+ def _coverage(
405
+ frame: pd.DataFrame,
406
+ column: str,
407
+ fallback: str = "feature_activation",
408
+ ) -> float:
409
+ name = column if column in frame.columns else fallback
410
+ values = pd.to_numeric(frame[name], errors="coerce").fillna(0)
411
+ return float((values > 0).mean())
412
+
413
+
414
+ def _build_interpretation(
415
+ *,
416
+ max_stats: dict,
417
+ final_coverage: float,
418
+ max_coverage: float,
419
+ ) -> str:
420
+ strong = (
421
+ max_stats["ratio"] >= 1.5
422
+ and max_stats["ci"][0] > 0
423
+ and max_stats["pvalue"] < 0.05
424
+ )
425
+ if strong:
426
  interpretation = (
427
+ "Max-active interventions produced larger task-level target effects than "
428
+ "norm-matched random controls with paired uncertainty excluding zero. "
429
+ "Predictive SAE features therefore show causal specificity when intervened "
430
+ "where the selected feature is actually represented, while the final-token "
431
+ "baseline quantifies sensitivity to intervention location."
432
  )
433
+ elif max_stats["ratio"] >= 1.5:
434
  interpretation = (
435
+ "Max-active interventions had a larger point-estimate effect than norm-matched "
436
+ "random controls, but task-level paired uncertainty did not support a strong "
437
+ "significance claim. The result is therefore reported as suggestive causal "
438
+ "specificity rather than conclusive evidence."
439
  )
440
  else:
441
  interpretation = (
442
+ "Held-out feature predictiveness was strong, but max-active causal effects were "
443
+ "only modest relative to norm-matched random controls. FeatureLens therefore "
444
+ "separates predictive association from causal control rather than treating them "
445
+ "as interchangeable."
446
  )
447
 
448
+ if max_coverage > final_coverage + 0.1:
449
+ interpretation += (
450
+ " Moving from the final prompt token to the feature's maximum-activation token "
451
+ f"increased intervention coverage from {final_coverage:.1%} to "
452
+ f"{max_coverage:.1%}, showing that causal conclusions depend materially on "
453
+ "where the representation is tested."
454
+ )
455
+ return interpretation
456
+
457
+
458
+ def _build_report_lines(
459
+ *,
460
+ headline: str,
461
+ interpretation: str,
462
+ highlights: list[str],
463
+ feature_sets: pd.DataFrame | None,
464
+ study: pd.DataFrame,
465
+ ) -> list[str]:
466
+ lines = [
467
+ "# FeatureLens experiment report",
468
+ "",
469
+ "## Research question",
470
+ "",
471
+ "**Do sparse features that predict a concept also causally influence model behaviour?**",
472
+ "",
473
+ "## Executive summary",
474
+ "",
475
+ headline,
476
+ "",
477
+ interpretation,
478
+ "",
479
+ "## Key measurements",
480
+ "",
481
+ *[f"- {item}" for item in highlights],
482
+ "",
483
+ "## Experimental design",
484
+ "",
485
+ "- Model: Qwen3-1.7B-Base.",
486
+ "- SAEs: Qwen-Scope residual-stream TopK SAEs at configured early/middle/late layers.",
487
+ "- Discovery evidence: prompt-wide maximum SAE activation across non-padding tokens; final-token activations are saved separately.",
488
+ "- Split discipline: paraphrase groups remain entirely in train or held-out test.",
489
+ "- Feature selection: training-split AUROC plus activation contrast; held-out AUROC/F1 are reported separately.",
490
+ "- Causal position policies: final prompt token and maximum selected-feature activation within the prompt. Max-active positions are selected from SAE activation only, never from behavioral outcomes.",
491
+ "- Primary causal statistical unit: causal task. Ablation and 2× amplification are averaged within task before paired bootstrap/sign-flip inference.",
492
+ "- Negative control: deterministic norm-matched random residual directions.",
493
+ "- Primary target metric: exact full continuation mean log probability per token under teacher forcing.",
494
+ "- Coverage and conditional-on-active effect strength are reported separately.",
495
+ "- Feature-set analysis remains a final-token diagnostic and is not conflated with the max-active single-feature study.",
496
+ "",
497
+ "## Figures",
498
+ "",
499
+ "![Feature AUROC](figures/feature_auroc.png)",
500
+ "",
501
+ "![Layer diagnostics](figures/layer_diagnostics.png)",
502
+ "",
503
+ "![Causal position sensitivity](figures/causal_position_sensitivity.png)",
504
+ "",
505
+ "![Max-active causal effects](figures/causal_effects.png)",
506
+ ]
507
+
508
+ if feature_sets is not None and not feature_sets.empty:
509
+ lines.extend(
510
+ [
511
+ "",
512
+ "![Feature-set diagnostic](figures/feature_set_effects.png)",
513
+ ]
514
+ )
515
+
516
+ if not study.empty:
517
+ lines.extend(
518
+ [
519
+ "",
520
+ "![Association vs causality](figures/association_vs_causality.png)",
521
+ "",
522
+ "## Position sensitivity",
523
+ "",
524
+ "The final-token policy asks whether the selected feature matters at the conventional last-prompt-token intervention site. The max-active policy asks whether it matters where that same feature is most strongly represented in the prompt. Reporting both prevents low final-token coverage from being mistaken for evidence that a predictive feature is globally non-causal.",
525
+ "",
526
+ "## Association vs causality across concepts",
527
+ "",
528
+ "Cross-concept correlations use max-active random-normalized specificity and are descriptive because the study has seven controlled concepts.",
529
+ ]
530
+ )
531
+
532
+ lines.extend(
533
+ [
534
+ "",
535
+ "## Interpretation guardrails",
536
+ "",
537
+ "High held-out AUROC is correlational evidence. Causal claims require downstream changes relative to norm-matched random controls. Max-active positions are chosen without reference to behavioral effect size. Task-level uncertainty treats ablation and amplification on the same causal prompt as repeated interventions, not independent experimental units.",
538
+ "",
539
+ "## Reproducibility",
540
+ "",
541
+ "Run `python -m experiments.run_all --resume` for a fresh full study. For an existing v0.15 final-token study, run the v0.16 causal addendum notebook; it preserves the baseline, computes only max-active causal rows, and reruns CPU analysis/reporting.",
542
+ "",
543
+ ]
544
+ )
545
+ return lines
546
+
547
+
548
+ def main() -> None:
549
+ args = parse_args()
550
+ artifact_dir = args.artifact_dir
551
+
552
+ catalog = pd.read_csv(artifact_dir / "feature_catalog.csv")
553
+ layers = pd.read_csv(artifact_dir / "layer_metrics.csv")
554
+ stability = pd.read_csv(artifact_dir / "stability.csv")
555
+ final = pd.read_csv(_causal_file(artifact_dir, "final_token"))
556
+ max_active = pd.read_csv(
557
+ _causal_file(artifact_dir, "max_feature_activation")
558
+ )
559
+
560
+ feature_set_path = artifact_dir / "feature_set_results.csv"
561
+ feature_sets = (
562
+ pd.read_csv(feature_set_path) if feature_set_path.exists() else None
563
+ )
564
+
565
+ study_path = artifact_dir / "study_feature_summary.csv"
566
+ study = pd.read_csv(study_path) if study_path.exists() else pd.DataFrame()
567
+ position = pd.read_csv(artifact_dir / "causal_position_summary.csv")
568
+
569
+ study_summary_path = artifact_dir / "study_summary.json"
570
+ if study_summary_path.exists():
571
+ study_summary = json.loads(study_summary_path.read_text())
572
+ else:
573
+ study_summary = {}
574
+
575
+ selected = _selected_features(catalog)
576
+ _save_plots(
577
+ artifact_dir,
578
+ selected,
579
+ layers,
580
+ max_active,
581
+ feature_sets,
582
+ position,
583
+ study,
584
+ )
585
+
586
+ mean_auc = float(selected["auroc"].mean())
587
+ median_auc = float(selected["auroc"].median())
588
+ auc_low, auc_high = bootstrap_mean_ci(
589
+ selected["auroc"].to_numpy(),
590
+ seed=42,
591
+ )
592
+
593
+ best = layers.sort_values(
594
+ "linear_probe_macro_auroc",
595
+ ascending=False,
596
+ ).iloc[0]
597
+ mean_jaccard = float(stability["topk_jaccard"].mean())
598
+ mean_cosine = float(stability["sparse_cosine"].mean())
599
+
600
+ final_stats = _task_level_stats(final, seed=43)
601
+ final_active_stats = _task_level_stats(
602
+ final,
603
+ seed=44,
604
+ active_only=True,
605
+ )
606
+ max_stats = _task_level_stats(max_active, seed=45)
607
+ max_active_stats = _task_level_stats(
608
+ max_active,
609
+ seed=46,
610
+ active_only=True,
611
+ )
612
+
613
+ final_sae = final[final["condition"] == "sae_feature"]
614
+ max_sae = max_active[max_active["condition"] == "sae_feature"]
615
+ final_coverage = _coverage(
616
+ final_sae,
617
+ "feature_active_at_intervention",
618
+ )
619
+ anywhere_coverage = _coverage(
620
+ max_sae,
621
+ "feature_active_anywhere",
622
+ )
623
+ max_coverage = _coverage(
624
+ max_sae,
625
+ "feature_active_at_intervention",
626
+ )
627
+
628
+ set_summary: dict[int, dict] = {}
629
+ if feature_sets is not None and not feature_sets.empty:
630
+ sizes = sorted(int(value) for value in feature_sets["set_size"].unique())
631
+ for size in sizes:
632
+ subset = feature_sets[feature_sets["set_size"] == size]
633
+ set_summary[size] = _feature_set_stats(
634
+ subset,
635
+ seed=100 + size,
636
  )
637
 
638
+ interpretation = _build_interpretation(
639
+ max_stats=max_stats,
640
+ final_coverage=final_coverage,
641
+ max_coverage=max_coverage,
642
+ )
643
+
644
  headline = (
645
+ f"Selected SAE features averaged {mean_auc:.3f} held-out AUROC. "
646
+ f"Max-active interventions covered {max_coverage:.1%} of causal tasks and "
647
+ f"changed mean log p/token by {max_stats['sae_abs']:.3f} in absolute value "
648
+ f"on average versus {max_stats['random_abs']:.3f} for norm-matched random "
649
+ f"controls ({max_stats['ratio']:.2f}×)."
650
  )
651
+
652
  highlights = [
653
+ (
654
+ f"Median selected-feature held-out AUROC: {median_auc:.3f}; mean AUROC "
655
+ f"95% bootstrap CI [{auc_low:.3f}, {auc_high:.3f}]."
656
+ ),
657
+ (
658
+ f"Best residual linear-probe layer: {int(best['layer'])} with macro AUROC "
659
+ f"{float(best['linear_probe_macro_auroc']):.3f}."
660
+ ),
661
+ (
662
+ f"Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation "
663
+ f"cosine: {mean_cosine:.3f}."
664
+ ),
665
+ (
666
+ f"Feature coverage: final-token policy {final_coverage:.1%}; active "
667
+ f"anywhere in prompt {anywhere_coverage:.1%}; max-active intervention "
668
+ f"{max_coverage:.1%}."
669
+ ),
670
+ (
671
+ f"Final-token task-level SAE/random ratio: {final_stats['ratio']:.2f}×; "
672
+ f"paired advantage {final_stats['advantage']:+.4f}, 95% CI "
673
+ f"[{final_stats['ci'][0]:+.4f}, {final_stats['ci'][1]:+.4f}], "
674
+ f"sign-flip p={final_stats['pvalue']:.4f}."
675
+ ),
676
+ (
677
+ f"Max-active task-level SAE/random ratio: {max_stats['ratio']:.2f}×; "
678
+ f"paired advantage {max_stats['advantage']:+.4f}, 95% CI "
679
+ f"[{max_stats['ci'][0]:+.4f}, {max_stats['ci'][1]:+.4f}], "
680
+ f"sign-flip p={max_stats['pvalue']:.4f}."
681
+ ),
682
+ (
683
+ "Conditional on feature-active tasks, max-active SAE/random ratio: "
684
+ f"{max_active_stats['ratio']:.2f}× "
685
+ f"(n={max_active_stats['n_tasks']})."
686
+ ),
687
  ]
688
+
689
+ if set_summary:
690
+ max_size = max(set_summary)
691
+ set_stats = set_summary[max_size]
692
  highlights.append(
693
+ f"Final-token top-{max_size} joint ablation SAE/random ratio: "
694
+ f"{set_stats['ratio']:.2f}×; paired advantage "
695
+ f"{set_stats['advantage']:+.4f}, 95% CI "
696
+ f"[{set_stats['ci'][0]:+.4f}, {set_stats['ci'][1]:+.4f}], "
697
+ f"sign-flip p={set_stats['pvalue']:.4f}."
698
  )
699
 
700
+ correlations = study_summary.get("correlations", {})
701
+ target_corr = correlations.get(
702
+ "heldout_auroc_vs_max_active_target_specificity",
703
+ {},
704
+ )
705
+ js_corr = correlations.get(
706
+ "heldout_auroc_vs_max_active_js_specificity",
707
+ {},
708
+ )
709
+ if target_corr:
710
+ target_rho = float(target_corr.get("rho", float("nan")))
711
+ js_rho = float(js_corr.get("rho", float("nan")))
712
  highlights.extend(
713
  [
714
+ (
715
+ "Across seven concepts, held-out AUROC vs max-active target "
716
+ f"specificity Spearman ρ={target_rho:+.3f}; descriptive only."
717
+ ),
718
+ (
719
+ "Held-out AUROC vs max-active JS specificity Spearman "
720
+ f"ρ={js_rho:+.3f}; descriptive only."
721
+ ),
722
  ]
723
  )
724
 
725
  summary = {
726
+ "headline": headline,
727
+ "highlights": highlights,
728
+ "interpretation": interpretation,
729
+ "metrics": {
730
+ "mean_selected_feature_test_auroc": mean_auc,
731
+ "mean_selected_feature_test_auroc_bootstrap_ci_95": [
732
+ auc_low,
733
+ auc_high,
734
+ ],
735
+ "median_selected_feature_test_auroc": median_auc,
736
+ "best_linear_probe_layer": int(best["layer"]),
737
+ "best_linear_probe_macro_auroc": float(
738
+ best["linear_probe_macro_auroc"]
739
+ ),
740
+ "mean_paraphrase_topk_jaccard": mean_jaccard,
741
+ "mean_paraphrase_sparse_cosine": mean_cosine,
742
+ "final_token_feature_coverage": final_coverage,
743
+ "prompt_anywhere_feature_coverage": anywhere_coverage,
744
+ "max_active_feature_coverage": max_coverage,
745
+ "final_token_task_level": final_stats,
746
+ "final_token_active_only": final_active_stats,
747
+ "max_active_task_level": max_stats,
748
+ "max_active_active_only": max_active_stats,
749
+ "feature_set_results": {
750
+ str(key): value for key, value in set_summary.items()
751
+ },
752
+ "study_summary": study_summary,
753
  },
754
  }
755
+ (artifact_dir / "summary.json").write_text(
756
+ json.dumps(summary, indent=2),
757
+ encoding="utf-8",
758
+ )
759
 
760
+ report_lines = _build_report_lines(
761
+ headline=headline,
762
+ interpretation=interpretation,
763
+ highlights=highlights,
764
+ feature_sets=feature_sets,
765
+ study=study,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  )
767
+ report_path = artifact_dir / "report.md"
768
+ report_path.write_text("\n".join(report_lines), encoding="utf-8")
769
  print(headline)
770
+ print(f"Wrote {report_path}")
771
 
772
 
773
+ if __name__ == "__main__":
774
  main()
experiments/run_all.py CHANGED
@@ -80,12 +80,30 @@ def main() -> None:
80
  ],
81
  resume=args.resume,
82
  )
83
- causal_output = artifact_dir / 'causal_results.csv'
84
  run(
85
  'experiments.run_causal',
86
- outputs=[causal_output, causal_output.with_suffix(causal_output.suffix + '.complete')],
87
  resume=args.resume,
88
- extra_args=['--resume'] if args.resume else None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  )
90
  feature_set_output = artifact_dir / 'feature_set_results.csv'
91
  run(
 
80
  ],
81
  resume=args.resume,
82
  )
83
+ final_output = artifact_dir / 'causal_results_final_token.csv'
84
  run(
85
  'experiments.run_causal',
86
+ outputs=[final_output, final_output.with_suffix(final_output.suffix + '.complete')],
87
  resume=args.resume,
88
+ extra_args=[
89
+ '--position-policy', 'final_token',
90
+ '--output', str(final_output),
91
+ *(['--resume'] if args.resume else []),
92
+ ],
93
+ )
94
+ max_active_output = artifact_dir / 'causal_results_max_active.csv'
95
+ run(
96
+ 'experiments.run_causal',
97
+ outputs=[
98
+ max_active_output,
99
+ max_active_output.with_suffix(max_active_output.suffix + '.complete'),
100
+ ],
101
+ resume=args.resume,
102
+ extra_args=[
103
+ '--position-policy', 'max_feature_activation',
104
+ '--output', str(max_active_output),
105
+ *(['--resume'] if args.resume else []),
106
+ ],
107
  )
108
  feature_set_output = artifact_dir / 'feature_set_results.csv'
109
  run(
experiments/run_causal.py CHANGED
@@ -12,14 +12,17 @@ from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
12
  from featurelens.config import SETTINGS
13
  from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
14
  from featurelens.metrics import js_divergence_from_logits, sequence_logprob_summary
15
- from featurelens.sae import SAEStore
 
 
16
 
17
 
18
  def parse_args() -> argparse.Namespace:
19
  parser = argparse.ArgumentParser(description='Run held-out causal SAE interventions.')
20
  parser.add_argument('--tasks', type=Path, default=DATA_DIR / 'causal_tasks.jsonl')
21
  parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
22
- parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'causal_results.csv')
 
23
  parser.add_argument('--seed', type=int, default=42)
24
  parser.add_argument('--random-controls', type=int, default=8)
25
  parser.add_argument(
@@ -30,6 +33,10 @@ def parse_args() -> argparse.Namespace:
30
  return parser.parse_args()
31
 
32
 
 
 
 
 
33
 
34
 
35
  def _completion_marker(path: Path) -> Path:
@@ -85,8 +92,6 @@ def replace_hidden(output, hidden):
85
 
86
 
87
  def _make_capture_hook(capture: dict):
88
- """Bind a per-task capture dictionary before registering the hook."""
89
-
90
  def capture_hook(_module, _inp, output):
91
  if 'hidden' not in capture:
92
  capture['hidden'] = hidden_from_output(output).detach()
@@ -96,18 +101,17 @@ def _make_capture_hook(capture: dict):
96
 
97
  def _make_batch_edit_hook(
98
  applied: dict[str, bool],
99
- prompt_len: int,
100
  deltas: torch.Tensor,
101
  ):
102
- """Bind per-task edit state so hooks cannot capture a later loop iteration."""
103
-
104
  def batch_edit_hook(_module, _inp, output):
105
  if applied['done']:
106
  return output
107
  hidden = hidden_from_output(output)
108
  modified = hidden.clone()
109
- modified[:, prompt_len - 1, :] = (
110
- modified[:, prompt_len - 1, :] + deltas.to(hidden.device, hidden.dtype)
 
111
  )
112
  applied['done'] = True
113
  return replace_hidden(output, modified)
@@ -136,9 +140,45 @@ def make_random_controls(delta: torch.Tensor, seed: int, count: int) -> list[tor
136
  ]
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  @torch.inference_mode()
140
  def main() -> None:
141
  args = parse_args()
 
 
142
  set_seed(args.seed)
143
  tasks = load_jsonl(args.tasks)
144
  selected = load_selected_features(args.catalog)
@@ -183,7 +223,10 @@ def main() -> None:
183
  for task_idx, task in enumerate(tasks):
184
  task_id = str(task['id'])
185
  if args.resume and completed_counts.get(task_id, 0) == expected_rows_per_task:
186
- print(f"SKIP causal task {task_idx + 1}/{len(tasks)}: {task_id}", flush=True)
 
 
 
187
  continue
188
  if args.resume and completed_counts.get(task_id, 0):
189
  results = [row for row in results if str(row.get('task_id', '')) != task_id]
@@ -201,10 +244,9 @@ def main() -> None:
201
  raise RuntimeError(f"Target tokenization empty for task {task['id']}")
202
  target_ids = [int(x) for x in target_ids]
203
  full_inputs = append_target(prompt_inputs, target_ids)
 
204
  capture: dict = {}
205
- handle = model.model.layers[layer].register_forward_hook(
206
- _make_capture_hook(capture)
207
- )
208
  single_baseline_out = model(**full_inputs, use_cache=False)
209
  handle.remove()
210
  single_baseline_logits = single_baseline_out.logits[0]
@@ -214,9 +256,24 @@ def main() -> None:
214
  target_ids=target_ids,
215
  )
216
 
217
- residual = capture['hidden'][0, prompt_len - 1]
218
- encoding = sae.encode(residual)
219
- original_activation = encoding.activation_for(feature_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
  specs = [
222
  ('ablate', InterventionSpec('ablate', 0.0)),
@@ -257,7 +314,7 @@ def main() -> None:
257
  repeated = {key: value.repeat(deltas.shape[0], 1) for key, value in full_inputs.items()}
258
  applied = {'done': False}
259
  hook = model.model.layers[layer].register_forward_hook(
260
- _make_batch_edit_hook(applied, prompt_len, deltas)
261
  )
262
  edited_out = model(**repeated, use_cache=False)
263
  hook.remove()
@@ -278,10 +335,14 @@ def main() -> None:
278
  baseline_rank = int((baseline_next > baseline_next[target_id]).sum().item()) + 1
279
  baseline_top1 = int(torch.argmax(baseline_next).item())
280
 
281
- for row_idx, (intervention_name, condition, control_id, _spec, applied_delta, delta_activation) in enumerate(
282
- condition_meta,
283
- start=1,
284
- ):
 
 
 
 
285
  modified_logits = edited_out.logits[row_idx]
286
  modified_next = modified_logits[prompt_len - 1]
287
  modified_prob = float(torch.softmax(modified_next.float(), dim=-1)[target_id].item())
@@ -305,7 +366,19 @@ def main() -> None:
305
  'feature_train_auroc': choice['train_auroc'],
306
  'feature_test_auroc': choice['test_auroc'],
307
  'feature_test_f1': choice['test_f1'],
 
 
 
 
 
 
 
308
  'feature_activation': original_activation,
 
 
 
 
 
309
  'intervention': intervention_name,
310
  'condition': condition,
311
  'control_id': control_id,
@@ -335,11 +408,15 @@ def main() -> None:
335
  }
336
  )
337
  _write_rows_atomic(args.output, results)
338
- print(f"Causal task {task_idx + 1}/{len(tasks)}: {concept}", flush=True)
 
 
 
 
339
 
340
  _write_rows_atomic(args.output, results)
341
  marker.write_text('complete\n', encoding='utf-8')
342
- print(f'Wrote {len(results)} causal intervention rows to {args.output}')
343
 
344
 
345
  if __name__ == '__main__':
 
12
  from featurelens.config import SETTINGS
13
  from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
14
  from featurelens.metrics import js_divergence_from_logits, sequence_logprob_summary
15
+ from featurelens.sae import SAEStore, SparseEncoding
16
+
17
+ POSITION_POLICIES = ('final_token', 'max_feature_activation')
18
 
19
 
20
  def parse_args() -> argparse.Namespace:
21
  parser = argparse.ArgumentParser(description='Run held-out causal SAE interventions.')
22
  parser.add_argument('--tasks', type=Path, default=DATA_DIR / 'causal_tasks.jsonl')
23
  parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
24
+ parser.add_argument('--output', type=Path, default=None)
25
+ parser.add_argument('--position-policy', choices=POSITION_POLICIES, default='final_token')
26
  parser.add_argument('--seed', type=int, default=42)
27
  parser.add_argument('--random-controls', type=int, default=8)
28
  parser.add_argument(
 
33
  return parser.parse_args()
34
 
35
 
36
+ def default_output(policy: str) -> Path:
37
+ if policy == 'final_token':
38
+ return ARTIFACT_DIR / 'causal_results_final_token.csv'
39
+ return ARTIFACT_DIR / 'causal_results_max_active.csv'
40
 
41
 
42
  def _completion_marker(path: Path) -> Path:
 
92
 
93
 
94
  def _make_capture_hook(capture: dict):
 
 
95
  def capture_hook(_module, _inp, output):
96
  if 'hidden' not in capture:
97
  capture['hidden'] = hidden_from_output(output).detach()
 
101
 
102
  def _make_batch_edit_hook(
103
  applied: dict[str, bool],
104
+ intervention_token_index: int,
105
  deltas: torch.Tensor,
106
  ):
 
 
107
  def batch_edit_hook(_module, _inp, output):
108
  if applied['done']:
109
  return output
110
  hidden = hidden_from_output(output)
111
  modified = hidden.clone()
112
+ modified[:, intervention_token_index, :] = (
113
+ modified[:, intervention_token_index, :]
114
+ + deltas.to(hidden.device, hidden.dtype)
115
  )
116
  applied['done'] = True
117
  return replace_hidden(output, modified)
 
140
  ]
141
 
142
 
143
+ def feature_activation_trace(encoding: SparseEncoding, feature_id: int) -> torch.Tensor:
144
+ """Return one TopK feature activation per encoded token."""
145
+ indices = encoding.indices
146
+ values = encoding.values
147
+ if indices.ndim != 2 or values.ndim != 2:
148
+ raise ValueError('Expected tokenwise sparse encoding with shape [tokens, top_k].')
149
+ mask = indices == int(feature_id)
150
+ return torch.where(mask, values, torch.zeros_like(values)).max(dim=-1).values
151
+
152
+
153
+ def choose_intervention_position(
154
+ token_activations: torch.Tensor,
155
+ *,
156
+ prompt_len: int,
157
+ position_policy: str,
158
+ ) -> tuple[int, float, bool]:
159
+ if prompt_len < 1:
160
+ raise ValueError('Prompt must contain at least one token.')
161
+ if position_policy == 'final_token':
162
+ index = prompt_len - 1
163
+ activation = float(token_activations[index].item())
164
+ return index, activation, activation > 0.0
165
+ if position_policy != 'max_feature_activation':
166
+ raise ValueError(f'Unknown position policy: {position_policy}')
167
+
168
+ max_activation, max_index = torch.max(token_activations[:prompt_len], dim=0)
169
+ activation = float(max_activation.item())
170
+ if activation <= 0.0:
171
+ # No selected feature is represented in TopK anywhere in the prompt.
172
+ # Keep a deterministic final-token location; the feature delta is zero.
173
+ return prompt_len - 1, 0.0, False
174
+ return int(max_index.item()), activation, True
175
+
176
+
177
  @torch.inference_mode()
178
  def main() -> None:
179
  args = parse_args()
180
+ if args.output is None:
181
+ args.output = default_output(args.position_policy)
182
  set_seed(args.seed)
183
  tasks = load_jsonl(args.tasks)
184
  selected = load_selected_features(args.catalog)
 
223
  for task_idx, task in enumerate(tasks):
224
  task_id = str(task['id'])
225
  if args.resume and completed_counts.get(task_id, 0) == expected_rows_per_task:
226
+ print(
227
+ f'SKIP {args.position_policy} causal task {task_idx + 1}/{len(tasks)}: {task_id}',
228
+ flush=True,
229
+ )
230
  continue
231
  if args.resume and completed_counts.get(task_id, 0):
232
  results = [row for row in results if str(row.get('task_id', '')) != task_id]
 
244
  raise RuntimeError(f"Target tokenization empty for task {task['id']}")
245
  target_ids = [int(x) for x in target_ids]
246
  full_inputs = append_target(prompt_inputs, target_ids)
247
+
248
  capture: dict = {}
249
+ handle = model.model.layers[layer].register_forward_hook(_make_capture_hook(capture))
 
 
250
  single_baseline_out = model(**full_inputs, use_cache=False)
251
  handle.remove()
252
  single_baseline_logits = single_baseline_out.logits[0]
 
256
  target_ids=target_ids,
257
  )
258
 
259
+ prompt_hidden = capture['hidden'][0, :prompt_len]
260
+ token_encoding = sae.encode(prompt_hidden)
261
+ token_activations = feature_activation_trace(token_encoding, feature_id)
262
+ final_token_activation = float(token_activations[prompt_len - 1].item())
263
+ max_activation_value, max_activation_index = torch.max(token_activations, dim=0)
264
+ max_prompt_activation = float(max_activation_value.item())
265
+ max_prompt_index = int(max_activation_index.item()) if max_prompt_activation > 0 else prompt_len - 1
266
+ active_anywhere = max_prompt_activation > 0.0
267
+
268
+ intervention_index, original_activation, active_at_intervention = choose_intervention_position(
269
+ token_activations,
270
+ prompt_len=prompt_len,
271
+ position_policy=args.position_policy,
272
+ )
273
+ prompt_token_ids = prompt_inputs['input_ids'][0]
274
+ intervention_token_text = tokenizer.decode([int(prompt_token_ids[intervention_index].item())])
275
+ max_prompt_token_text = tokenizer.decode([int(prompt_token_ids[max_prompt_index].item())])
276
+ final_token_text = tokenizer.decode([int(prompt_token_ids[prompt_len - 1].item())])
277
 
278
  specs = [
279
  ('ablate', InterventionSpec('ablate', 0.0)),
 
314
  repeated = {key: value.repeat(deltas.shape[0], 1) for key, value in full_inputs.items()}
315
  applied = {'done': False}
316
  hook = model.model.layers[layer].register_forward_hook(
317
+ _make_batch_edit_hook(applied, intervention_index, deltas)
318
  )
319
  edited_out = model(**repeated, use_cache=False)
320
  hook.remove()
 
335
  baseline_rank = int((baseline_next > baseline_next[target_id]).sum().item()) + 1
336
  baseline_top1 = int(torch.argmax(baseline_next).item())
337
 
338
+ for row_idx, (
339
+ intervention_name,
340
+ condition,
341
+ control_id,
342
+ _spec,
343
+ applied_delta,
344
+ delta_activation,
345
+ ) in enumerate(condition_meta, start=1):
346
  modified_logits = edited_out.logits[row_idx]
347
  modified_next = modified_logits[prompt_len - 1]
348
  modified_prob = float(torch.softmax(modified_next.float(), dim=-1)[target_id].item())
 
366
  'feature_train_auroc': choice['train_auroc'],
367
  'feature_test_auroc': choice['test_auroc'],
368
  'feature_test_f1': choice['test_f1'],
369
+ 'position_policy': args.position_policy,
370
+ 'intervention_token_index': intervention_index,
371
+ 'intervention_token_text': intervention_token_text,
372
+ 'final_token_index': prompt_len - 1,
373
+ 'final_token_text': final_token_text,
374
+ 'max_prompt_feature_token_index': max_prompt_index,
375
+ 'max_prompt_feature_token_text': max_prompt_token_text,
376
  'feature_activation': original_activation,
377
+ 'final_token_feature_activation': final_token_activation,
378
+ 'max_prompt_feature_activation': max_prompt_activation,
379
+ 'feature_active_at_intervention': int(active_at_intervention),
380
+ 'feature_active_at_final_token': int(final_token_activation > 0.0),
381
+ 'feature_active_anywhere': int(active_anywhere),
382
  'intervention': intervention_name,
383
  'condition': condition,
384
  'control_id': control_id,
 
408
  }
409
  )
410
  _write_rows_atomic(args.output, results)
411
+ print(
412
+ f'Causal {args.position_policy} task {task_idx + 1}/{len(tasks)}: '
413
+ f'{concept} @ token {intervention_index} (activation {original_activation:.4f})',
414
+ flush=True,
415
+ )
416
 
417
  _write_rows_atomic(args.output, results)
418
  marker.write_text('complete\n', encoding='utf-8')
419
+ print(f'Wrote {len(results)} {args.position_policy} causal rows to {args.output}')
420
 
421
 
422
  if __name__ == '__main__':
experiments/run_causal_addendum.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from experiments.common import ARTIFACT_DIR
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+
15
+
16
+ def parse_args() -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(
18
+ description='Run only the v0.16 max-active causal addendum and CPU reanalysis.'
19
+ )
20
+ parser.add_argument('--artifact-dir', type=Path, default=ARTIFACT_DIR)
21
+ parser.add_argument('--resume', action='store_true')
22
+ parser.add_argument('--random-controls', type=int, default=8)
23
+ return parser.parse_args()
24
+
25
+
26
+ def run(command: list[str]) -> None:
27
+ print('\n$', ' '.join(command), flush=True)
28
+ subprocess.run(command, cwd=ROOT, check=True)
29
+
30
+
31
+ def migrate_final_token_baseline(source: Path, destination: Path) -> None:
32
+ """Preserve a v0.15 causal CSV while adding v0.16 position-policy metadata."""
33
+ frame = pd.read_csv(source)
34
+ if 'position_policy' not in frame.columns:
35
+ active = pd.to_numeric(frame['feature_activation'], errors='coerce').fillna(0.0) > 0.0
36
+ frame['position_policy'] = 'final_token'
37
+ frame['intervention_token_index'] = -1
38
+ frame['intervention_token_text'] = ''
39
+ frame['final_token_index'] = -1
40
+ frame['final_token_text'] = ''
41
+ frame['max_prompt_feature_token_index'] = -1
42
+ frame['max_prompt_feature_token_text'] = ''
43
+ frame['final_token_feature_activation'] = pd.to_numeric(
44
+ frame['feature_activation'], errors='coerce'
45
+ ).fillna(0.0)
46
+ frame['max_prompt_feature_activation'] = np.nan
47
+ frame['feature_active_at_intervention'] = active.astype(int)
48
+ frame['feature_active_at_final_token'] = active.astype(int)
49
+ # Prompt-wide activity cannot be reconstructed from the legacy final-token CSV.
50
+ frame['feature_active_anywhere'] = np.nan
51
+ destination.parent.mkdir(parents=True, exist_ok=True)
52
+ temporary = destination.with_suffix(destination.suffix + '.tmp')
53
+ frame.to_csv(temporary, index=False)
54
+ temporary.replace(destination)
55
+
56
+
57
+ def main() -> None:
58
+ args = parse_args()
59
+ artifact_dir = args.artifact_dir
60
+ legacy = artifact_dir / 'causal_results.csv'
61
+ final = artifact_dir / 'causal_results_final_token.csv'
62
+ if not final.exists():
63
+ if not legacy.exists():
64
+ raise SystemExit(
65
+ 'Missing final-token baseline. Expected artifacts/causal_results.csv or '
66
+ 'artifacts/causal_results_final_token.csv from the completed v0.15 study.'
67
+ )
68
+ migrate_final_token_baseline(legacy, final)
69
+ print(f'Preserved v0.15 baseline as {final.name}.')
70
+ elif 'position_policy' not in pd.read_csv(final, nrows=1).columns:
71
+ migrate_final_token_baseline(final, final)
72
+ print(f'Upgraded {final.name} with v0.16 position metadata.')
73
+
74
+ output = artifact_dir / 'causal_results_max_active.csv'
75
+ causal_command = [
76
+ sys.executable, '-m', 'experiments.run_causal',
77
+ '--position-policy', 'max_feature_activation',
78
+ '--output', str(output),
79
+ '--random-controls', str(args.random_controls),
80
+ ]
81
+ if args.resume:
82
+ causal_command.append('--resume')
83
+ run(causal_command)
84
+ run([sys.executable, '-m', 'experiments.analyze_study'])
85
+ run([sys.executable, '-m', 'experiments.make_report'])
86
+ run([sys.executable, '-m', 'scripts.validate_artifacts'])
87
+ print('\nFeatureLens v0.16 causal addendum complete.')
88
+
89
+
90
+ if __name__ == '__main__':
91
+ main()
featurelens/stats.py CHANGED
@@ -57,8 +57,15 @@ def paired_sign_flip_pvalue(
57
  *,
58
  n_permutations: int = 20000,
59
  seed: int = 42,
 
60
  ) -> float:
61
- """Two-sided paired randomization test for a non-zero mean difference."""
 
 
 
 
 
 
62
  x = np.asarray(a, dtype=float)
63
  y = np.asarray(b, dtype=float)
64
  mask = np.isfinite(x) & np.isfinite(y)
@@ -68,14 +75,36 @@ def paired_sign_flip_pvalue(
68
  observed = abs(float(diff.mean()))
69
  if observed == 0.0:
70
  return 1.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  rng = np.random.default_rng(seed)
72
  extreme = 0
73
  batch = 2000
74
  remaining = int(n_permutations)
75
  while remaining > 0:
76
  n = min(batch, remaining)
77
- signs = rng.choice(np.array([-1.0, 1.0]), size=(n, diff.size))
78
- permuted = np.abs((signs * diff).mean(axis=1))
79
  extreme += int(np.count_nonzero(permuted >= observed))
80
  remaining -= n
81
  return float((extreme + 1) / (int(n_permutations) + 1))
 
57
  *,
58
  n_permutations: int = 20000,
59
  seed: int = 42,
60
+ exact_max_nonzero_pairs: int = 18,
61
  ) -> float:
62
+ """Two-sided paired sign-flip test for a non-zero mean difference.
63
+
64
+ For small effective samples the test is enumerated exactly. Zero-difference
65
+ pairs are removed before deciding whether exact enumeration is feasible;
66
+ their sign cannot change the statistic. Larger samples use a deterministic
67
+ Monte-Carlo approximation.
68
+ """
69
  x = np.asarray(a, dtype=float)
70
  y = np.asarray(b, dtype=float)
71
  mask = np.isfinite(x) & np.isfinite(y)
 
75
  observed = abs(float(diff.mean()))
76
  if observed == 0.0:
77
  return 1.0
78
+
79
+ nonzero = diff[np.abs(diff) > 0.0]
80
+ if nonzero.size == 0:
81
+ return 1.0
82
+
83
+ # Preserve the original mean denominator: zero-difference pairs contribute
84
+ # to n but never to a sign-flipped numerator.
85
+ denominator = float(diff.size)
86
+ if nonzero.size <= int(exact_max_nonzero_pairs):
87
+ count = 1 << int(nonzero.size)
88
+ extreme = 0
89
+ for bits in range(count):
90
+ signs = np.fromiter(
91
+ (1.0 if bits & (1 << idx) else -1.0 for idx in range(nonzero.size)),
92
+ dtype=float,
93
+ count=nonzero.size,
94
+ )
95
+ statistic = abs(float(np.sum(signs * nonzero) / denominator))
96
+ if statistic >= observed - 1e-15:
97
+ extreme += 1
98
+ return float(extreme / count)
99
+
100
  rng = np.random.default_rng(seed)
101
  extreme = 0
102
  batch = 2000
103
  remaining = int(n_permutations)
104
  while remaining > 0:
105
  n = min(batch, remaining)
106
+ signs = rng.choice(np.array([-1.0, 1.0]), size=(n, nonzero.size))
107
+ permuted = np.abs((signs * nonzero).sum(axis=1) / denominator)
108
  extreme += int(np.count_nonzero(permuted >= observed))
109
  remaining -= n
110
  return float((extreme + 1) / (int(n_permutations) + 1))
featurelens/study.py CHANGED
@@ -13,11 +13,13 @@ class OfflineStudy:
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
  )
@@ -55,30 +57,34 @@ class OfflineStudy:
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:
 
13
  'summary.json',
14
  'study_summary.json',
15
  'study_feature_summary.csv',
16
+ 'causal_position_summary.csv',
17
  'selection_stability.csv',
18
  'feature_catalog.csv',
19
  'layer_metrics.csv',
20
  'stability.csv',
21
+ 'causal_results_final_token.csv',
22
+ 'causal_results_max_active.csv',
23
  'feature_set_results.csv',
24
  'report.md',
25
  )
 
57
  suffix = '…' if len(self.missing) > 6 else ''
58
  return (
59
  '### Offline study not materialized yet\n\n'
60
+ 'The live workbench is usable now, but the finalized position-sensitivity study artifacts '
61
+ f'have not been committed. Missing: {missing}{suffix}\n\n'
62
+ 'For a fresh study run `python -m experiments.run_all --resume`. If the v0.15 final-token '
63
+ 'study already exists, use the v0.16 causal-addendum notebook or '
64
+ '`python -m experiments.run_causal_addendum --resume` to add max-active causal results '
65
+ 'without recollecting discovery activations.'
 
66
  )
67
 
68
  summary = self._json('summary.json')
69
  study = self._json('study_summary.json')
70
  correlations = study.get('correlations', {})
71
+ target_corr = correlations.get('heldout_auroc_vs_max_active_target_specificity', {})
72
+ js_corr = correlations.get('heldout_auroc_vs_max_active_js_specificity', {})
73
  return (
74
  '### Offline study results\n\n'
75
  f"{summary.get('headline', 'Benchmark completed.')}\n\n"
76
  f"{summary.get('interpretation', '')}\n\n"
77
+ '**Position sensitivity**\n\n'
78
+ f"- Final-token feature coverage: **{float(study.get('final_token_feature_coverage', float('nan'))):.1%}**.\n"
79
+ f"- Max-active feature coverage: **{float(study.get('max_active_feature_coverage', float('nan'))):.1%}**.\n"
80
+ f"- Final-token target specificity: **{float(study.get('final_token_target_specificity_ratio', float('nan'))):.2f}×**.\n"
81
+ f"- Max-active target specificity: **{float(study.get('max_active_target_specificity_ratio', float('nan'))):.2f}×**.\n\n"
82
  '**Study-level diagnostics**\n\n'
83
  f"- Selected-feature median resample support: **{float(study.get('median_selected_feature_resample_support', float('nan'))):.1%}**.\n"
84
+ f"- Held-out AUROC ↔ max-active target-specificity Spearman ρ: **{float(target_corr.get('rho', float('nan'))):+.3f}** (n={int(target_corr.get('n', 0))}).\n"
85
+ f"- Held-out AUROC ↔ max-active JS-specificity Spearman ρ: **{float(js_corr.get('rho', float('nan'))):+.3f}** (n={int(js_corr.get('n', 0))}).\n\n"
86
+ 'Cross-concept correlations are descriptive; per-concept causal evidence remains anchored to '
87
+ 'norm-matched random controls.'
88
  )
89
 
90
  def readiness_markdown(self) -> str:
notebooks/FeatureLens_Causal_Addendum_Colab.ipynb ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# FeatureLens v0.16 causal-position addendum\n",
8
+ "\n",
9
+ "Use this notebook **only after the v0.15 full offline study completed**. It preserves that final-token causal baseline and runs the smaller max-feature-activation causal addendum.\n",
10
+ "\n",
11
+ "It does **not** recollect the 224-prompt activation matrices, refit probes/features, rerun candidate stability, or rerun the 1/3/5 feature-set benchmark.\n"
12
+ ]
13
+ },
14
+ {
15
+ "cell_type": "code",
16
+ "execution_count": null,
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "# 1. Verify the Colab GPU runtime.\n",
21
+ "!nvidia-smi\n",
22
+ "import torch\n",
23
+ "print(\"CUDA available:\", torch.cuda.is_available())\n",
24
+ "if not torch.cuda.is_available():\n",
25
+ " raise RuntimeError(\"Enable a GPU runtime before continuing.\")\n",
26
+ "print(\"GPU:\", torch.cuda.get_device_name(0))\n",
27
+ "print(\"VRAM GiB:\", torch.cuda.get_device_properties(0).total_memory / 1024**3)\n"
28
+ ]
29
+ },
30
+ {
31
+ "cell_type": "code",
32
+ "execution_count": null,
33
+ "metadata": {},
34
+ "outputs": [],
35
+ "source": [
36
+ "# 2. Mount Google Drive.\n",
37
+ "from google.colab import drive\n",
38
+ "drive.mount(\"/content/drive\")\n"
39
+ ]
40
+ },
41
+ {
42
+ "cell_type": "code",
43
+ "execution_count": null,
44
+ "metadata": {},
45
+ "outputs": [],
46
+ "source": [
47
+ "# 3. Configuration \u2014 edit REPO_URL if needed.\n",
48
+ "from pathlib import Path\n",
49
+ "\n",
50
+ "REPO_URL = \"PASTE_YOUR_GIT_REPO_URL_HERE\"\n",
51
+ "BRANCH = \"main\"\n",
52
+ "SOURCE_RUN_NAME = \"FeatureLens_offline_v015\"\n",
53
+ "ADDENDUM_RUN_NAME = \"FeatureLens_offline_v016\"\n",
54
+ "\n",
55
+ "REPO_DIR = Path(\"/content/FeatureLens\")\n",
56
+ "DRIVE_ROOT = Path(\"/content/drive/MyDrive\")\n",
57
+ "SOURCE_ARTIFACTS = DRIVE_ROOT / SOURCE_RUN_NAME / \"artifacts\"\n",
58
+ "ADDENDUM_ROOT = DRIVE_ROOT / ADDENDUM_RUN_NAME\n",
59
+ "ADDENDUM_ARTIFACTS = ADDENDUM_ROOT / \"artifacts\"\n",
60
+ "LOG_PATH = ADDENDUM_ROOT / \"causal_addendum.log\"\n",
61
+ "\n",
62
+ "if REPO_URL.startswith(\"PASTE_\"):\n",
63
+ " raise ValueError(\"Set REPO_URL to your FeatureLens Git repository URL first.\")\n",
64
+ "if not SOURCE_ARTIFACTS.exists():\n",
65
+ " raise FileNotFoundError(\n",
66
+ " f\"Could not find the completed v0.15 artifacts at {SOURCE_ARTIFACTS}. \"\n",
67
+ " \"Change SOURCE_RUN_NAME if your previous Drive folder used another name.\"\n",
68
+ " )\n",
69
+ "ADDENDUM_ARTIFACTS.mkdir(parents=True, exist_ok=True)\n",
70
+ "print(\"Source:\", SOURCE_ARTIFACTS)\n",
71
+ "print(\"Addendum:\", ADDENDUM_ARTIFACTS)\n"
72
+ ]
73
+ },
74
+ {
75
+ "cell_type": "code",
76
+ "execution_count": null,
77
+ "metadata": {},
78
+ "outputs": [],
79
+ "source": [
80
+ "# 4. Clone or refresh the v0.16 FeatureLens source.\n",
81
+ "import subprocess, shutil\n",
82
+ "\n",
83
+ "if not REPO_DIR.exists():\n",
84
+ " subprocess.run([\"git\", \"clone\", \"--branch\", BRANCH, \"--single-branch\", REPO_URL, str(REPO_DIR)], check=True)\n",
85
+ "else:\n",
86
+ " subprocess.run([\"git\", \"-C\", str(REPO_DIR), \"fetch\", \"origin\", BRANCH], check=True)\n",
87
+ " subprocess.run([\"git\", \"-C\", str(REPO_DIR), \"checkout\", BRANCH], check=True)\n",
88
+ " subprocess.run([\"git\", \"-C\", str(REPO_DIR), \"pull\", \"--ff-only\", \"origin\", BRANCH], check=True)\n",
89
+ "print(\"Commit:\", subprocess.check_output([\"git\", \"-C\", str(REPO_DIR), \"rev-parse\", \"--short\", \"HEAD\"], text=True).strip())\n"
90
+ ]
91
+ },
92
+ {
93
+ "cell_type": "code",
94
+ "execution_count": null,
95
+ "metadata": {},
96
+ "outputs": [],
97
+ "source": [
98
+ "# 5. Install runtime dependencies.\n",
99
+ "import subprocess, sys\n",
100
+ "subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"-r\", str(REPO_DIR / \"requirements.txt\")], check=True)\n"
101
+ ]
102
+ },
103
+ {
104
+ "cell_type": "code",
105
+ "execution_count": null,
106
+ "metadata": {},
107
+ "outputs": [],
108
+ "source": [
109
+ "# 6. Seed the addendum folder with only the small v0.15 study outputs.\n",
110
+ "# Large activation caches are intentionally NOT copied.\n",
111
+ "import shutil\n",
112
+ "\n",
113
+ "for path in SOURCE_ARTIFACTS.rglob(\"*\"):\n",
114
+ " if not path.is_file():\n",
115
+ " continue\n",
116
+ " rel = path.relative_to(SOURCE_ARTIFACTS)\n",
117
+ " if rel.parts and rel.parts[0] == \"activations\":\n",
118
+ " continue\n",
119
+ " if path.name.endswith((\".complete\", \".tmp\")):\n",
120
+ " continue\n",
121
+ " destination = ADDENDUM_ARTIFACTS / rel\n",
122
+ " if not destination.exists():\n",
123
+ " destination.parent.mkdir(parents=True, exist_ok=True)\n",
124
+ " shutil.copy2(path, destination)\n",
125
+ "\n",
126
+ "required = [\"feature_catalog.csv\", \"layer_metrics.csv\", \"stability.csv\", \"selection_stability.csv\", \"feature_set_results.csv\"]\n",
127
+ "missing = [name for name in required if not (ADDENDUM_ARTIFACTS / name).exists()]\n",
128
+ "if missing:\n",
129
+ " raise RuntimeError(f\"Previous study is missing required small artifacts: {missing}\")\n",
130
+ "if not ((ADDENDUM_ARTIFACTS / \"causal_results.csv\").exists() or (ADDENDUM_ARTIFACTS / \"causal_results_final_token.csv\").exists()):\n",
131
+ " raise RuntimeError(\"Previous study is missing its final-token causal baseline.\")\n",
132
+ "print(\"Small v0.15 artifacts copied; large activations were skipped.\")\n"
133
+ ]
134
+ },
135
+ {
136
+ "cell_type": "code",
137
+ "execution_count": null,
138
+ "metadata": {},
139
+ "outputs": [],
140
+ "source": [
141
+ "# 7. Point the repo's artifacts/ directory at the Drive-backed addendum folder.\n",
142
+ "import shutil\n",
143
+ "local_artifacts = REPO_DIR / \"artifacts\"\n",
144
+ "if local_artifacts.is_symlink():\n",
145
+ " local_artifacts.unlink()\n",
146
+ "elif local_artifacts.exists():\n",
147
+ " shutil.rmtree(local_artifacts)\n",
148
+ "local_artifacts.symlink_to(ADDENDUM_ARTIFACTS, target_is_directory=True)\n",
149
+ "print(\"artifacts ->\", local_artifacts.resolve())\n"
150
+ ]
151
+ },
152
+ {
153
+ "cell_type": "code",
154
+ "execution_count": null,
155
+ "metadata": {},
156
+ "outputs": [],
157
+ "source": [
158
+ "# 8. Run only the max-active causal addendum + CPU synthesis.\n",
159
+ "# Task-level checkpointing makes this resumable if Colab disconnects.\n",
160
+ "import subprocess, sys, time\n",
161
+ "\n",
162
+ "command = [sys.executable, \"-m\", \"experiments.run_causal_addendum\", \"--resume\"]\n",
163
+ "print(\"$\", \" \".join(command))\n",
164
+ "print(\"Log:\", LOG_PATH)\n",
165
+ "start = time.time()\n",
166
+ "with LOG_PATH.open(\"a\", encoding=\"utf-8\") as log:\n",
167
+ " log.write(\"\\n\\n=== FeatureLens v0.16 causal addendum ===\\n\")\n",
168
+ " process = subprocess.Popen(command, cwd=REPO_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)\n",
169
+ " assert process.stdout is not None\n",
170
+ " for line in process.stdout:\n",
171
+ " print(line, end=\"\")\n",
172
+ " log.write(line)\n",
173
+ " log.flush()\n",
174
+ " return_code = process.wait()\n",
175
+ "if return_code != 0:\n",
176
+ " raise RuntimeError(f\"Addendum exited with code {return_code}. Fix the error and rerun this cell; --resume keeps completed causal tasks.\")\n",
177
+ "print(f\"\\nCompleted in {(time.time()-start)/60:.1f} minutes.\")\n"
178
+ ]
179
+ },
180
+ {
181
+ "cell_type": "code",
182
+ "execution_count": null,
183
+ "metadata": {},
184
+ "outputs": [],
185
+ "source": [
186
+ "# 9. Inspect the finalized position-sensitivity results.\n",
187
+ "import json, pandas as pd\n",
188
+ "from IPython.display import display, Markdown\n",
189
+ "\n",
190
+ "position = pd.read_csv(ADDENDUM_ARTIFACTS / \"causal_position_summary.csv\")\n",
191
+ "study = pd.read_csv(ADDENDUM_ARTIFACTS / \"study_feature_summary.csv\")\n",
192
+ "summary = json.loads((ADDENDUM_ARTIFACTS / \"study_summary.json\").read_text(encoding=\"utf-8\"))\n",
193
+ "report = (ADDENDUM_ARTIFACTS / \"report.md\").read_text(encoding=\"utf-8\")\n",
194
+ "\n",
195
+ "display(position[position[\"concept\"] == \"__all__\"])\n",
196
+ "display(study)\n",
197
+ "display(summary)\n",
198
+ "display(Markdown(report))\n"
199
+ ]
200
+ },
201
+ {
202
+ "cell_type": "code",
203
+ "execution_count": null,
204
+ "metadata": {},
205
+ "outputs": [],
206
+ "source": [
207
+ "# 10. Create the final publishable result bundle.\n",
208
+ "import zipfile\n",
209
+ "\n",
210
+ "PUBLISH_ZIP = ADDENDUM_ROOT / \"FeatureLens_offline_results_v016.zip\"\n",
211
+ "with zipfile.ZipFile(PUBLISH_ZIP, \"w\", compression=zipfile.ZIP_DEFLATED) as zf:\n",
212
+ " for path in sorted(ADDENDUM_ARTIFACTS.rglob(\"*\")):\n",
213
+ " if not path.is_file():\n",
214
+ " continue\n",
215
+ " rel = path.relative_to(ADDENDUM_ARTIFACTS)\n",
216
+ " if rel.parts and rel.parts[0] == \"activations\":\n",
217
+ " continue\n",
218
+ " if path.name.endswith((\".complete\", \".tmp\")):\n",
219
+ " continue\n",
220
+ " zf.write(path, arcname=str(Path(\"artifacts\") / rel))\n",
221
+ "print(\"Final result bundle:\", PUBLISH_ZIP)\n",
222
+ "print(f\"Size: {PUBLISH_ZIP.stat().st_size / 1024**2:.2f} MiB\")\n"
223
+ ]
224
+ },
225
+ {
226
+ "cell_type": "markdown",
227
+ "metadata": {},
228
+ "source": [
229
+ "## After the addendum\n",
230
+ "\n",
231
+ "Download `FeatureLens_offline_results_v016.zip` and bring it back to the ChatGPT project before committing the empirical artifacts. The final public release should use the v0.16 report/summary rather than the older v0.15 headline.\n"
232
+ ]
233
+ }
234
+ ],
235
+ "metadata": {
236
+ "accelerator": "GPU",
237
+ "kernelspec": {
238
+ "display_name": "Python 3",
239
+ "language": "python",
240
+ "name": "python3"
241
+ },
242
+ "language_info": {
243
+ "name": "python"
244
+ }
245
+ },
246
+ "nbformat": 4,
247
+ "nbformat_minor": 5
248
+ }
notebooks/FeatureLens_Offline_Study_Colab.ipynb CHANGED
@@ -1,295 +1,295 @@
1
  {
2
- "cells": [
3
- {
4
- "cell_type": "markdown",
5
- "id": "3ca18c70",
6
- "metadata": {},
7
- "source": [
8
- "# FeatureLens offline study\n",
9
- "\n",
10
- "This notebook runs the full FeatureLens empirical study on a CUDA runtime while persisting experiment artifacts to Google Drive. It is designed to be resumable after Colab disconnects.\n",
11
- "\n",
12
- "**Before running:** choose a GPU runtime in Colab, then execute the cells from top to bottom."
13
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  },
15
- {
16
- "cell_type": "code",
17
- "execution_count": null,
18
- "id": "dd012fdd",
19
- "metadata": {},
20
- "outputs": [],
21
- "source": [
22
- "# 1. Verify that Colab actually assigned a GPU.\n",
23
- "import subprocess, sys\n",
24
- "\n",
25
- "subprocess.run([\"nvidia-smi\"], check=True)\n",
26
- "\n",
27
- "try:\n",
28
- " import torch\n",
29
- " assert torch.cuda.is_available(), \"CUDA is not available. Change the Colab runtime to a GPU and reconnect.\"\n",
30
- " props = torch.cuda.get_device_properties(0)\n",
31
- " gpu_name = torch.cuda.get_device_name(0)\n",
32
- " gpu_vram_gb = props.total_memory / 1024**3\n",
33
- " print(f\"\\nGPU: {gpu_name} | VRAM: {gpu_vram_gb:.1f} GB\")\n",
34
- "except Exception as exc:\n",
35
- " raise RuntimeError(\"A CUDA GPU runtime is required for the model stages.\") from exc"
36
- ]
37
- },
38
- {
39
- "cell_type": "code",
40
- "execution_count": null,
41
- "id": "bdac04b1",
42
- "metadata": {},
43
- "outputs": [],
44
- "source": [
45
- "# 2. Mount Google Drive so completed experiment stages survive a runtime reset.\n",
46
- "from google.colab import drive\n",
47
- "drive.mount(\"/content/drive\")"
48
- ]
49
- },
50
- {
51
- "cell_type": "code",
52
- "execution_count": null,
53
- "id": "0446d7cd",
54
- "metadata": {},
55
- "outputs": [],
56
- "source": [
57
- "# 3. Configuration — edit REPO_URL before running this cell.\n",
58
- "from pathlib import Path\n",
59
- "\n",
60
- "REPO_URL = \"PASTE_YOUR_GIT_REPO_URL_HERE\"\n",
61
- "BRANCH = \"main\"\n",
62
- "DRIVE_RUN_NAME = \"FeatureLens_offline_v015\"\n",
63
- "\n",
64
- "REPO_DIR = Path(\"/content/FeatureLens\")\n",
65
- "DRIVE_ROOT = Path(\"/content/drive/MyDrive\") / DRIVE_RUN_NAME\n",
66
- "DRIVE_ARTIFACTS = DRIVE_ROOT / \"artifacts\"\n",
67
- "LOG_PATH = DRIVE_ROOT / \"offline_study.log\"\n",
68
- "\n",
69
- "if REPO_URL.startswith(\"PASTE_\"):\n",
70
- " raise ValueError(\"Set REPO_URL to your FeatureLens Git repository URL first.\")\n",
71
- "\n",
72
- "DRIVE_ARTIFACTS.mkdir(parents=True, exist_ok=True)\n",
73
- "print(\"Persistent run directory:\", DRIVE_ROOT)"
74
- ]
75
- },
76
- {
77
- "cell_type": "code",
78
- "execution_count": null,
79
- "id": "c305468e",
80
- "metadata": {},
81
- "outputs": [],
82
- "source": [
83
- "# 4. Clone or refresh the FeatureLens source.\n",
84
- "import shutil, subprocess\n",
85
- "\n",
86
- "if not REPO_DIR.exists():\n",
87
- " subprocess.run([\"git\", \"clone\", \"--branch\", BRANCH, \"--single-branch\", REPO_URL, str(REPO_DIR)], check=True)\n",
88
- "else:\n",
89
- " subprocess.run([\"git\", \"-C\", str(REPO_DIR), \"fetch\", \"origin\", BRANCH], check=True)\n",
90
- " subprocess.run([\"git\", \"-C\", str(REPO_DIR), \"checkout\", BRANCH], check=True)\n",
91
- " subprocess.run([\"git\", \"-C\", str(REPO_DIR), \"pull\", \"--ff-only\", \"origin\", BRANCH], check=True)\n",
92
- "\n",
93
- "print(subprocess.check_output([\"git\", \"-C\", str(REPO_DIR), \"rev-parse\", \"--short\", \"HEAD\"], text=True).strip())"
94
- ]
95
- },
96
- {
97
- "cell_type": "code",
98
- "execution_count": null,
99
- "id": "e34c567a",
100
- "metadata": {},
101
- "outputs": [],
102
- "source": [
103
- "# 5. Install the project environment. This can take a few minutes on a fresh runtime.\n",
104
- "import subprocess, sys\n",
105
- "subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"-r\", str(REPO_DIR / \"requirements.txt\")], check=True)"
106
- ]
107
- },
108
- {
109
- "cell_type": "code",
110
- "execution_count": null,
111
- "id": "501273c0",
112
- "metadata": {},
113
- "outputs": [],
114
- "source": [
115
- "# 6. Re-check CUDA after dependency installation and choose a conservative activation batch.\n",
116
- "import os, torch\n",
117
- "\n",
118
- "assert torch.cuda.is_available(), \"CUDA disappeared after dependency setup.\"\n",
119
- "gpu_name = torch.cuda.get_device_name(0)\n",
120
- "gpu_vram_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3\n",
121
- "ACTIVATION_BATCH_SIZE = 16 if gpu_vram_gb >= 20 else 8\n",
122
- "ACTIVATION_MAX_LENGTH = 192\n",
123
- "\n",
124
- "# Keep model/SAE downloads on Colab's local disk for speed.\n",
125
- "os.environ[\"HF_HOME\"] = \"/content/hf_cache\"\n",
126
- "os.environ[\"TOKENIZERS_PARALLELISM\"] = \"false\"\n",
127
- "\n",
128
- "print(f\"GPU: {gpu_name} ({gpu_vram_gb:.1f} GB)\")\n",
129
- "print(f\"Activation batch size: {ACTIVATION_BATCH_SIZE}\")"
130
- ]
131
- },
132
- {
133
- "cell_type": "code",
134
- "execution_count": null,
135
- "id": "41a3a0e8",
136
- "metadata": {},
137
- "outputs": [],
138
- "source": [
139
- "# 7. Link FeatureLens artifacts to Google Drive.\n",
140
- "# Existing small repo artifacts (for example README.md) are copied once; the local directory is then replaced by a symlink.\n",
141
- "import shutil\n",
142
- "\n",
143
- "local_artifacts = REPO_DIR / \"artifacts\"\n",
144
- "if local_artifacts.is_symlink():\n",
145
- " local_artifacts.unlink()\n",
146
- "elif local_artifacts.exists():\n",
147
- " shutil.copytree(local_artifacts, DRIVE_ARTIFACTS, dirs_exist_ok=True)\n",
148
- " shutil.rmtree(local_artifacts)\n",
149
- "\n",
150
- "local_artifacts.symlink_to(DRIVE_ARTIFACTS, target_is_directory=True)\n",
151
- "print(\"artifacts ->\", local_artifacts.resolve())"
152
- ]
153
- },
154
- {
155
- "cell_type": "markdown",
156
- "id": "dbfda9e4",
157
- "metadata": {},
158
- "source": [
159
- "## Run / resume the study\n",
160
- "\n",
161
- "The command below is safe to rerun. Completed stages are skipped. The causal and feature-set stages also checkpoint completed tasks, so a disconnect during either stage does not discard earlier tasks from that stage."
162
- ]
163
- },
164
- {
165
- "cell_type": "code",
166
- "execution_count": null,
167
- "id": "3f0bfd41",
168
- "metadata": {},
169
- "outputs": [],
170
- "source": [
171
- "# 8. Run the full pipeline with live output and a persistent log.\n",
172
- "import subprocess, sys, time\n",
173
- "\n",
174
- "command = [\n",
175
- " sys.executable, \"-m\", \"experiments.run_all\",\n",
176
- " \"--resume\",\n",
177
- " \"--activation-batch-size\", str(ACTIVATION_BATCH_SIZE),\n",
178
- " \"--activation-max-length\", str(ACTIVATION_MAX_LENGTH),\n",
179
- "]\n",
180
- "\n",
181
- "print(\"$\", \" \".join(command))\n",
182
- "print(\"Log:\", LOG_PATH)\n",
183
- "start = time.time()\n",
184
- "\n",
185
- "with LOG_PATH.open(\"a\", encoding=\"utf-8\") as log:\n",
186
- " log.write(\"\\n\\n=== FeatureLens run ===\\n\")\n",
187
- " log.write(\"$ \" + \" \".join(command) + \"\\n\")\n",
188
- " process = subprocess.Popen(\n",
189
- " command, cwd=REPO_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1\n",
190
- " )\n",
191
- " assert process.stdout is not None\n",
192
- " for line in process.stdout:\n",
193
- " print(line, end=\"\")\n",
194
- " log.write(line)\n",
195
- " log.flush()\n",
196
- " return_code = process.wait()\n",
197
- "\n",
198
- "if return_code != 0:\n",
199
- " raise RuntimeError(\n",
200
- " f\"Pipeline exited with code {return_code}. Fix the error, then rerun this cell; --resume will keep completed work.\"\n",
201
- " )\n",
202
- "\n",
203
- "print(f\"\\nCompleted in {(time.time() - start) / 60:.1f} minutes.\")"
204
- ]
205
- },
206
- {
207
- "cell_type": "code",
208
- "execution_count": null,
209
- "id": "645d7ee5",
210
- "metadata": {},
211
- "outputs": [],
212
- "source": [
213
- "# 9. Validate the measured artifact set.\n",
214
- "import subprocess, sys\n",
215
- "subprocess.run([sys.executable, \"-m\", \"scripts.validate_artifacts\"], cwd=REPO_DIR, check=True)"
216
- ]
217
- },
218
- {
219
- "cell_type": "code",
220
- "execution_count": null,
221
- "id": "9e823e4d",
222
- "metadata": {},
223
- "outputs": [],
224
- "source": [
225
- "# 10. Inspect the study summary and report.\n",
226
- "from pathlib import Path\n",
227
- "import json, pandas as pd\n",
228
- "from IPython.display import display, Markdown\n",
229
- "\n",
230
- "summary_path = DRIVE_ARTIFACTS / \"study_summary.json\"\n",
231
- "study_table_path = DRIVE_ARTIFACTS / \"study_feature_summary.csv\"\n",
232
- "report_path = DRIVE_ARTIFACTS / \"report.md\"\n",
233
- "\n",
234
- "summary = json.loads(summary_path.read_text(encoding=\"utf-8\"))\n",
235
- "display(summary)\n",
236
- "display(pd.read_csv(study_table_path))\n",
237
- "display(Markdown(report_path.read_text(encoding=\"utf-8\")))"
238
- ]
239
- },
240
- {
241
- "cell_type": "code",
242
- "execution_count": null,
243
- "id": "62c96dc6",
244
- "metadata": {},
245
- "outputs": [],
246
- "source": [
247
- "# 11. Create a small publishable artifact bundle (activation caches and checkpoint markers are excluded).\n",
248
- "import zipfile\n",
249
- "\n",
250
- "PUBLISH_ZIP = DRIVE_ROOT / \"FeatureLens_offline_results.zip\"\n",
251
- "\n",
252
- "with zipfile.ZipFile(PUBLISH_ZIP, \"w\", compression=zipfile.ZIP_DEFLATED) as zf:\n",
253
- " for path in sorted(DRIVE_ARTIFACTS.rglob(\"*\")):\n",
254
- " if not path.is_file():\n",
255
- " continue\n",
256
- " rel = path.relative_to(DRIVE_ARTIFACTS)\n",
257
- " if rel.parts and rel.parts[0] == \"activations\":\n",
258
- " continue\n",
259
- " if path.name.endswith(\".complete\") or path.name.endswith(\".tmp\"):\n",
260
- " continue\n",
261
- " zf.write(path, arcname=str(Path(\"artifacts\") / rel))\n",
262
- "\n",
263
- "print(\"Publishable bundle:\", PUBLISH_ZIP)\n",
264
- "print(f\"Size: {PUBLISH_ZIP.stat().st_size / 1024**2:.2f} MiB\")"
265
- ]
266
- },
267
- {
268
- "cell_type": "markdown",
269
- "id": "a6634bfe",
270
- "metadata": {},
271
- "source": [
272
- "## After Colab\n",
273
- "\n",
274
- "Download `FeatureLens_offline_results.zip` from the Drive run folder. Extract it over your local FeatureLens repository so the files land under `artifacts/`, run the normal release checks locally, inspect the measured report, and only then commit the small study artifacts. Do **not** commit `artifacts/activations/`."
275
- ]
276
- }
277
- ],
278
- "metadata": {
279
- "accelerator": "GPU",
280
- "colab": {
281
- "name": "FeatureLens Offline Study",
282
- "provenance": []
283
- },
284
- "kernelspec": {
285
- "display_name": "Python 3",
286
- "language": "python",
287
- "name": "python3"
288
- },
289
- "language_info": {
290
- "name": "python"
291
- }
292
- },
293
- "nbformat": 4,
294
- "nbformat_minor": 5
295
  }
 
1
  {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "3ca18c70",
6
+ "metadata": {},
7
+ "source": [
8
+ "# FeatureLens offline study\n",
9
+ "\n",
10
+ "This notebook runs the full FeatureLens empirical study on a CUDA runtime while persisting experiment artifacts to Google Drive. It is designed to be resumable after Colab disconnects.\n",
11
+ "\n",
12
+ "**Before running:** choose a GPU runtime in Colab, then execute the cells from top to bottom.\n"
13
+ ]
14
+ },
15
+ {
16
+ "cell_type": "code",
17
+ "execution_count": null,
18
+ "id": "dd012fdd",
19
+ "metadata": {},
20
+ "outputs": [],
21
+ "source": [
22
+ "# 1. Verify that Colab actually assigned a GPU.\n",
23
+ "import subprocess, sys\n",
24
+ "\n",
25
+ "subprocess.run([\"nvidia-smi\"], check=True)\n",
26
+ "\n",
27
+ "try:\n",
28
+ " import torch\n",
29
+ " assert torch.cuda.is_available(), \"CUDA is not available. Change the Colab runtime to a GPU and reconnect.\"\n",
30
+ " props = torch.cuda.get_device_properties(0)\n",
31
+ " gpu_name = torch.cuda.get_device_name(0)\n",
32
+ " gpu_vram_gb = props.total_memory / 1024**3\n",
33
+ " print(f\"\\nGPU: {gpu_name} | VRAM: {gpu_vram_gb:.1f} GB\")\n",
34
+ "except Exception as exc:\n",
35
+ " raise RuntimeError(\"A CUDA GPU runtime is required for the model stages.\") from exc\n"
36
+ ]
37
+ },
38
+ {
39
+ "cell_type": "code",
40
+ "execution_count": null,
41
+ "id": "bdac04b1",
42
+ "metadata": {},
43
+ "outputs": [],
44
+ "source": [
45
+ "# 2. Mount Google Drive so completed experiment stages survive a runtime reset.\n",
46
+ "from google.colab import drive\n",
47
+ "drive.mount(\"/content/drive\")\n"
48
+ ]
49
+ },
50
+ {
51
+ "cell_type": "code",
52
+ "execution_count": null,
53
+ "id": "0446d7cd",
54
+ "metadata": {},
55
+ "outputs": [],
56
+ "source": [
57
+ "# 3. Configuration \u2014 edit REPO_URL before running this cell.\n",
58
+ "from pathlib import Path\n",
59
+ "\n",
60
+ "REPO_URL = \"PASTE_YOUR_GIT_REPO_URL_HERE\"\n",
61
+ "BRANCH = \"main\"\n",
62
+ "DRIVE_RUN_NAME = \"FeatureLens_offline_v016_full\"\n",
63
+ "\n",
64
+ "REPO_DIR = Path(\"/content/FeatureLens\")\n",
65
+ "DRIVE_ROOT = Path(\"/content/drive/MyDrive\") / DRIVE_RUN_NAME\n",
66
+ "DRIVE_ARTIFACTS = DRIVE_ROOT / \"artifacts\"\n",
67
+ "LOG_PATH = DRIVE_ROOT / \"offline_study.log\"\n",
68
+ "\n",
69
+ "if REPO_URL.startswith(\"PASTE_\"):\n",
70
+ " raise ValueError(\"Set REPO_URL to your FeatureLens Git repository URL first.\")\n",
71
+ "\n",
72
+ "DRIVE_ARTIFACTS.mkdir(parents=True, exist_ok=True)\n",
73
+ "print(\"Persistent run directory:\", DRIVE_ROOT)\n"
74
+ ]
75
+ },
76
+ {
77
+ "cell_type": "code",
78
+ "execution_count": null,
79
+ "id": "c305468e",
80
+ "metadata": {},
81
+ "outputs": [],
82
+ "source": [
83
+ "# 4. Clone or refresh the FeatureLens source.\n",
84
+ "import shutil, subprocess\n",
85
+ "\n",
86
+ "if not REPO_DIR.exists():\n",
87
+ " subprocess.run([\"git\", \"clone\", \"--branch\", BRANCH, \"--single-branch\", REPO_URL, str(REPO_DIR)], check=True)\n",
88
+ "else:\n",
89
+ " subprocess.run([\"git\", \"-C\", str(REPO_DIR), \"fetch\", \"origin\", BRANCH], check=True)\n",
90
+ " subprocess.run([\"git\", \"-C\", str(REPO_DIR), \"checkout\", BRANCH], check=True)\n",
91
+ " subprocess.run([\"git\", \"-C\", str(REPO_DIR), \"pull\", \"--ff-only\", \"origin\", BRANCH], check=True)\n",
92
+ "\n",
93
+ "print(subprocess.check_output([\"git\", \"-C\", str(REPO_DIR), \"rev-parse\", \"--short\", \"HEAD\"], text=True).strip())\n"
94
+ ]
95
+ },
96
+ {
97
+ "cell_type": "code",
98
+ "execution_count": null,
99
+ "id": "e34c567a",
100
+ "metadata": {},
101
+ "outputs": [],
102
+ "source": [
103
+ "# 5. Install the project environment. This can take a few minutes on a fresh runtime.\n",
104
+ "import subprocess, sys\n",
105
+ "subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"-r\", str(REPO_DIR / \"requirements.txt\")], check=True)\n"
106
+ ]
107
+ },
108
+ {
109
+ "cell_type": "code",
110
+ "execution_count": null,
111
+ "id": "501273c0",
112
+ "metadata": {},
113
+ "outputs": [],
114
+ "source": [
115
+ "# 6. Re-check CUDA after dependency installation and choose a conservative activation batch.\n",
116
+ "import os, torch\n",
117
+ "\n",
118
+ "assert torch.cuda.is_available(), \"CUDA disappeared after dependency setup.\"\n",
119
+ "gpu_name = torch.cuda.get_device_name(0)\n",
120
+ "gpu_vram_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3\n",
121
+ "ACTIVATION_BATCH_SIZE = 16 if gpu_vram_gb >= 20 else 8\n",
122
+ "ACTIVATION_MAX_LENGTH = 192\n",
123
+ "\n",
124
+ "# Keep model/SAE downloads on Colab's local disk for speed.\n",
125
+ "os.environ[\"HF_HOME\"] = \"/content/hf_cache\"\n",
126
+ "os.environ[\"TOKENIZERS_PARALLELISM\"] = \"false\"\n",
127
+ "\n",
128
+ "print(f\"GPU: {gpu_name} ({gpu_vram_gb:.1f} GB)\")\n",
129
+ "print(f\"Activation batch size: {ACTIVATION_BATCH_SIZE}\")\n"
130
+ ]
131
+ },
132
+ {
133
+ "cell_type": "code",
134
+ "execution_count": null,
135
+ "id": "41a3a0e8",
136
+ "metadata": {},
137
+ "outputs": [],
138
+ "source": [
139
+ "# 7. Link FeatureLens artifacts to Google Drive.\n",
140
+ "# Existing small repo artifacts (for example README.md) are copied once; the local directory is then replaced by a symlink.\n",
141
+ "import shutil\n",
142
+ "\n",
143
+ "local_artifacts = REPO_DIR / \"artifacts\"\n",
144
+ "if local_artifacts.is_symlink():\n",
145
+ " local_artifacts.unlink()\n",
146
+ "elif local_artifacts.exists():\n",
147
+ " shutil.copytree(local_artifacts, DRIVE_ARTIFACTS, dirs_exist_ok=True)\n",
148
+ " shutil.rmtree(local_artifacts)\n",
149
+ "\n",
150
+ "local_artifacts.symlink_to(DRIVE_ARTIFACTS, target_is_directory=True)\n",
151
+ "print(\"artifacts ->\", local_artifacts.resolve())\n"
152
+ ]
153
+ },
154
+ {
155
+ "cell_type": "markdown",
156
+ "id": "dbfda9e4",
157
+ "metadata": {},
158
+ "source": [
159
+ "## Run / resume the study\n",
160
+ "\n",
161
+ "The command below is safe to rerun. Completed stages are skipped. The causal and feature-set stages also checkpoint completed tasks, so a disconnect during either stage does not discard earlier tasks from that stage.\n"
162
+ ]
163
+ },
164
+ {
165
+ "cell_type": "code",
166
+ "execution_count": null,
167
+ "id": "3f0bfd41",
168
+ "metadata": {},
169
+ "outputs": [],
170
+ "source": [
171
+ "# 8. Run the full pipeline with live output and a persistent log.\n",
172
+ "import subprocess, sys, time\n",
173
+ "\n",
174
+ "command = [\n",
175
+ " sys.executable, \"-m\", \"experiments.run_all\",\n",
176
+ " \"--resume\",\n",
177
+ " \"--activation-batch-size\", str(ACTIVATION_BATCH_SIZE),\n",
178
+ " \"--activation-max-length\", str(ACTIVATION_MAX_LENGTH),\n",
179
+ "]\n",
180
+ "\n",
181
+ "print(\"$\", \" \".join(command))\n",
182
+ "print(\"Log:\", LOG_PATH)\n",
183
+ "start = time.time()\n",
184
+ "\n",
185
+ "with LOG_PATH.open(\"a\", encoding=\"utf-8\") as log:\n",
186
+ " log.write(\"\\n\\n=== FeatureLens run ===\\n\")\n",
187
+ " log.write(\"$ \" + \" \".join(command) + \"\\n\")\n",
188
+ " process = subprocess.Popen(\n",
189
+ " command, cwd=REPO_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1\n",
190
+ " )\n",
191
+ " assert process.stdout is not None\n",
192
+ " for line in process.stdout:\n",
193
+ " print(line, end=\"\")\n",
194
+ " log.write(line)\n",
195
+ " log.flush()\n",
196
+ " return_code = process.wait()\n",
197
+ "\n",
198
+ "if return_code != 0:\n",
199
+ " raise RuntimeError(\n",
200
+ " f\"Pipeline exited with code {return_code}. Fix the error, then rerun this cell; --resume will keep completed work.\"\n",
201
+ " )\n",
202
+ "\n",
203
+ "print(f\"\\nCompleted in {(time.time() - start) / 60:.1f} minutes.\")\n"
204
+ ]
205
+ },
206
+ {
207
+ "cell_type": "code",
208
+ "execution_count": null,
209
+ "id": "645d7ee5",
210
+ "metadata": {},
211
+ "outputs": [],
212
+ "source": [
213
+ "# 9. Validate the measured artifact set.\n",
214
+ "import subprocess, sys\n",
215
+ "subprocess.run([sys.executable, \"-m\", \"scripts.validate_artifacts\"], cwd=REPO_DIR, check=True)\n"
216
+ ]
217
+ },
218
+ {
219
+ "cell_type": "code",
220
+ "execution_count": null,
221
+ "id": "9e823e4d",
222
+ "metadata": {},
223
+ "outputs": [],
224
+ "source": [
225
+ "# 10. Inspect the study summary and report.\n",
226
+ "from pathlib import Path\n",
227
+ "import json, pandas as pd\n",
228
+ "from IPython.display import display, Markdown\n",
229
+ "\n",
230
+ "summary_path = DRIVE_ARTIFACTS / \"study_summary.json\"\n",
231
+ "study_table_path = DRIVE_ARTIFACTS / \"study_feature_summary.csv\"\n",
232
+ "report_path = DRIVE_ARTIFACTS / \"report.md\"\n",
233
+ "\n",
234
+ "summary = json.loads(summary_path.read_text(encoding=\"utf-8\"))\n",
235
+ "display(summary)\n",
236
+ "display(pd.read_csv(study_table_path))\n",
237
+ "display(Markdown(report_path.read_text(encoding=\"utf-8\")))\n"
238
+ ]
239
+ },
240
+ {
241
+ "cell_type": "code",
242
+ "execution_count": null,
243
+ "id": "62c96dc6",
244
+ "metadata": {},
245
+ "outputs": [],
246
+ "source": [
247
+ "# 11. Create a small publishable artifact bundle (activation caches and checkpoint markers are excluded).\n",
248
+ "import zipfile\n",
249
+ "\n",
250
+ "PUBLISH_ZIP = DRIVE_ROOT / \"FeatureLens_offline_results.zip\"\n",
251
+ "\n",
252
+ "with zipfile.ZipFile(PUBLISH_ZIP, \"w\", compression=zipfile.ZIP_DEFLATED) as zf:\n",
253
+ " for path in sorted(DRIVE_ARTIFACTS.rglob(\"*\")):\n",
254
+ " if not path.is_file():\n",
255
+ " continue\n",
256
+ " rel = path.relative_to(DRIVE_ARTIFACTS)\n",
257
+ " if rel.parts and rel.parts[0] == \"activations\":\n",
258
+ " continue\n",
259
+ " if path.name.endswith(\".complete\") or path.name.endswith(\".tmp\"):\n",
260
+ " continue\n",
261
+ " zf.write(path, arcname=str(Path(\"artifacts\") / rel))\n",
262
+ "\n",
263
+ "print(\"Publishable bundle:\", PUBLISH_ZIP)\n",
264
+ "print(f\"Size: {PUBLISH_ZIP.stat().st_size / 1024**2:.2f} MiB\")\n"
265
+ ]
266
+ },
267
+ {
268
+ "cell_type": "markdown",
269
+ "id": "a6634bfe",
270
+ "metadata": {},
271
+ "source": [
272
+ "## After Colab\n",
273
+ "\n",
274
+ "Download `FeatureLens_offline_results.zip` from the Drive run folder. Extract it over your local FeatureLens repository so the files land under `artifacts/`, run the normal release checks locally, inspect the measured report, and only then commit the small study artifacts. Do **not** commit `artifacts/activations/`.\n"
275
+ ]
276
+ }
277
+ ],
278
+ "metadata": {
279
+ "accelerator": "GPU",
280
+ "colab": {
281
+ "name": "FeatureLens Offline Study",
282
+ "provenance": []
283
+ },
284
+ "kernelspec": {
285
+ "display_name": "Python 3",
286
+ "language": "python",
287
+ "name": "python3"
288
+ },
289
+ "language_info": {
290
+ "name": "python"
291
+ }
292
  },
293
+ "nbformat": 4,
294
+ "nbformat_minor": 5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  }
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "featurelens"
3
- version = "0.15.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.16.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",
@@ -205,5 +205,19 @@
205
  "muted_cross_target_chart_series",
206
  "colab_offline_runner_notebook",
207
  "task_level_causal_and_feature_set_resume"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  ]
209
  }
 
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",
 
205
  "muted_cross_target_chart_series",
206
  "colab_offline_runner_notebook",
207
  "task_level_causal_and_feature_set_resume"
208
+ ],
209
+ "offline_causal_position_policies": [
210
+ "final_token",
211
+ "max_feature_activation"
212
+ ],
213
+ "primary_offline_causal_position_policy": "max_feature_activation",
214
+ "offline_causal_statistical_unit": "causal task; average ablation and amplification within task before paired bootstrap/sign-flip inference",
215
+ "offline_features_v0_16": [
216
+ "final_token_vs_max_feature_activation_causal_position_sensitivity",
217
+ "causal_task_level_statistical_inference",
218
+ "coverage_separated_from_conditional_effect_strength",
219
+ "exact_small_sample_sign_flip_tests",
220
+ "causal_addendum_colab_runner",
221
+ "position_sensitivity_study_dashboard"
222
  ]
223
  }
scripts/release_check.py CHANGED
@@ -22,6 +22,7 @@ REQUIRED = [
22
  'featurelens/study.py',
23
  'experiments/run_all.py',
24
  'experiments/run_causal.py',
 
25
  'experiments/run_feature_sets.py',
26
  'experiments/analyze_stability.py',
27
  'experiments/analyze_study.py',
@@ -31,7 +32,9 @@ REQUIRED = [
31
  'docs/VALIDATION.md',
32
  'docs/OFFLINE_STUDY.md',
33
  'docs/COLAB.md',
 
34
  'notebooks/FeatureLens_Offline_Study_Colab.ipynb',
 
35
  'scripts/ui_smoke.py',
36
  'tests/test_offline_study.py',
37
  'scripts/validate_artifacts.py',
@@ -273,6 +276,25 @@ def check_config(config: dict) -> None:
273
  'research_config.json ui_and_runner_features_v0_15 mismatch: '
274
  f'{sorted(actual_v15)}'
275
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  if config.get('offline_selection_resamples') != 128:
277
  raise SystemExit('Offline selection resamples must be 128.')
278
  if 'prompt-wide' not in str(config.get('offline_feature_pooling', '')):
@@ -347,11 +369,14 @@ def check_readme() -> None:
347
  '--activation-batch-size',
348
  'validate_artifacts',
349
  'FeatureLens_Offline_Study_Colab.ipynb',
 
 
 
350
  'DESIGN.md',
351
  ]
352
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
353
  if missing:
354
- raise SystemExit(f'README.md is missing required v0.15 content: {missing}')
355
 
356
  # Public README should not lead with release-train marketing. Version history belongs in CHANGELOG.
357
  if '> **v0.' in readme or '## v0.' in readme:
@@ -360,8 +385,8 @@ def check_readme() -> None:
360
 
361
  def check_pyproject() -> None:
362
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
363
- if 'version = "0.15.0"' not in text:
364
- raise SystemExit('pyproject.toml must declare version 0.15.0.')
365
 
366
 
367
  def main() -> None:
@@ -379,7 +404,7 @@ def main() -> None:
379
  print(f' layers: {config["layers"]}')
380
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
381
  print(f' random controls: {config["live_random_controls"]}')
382
- print(' release: v0.15.0')
383
 
384
 
385
  if __name__ == '__main__':
 
22
  'featurelens/study.py',
23
  'experiments/run_all.py',
24
  'experiments/run_causal.py',
25
+ 'experiments/run_causal_addendum.py',
26
  'experiments/run_feature_sets.py',
27
  'experiments/analyze_stability.py',
28
  'experiments/analyze_study.py',
 
32
  'docs/VALIDATION.md',
33
  'docs/OFFLINE_STUDY.md',
34
  'docs/COLAB.md',
35
+ 'docs/CAUSAL_ADDENDUM.md',
36
  'notebooks/FeatureLens_Offline_Study_Colab.ipynb',
37
+ 'notebooks/FeatureLens_Causal_Addendum_Colab.ipynb',
38
  'scripts/ui_smoke.py',
39
  'tests/test_offline_study.py',
40
  'scripts/validate_artifacts.py',
 
276
  'research_config.json ui_and_runner_features_v0_15 mismatch: '
277
  f'{sorted(actual_v15)}'
278
  )
279
+
280
+ required_v16 = {
281
+ 'final_token_vs_max_feature_activation_causal_position_sensitivity',
282
+ 'causal_task_level_statistical_inference',
283
+ 'coverage_separated_from_conditional_effect_strength',
284
+ 'exact_small_sample_sign_flip_tests',
285
+ 'causal_addendum_colab_runner',
286
+ 'position_sensitivity_study_dashboard',
287
+ }
288
+ actual_v16 = set(config.get('offline_features_v0_16', []))
289
+ if actual_v16 != required_v16:
290
+ raise SystemExit(
291
+ 'research_config.json offline_features_v0_16 mismatch: '
292
+ f'{sorted(actual_v16)}'
293
+ )
294
+ if config.get('offline_causal_position_policies') != ['final_token', 'max_feature_activation']:
295
+ raise SystemExit('Offline causal position policies must be final_token and max_feature_activation.')
296
+ if config.get('primary_offline_causal_position_policy') != 'max_feature_activation':
297
+ raise SystemExit('Primary offline causal position policy must be max_feature_activation.')
298
  if config.get('offline_selection_resamples') != 128:
299
  raise SystemExit('Offline selection resamples must be 128.')
300
  if 'prompt-wide' not in str(config.get('offline_feature_pooling', '')):
 
369
  '--activation-batch-size',
370
  'validate_artifacts',
371
  'FeatureLens_Offline_Study_Colab.ipynb',
372
+ 'FeatureLens_Causal_Addendum_Colab.ipynb',
373
+ 'max-feature-activation',
374
+ 'causal task',
375
  'DESIGN.md',
376
  ]
377
  missing = [value for value in required_strings if value.lower() not in readme.lower()]
378
  if missing:
379
+ raise SystemExit(f'README.md is missing required v0.16 content: {missing}')
380
 
381
  # Public README should not lead with release-train marketing. Version history belongs in CHANGELOG.
382
  if '> **v0.' in readme or '## v0.' in readme:
 
385
 
386
  def check_pyproject() -> None:
387
  text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
388
+ if 'version = "0.16.0"' not in text:
389
+ raise SystemExit('pyproject.toml must declare version 0.16.0.')
390
 
391
 
392
  def main() -> None:
 
404
  print(f' layers: {config["layers"]}')
405
  print(f' feature-set sizes: {config["feature_set_sizes"]}')
406
  print(f' random controls: {config["live_random_controls"]}')
407
+ print(' release: v0.16.0')
408
 
409
 
410
  if __name__ == '__main__':
scripts/validate_artifacts.py CHANGED
@@ -9,12 +9,13 @@ 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',
@@ -37,68 +38,45 @@ def main() -> None:
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
 
 
9
  ARTIFACT_DIR = ROOT / 'artifacts'
10
 
11
  REQUIRED = [
 
12
  'feature_catalog.csv',
13
  'layer_metrics.csv',
14
  'stability.csv',
15
  'selection_stability.csv',
16
+ 'causal_results_final_token.csv',
17
+ 'causal_results_max_active.csv',
18
+ 'causal_position_summary.csv',
19
  'feature_set_results.csv',
20
  'study_feature_summary.csv',
21
  'study_summary.json',
 
38
  if missing:
39
  raise SystemExit(f'Missing offline-study artifacts: {missing}')
40
 
41
+ _require_columns(ARTIFACT_DIR / 'feature_catalog.csv', {'layer','concept','feature_id','train_auroc','auroc','f1'})
42
+ _require_columns(ARTIFACT_DIR / 'selection_stability.csv', {'layer','concept','feature_id','resample_support','median_resample_rank'})
43
+ causal_columns = {
44
+ 'task_id','concept','feature_id','position_policy','intervention_token_index',
45
+ 'feature_active_at_intervention','feature_active_at_final_token','feature_active_anywhere',
46
+ 'intervention','condition','target_mean_logprob_delta','js_divergence',
47
+ }
48
+ _require_columns(ARTIFACT_DIR / 'causal_results_final_token.csv', causal_columns)
49
+ _require_columns(ARTIFACT_DIR / 'causal_results_max_active.csv', causal_columns)
50
+ _require_columns(ARTIFACT_DIR / 'causal_position_summary.csv', {
51
+ 'concept','position_policy','feature_active_at_intervention_rate','target_specificity_ratio',
52
+ 'target_paired_advantage','target_sign_flip_pvalue',
53
+ })
54
+ _require_columns(ARTIFACT_DIR / 'study_feature_summary.csv', {
55
+ 'concept','layer','feature_id','heldout_auroc','heldout_f1','candidate_resample_support',
56
+ 'final_target_specificity_ratio','max_active_target_specificity_ratio',
57
+ 'final_feature_active_at_intervention_rate','max_active_feature_active_at_intervention_rate',
58
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  summary = json.loads((ARTIFACT_DIR / 'study_summary.json').read_text(encoding='utf-8'))
61
  if int(summary.get('n_concepts', 0)) < 1:
62
  raise SystemExit('study_summary.json has no concepts.')
63
+ if summary.get('primary_causal_position_policy') != 'max_feature_activation':
64
+ raise SystemExit('study_summary.json must use max_feature_activation as the primary causal policy.')
65
+ if 'causal task' not in str(summary.get('causal_statistical_unit', '')).lower():
66
+ raise SystemExit('study_summary.json must document causal-task-level inference.')
67
 
68
  required_figures = [
69
+ 'feature_auroc.png','layer_diagnostics.png','causal_effects.png','feature_set_effects.png',
70
+ 'association_vs_causality.png','causal_position_sensitivity.png',
 
 
 
 
 
 
 
71
  ]
72
+ missing_figures = [name for name in required_figures if not (ARTIFACT_DIR / 'figures' / name).exists()]
73
  if missing_figures:
74
  raise SystemExit(f'Missing report figures: {missing_figures}')
75
 
76
  print('FeatureLens offline artifact validation: PASS')
77
  print(f" concepts: {summary['n_concepts']}")
78
+ print(f" primary causal policy: {summary['primary_causal_position_policy']}")
79
+ print(f" statistical unit: {summary['causal_statistical_unit']}")
80
  print(' report: artifacts/report.md')
81
 
82
 
tests/test_causal_position.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import types
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import torch
10
+
11
+
12
+ def _stub_transformers() -> None:
13
+ if 'transformers' in sys.modules:
14
+ return
15
+ stub = types.ModuleType('transformers')
16
+ stub.AutoModelForCausalLM = type('AutoModelForCausalLM', (), {})
17
+ stub.AutoTokenizer = type('AutoTokenizer', (), {})
18
+ sys.modules['transformers'] = stub
19
+
20
+
21
+ def test_feature_trace_and_position_policy() -> None:
22
+ _stub_transformers()
23
+ from experiments.run_causal import choose_intervention_position, feature_activation_trace
24
+ from featurelens.sae import SparseEncoding
25
+
26
+ enc = SparseEncoding(
27
+ indices=torch.tensor([[1, 8], [7, 2], [7, 3]]),
28
+ values=torch.tensor([[2.0, 1.0], [4.0, 3.0], [9.0, 1.0]]),
29
+ )
30
+ trace = feature_activation_trace(enc, 7)
31
+ assert trace.tolist() == [0.0, 4.0, 9.0]
32
+ idx, activation, active = choose_intervention_position(
33
+ trace, prompt_len=3, position_policy='max_feature_activation'
34
+ )
35
+ assert (idx, activation, active) == (2, 9.0, True)
36
+ idx, activation, active = choose_intervention_position(
37
+ trace, prompt_len=3, position_policy='final_token'
38
+ )
39
+ assert (idx, activation, active) == (2, 9.0, True)
40
+
41
+
42
+ def test_max_active_inactive_falls_back_to_final_token() -> None:
43
+ _stub_transformers()
44
+ from experiments.run_causal import choose_intervention_position
45
+
46
+ idx, activation, active = choose_intervention_position(
47
+ torch.zeros(4), prompt_len=4, position_policy='max_feature_activation'
48
+ )
49
+ assert idx == 3
50
+ assert activation == 0.0
51
+ assert not active
52
+
53
+
54
+ def test_legacy_causal_baseline_migration(tmp_path: Path) -> None:
55
+ from experiments.run_causal_addendum import migrate_final_token_baseline
56
+
57
+ source = tmp_path / 'causal_results.csv'
58
+ destination = tmp_path / 'causal_results_final_token.csv'
59
+ pd.DataFrame(
60
+ [
61
+ {'task_id': 'a', 'feature_activation': 3.0, 'condition': 'sae_feature'},
62
+ {'task_id': 'a', 'feature_activation': 3.0, 'condition': 'random_norm_matched'},
63
+ {'task_id': 'b', 'feature_activation': 0.0, 'condition': 'sae_feature'},
64
+ ]
65
+ ).to_csv(source, index=False)
66
+ migrate_final_token_baseline(source, destination)
67
+ frame = pd.read_csv(destination)
68
+ assert set(frame['position_policy']) == {'final_token'}
69
+ assert frame['feature_active_at_intervention'].tolist() == [1, 1, 0]
70
+ assert frame['feature_active_at_final_token'].tolist() == [1, 1, 0]
71
+ assert frame['feature_active_anywhere'].isna().all()
72
+
73
+
74
+ def test_task_level_specificity_averages_interventions_within_task() -> None:
75
+ from experiments.analyze_study import _task_level_specificity
76
+
77
+ rows = []
78
+ for intervention, sae_effect, random_effects in [
79
+ ('ablate', 0.4, [0.1, -0.1]),
80
+ ('amplify_2x', -0.2, [0.05, -0.15]),
81
+ ]:
82
+ rows.append({'task_id': 'a', 'intervention': intervention, 'condition': 'sae_feature', 'target_mean_logprob_delta': sae_effect})
83
+ for value in random_effects:
84
+ rows.append({'task_id': 'a', 'intervention': intervention, 'condition': 'random_norm_matched', 'target_mean_logprob_delta': value})
85
+ result = _task_level_specificity(pd.DataFrame(rows), effect_column='target_mean_logprob_delta', seed=1)
86
+ # One task: SAE mean absolute intervention effect=(.4+.2)/2=.3;
87
+ # random means=(.1,.1), then averaged within task=.1.
88
+ assert result['n_tasks'] == 1
89
+ assert np.isclose(result['sae_abs_mean'], 0.3)
90
+ assert np.isclose(result['random_abs_mean'], 0.1)
91
+ assert np.isclose(result['specificity_ratio'], 3.0)
tests/test_stats.py CHANGED
@@ -31,3 +31,11 @@ def test_sign_flip_small_for_consistent_effect():
31
  def test_sign_flip_one_for_identical_pairs():
32
  x = np.array([1.0, 2.0, 3.0])
33
  assert paired_sign_flip_pvalue(x, x, n_permutations=1000, seed=4) == 1.0
 
 
 
 
 
 
 
 
 
31
  def test_sign_flip_one_for_identical_pairs():
32
  x = np.array([1.0, 2.0, 3.0])
33
  assert paired_sign_flip_pvalue(x, x, n_permutations=1000, seed=4) == 1.0
34
+
35
+
36
+ def test_sign_flip_is_exact_for_small_effective_sample() -> None:
37
+ from featurelens.stats import paired_sign_flip_pvalue
38
+
39
+ # With two positive non-zero differences, only the ++ and -- assignments
40
+ # are as extreme as the observed all-positive mean: p = 2 / 4.
41
+ assert paired_sign_flip_pvalue([1.0, 1.0], [0.0, 0.0]) == 0.5