Spaces:
Running on Zero
Running on Zero
Commit ·
b784950
1
Parent(s): 9838759
Finalize FeatureLens causal position study
Browse files- CHANGELOG.md +11 -0
- README.md +14 -4
- app.py +21 -8
- artifacts/README.md +12 -17
- docs/CAUSAL_ADDENDUM.md +49 -0
- docs/COLAB.md +13 -1
- docs/OFFLINE_STUDY.md +19 -0
- docs/VALIDATION.md +22 -69
- experiments/analyze_study.py +250 -120
- experiments/make_report.py +675 -357
- experiments/run_all.py +21 -3
- experiments/run_causal.py +100 -23
- experiments/run_causal_addendum.py +91 -0
- featurelens/stats.py +32 -3
- featurelens/study.py +20 -14
- notebooks/FeatureLens_Causal_Addendum_Colab.ipynb +248 -0
- notebooks/FeatureLens_Offline_Study_Colab.ipynb +292 -292
- pyproject.toml +1 -1
- research_config.json +15 -1
- scripts/release_check.py +29 -4
- scripts/validate_artifacts.py +30 -52
- tests/test_causal_position.py +91 -0
- tests/test_stats.py +8 -0
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).
|
| 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 |
-
├──
|
|
|
|
|
|
|
| 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("
|
| 3071 |
-
|
| 3072 |
-
["resample_support", "full_score"], ascending=[False, False]
|
| 3073 |
-
).head(40)
|
| 3074 |
gr.Dataframe(
|
| 3075 |
-
value=
|
| 3076 |
interactive=False,
|
| 3077 |
show_label=False,
|
| 3078 |
buttons=["fullscreen"],
|
| 3079 |
elem_classes=["result-table"],
|
| 3080 |
wrap=False,
|
| 3081 |
-
max_height=
|
| 3082 |
)
|
| 3083 |
with gr.Column(scale=2):
|
| 3084 |
gr.Image(
|
| 3085 |
-
value=STUDY.figure("
|
| 3086 |
-
label="
|
| 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 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
```bash
|
| 8 |
-
python experiments
|
| 9 |
```
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
```bash
|
| 14 |
-
python experiments
|
| 15 |
```
|
| 16 |
|
| 17 |
-
|
| 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 |
-
- `
|
|
|
|
|
|
|
| 31 |
- `feature_set_results.csv`;
|
| 32 |
- `study_feature_summary.csv`;
|
| 33 |
- `study_summary.json`;
|
| 34 |
- `summary.json`;
|
| 35 |
- `report.md`;
|
| 36 |
-
- report figures including
|
| 37 |
|
| 38 |
-
Validate
|
| 39 |
|
| 40 |
```bash
|
| 41 |
python -m scripts.validate_artifacts
|
| 42 |
```
|
| 43 |
|
| 44 |
-
`artifacts/activations/`
|
|
|
|
| 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/
|
|
|
|
|
|
|
| 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.
|
| 2 |
|
| 3 |
-
v0.
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 76 |
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
|
| 81 |
-
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
.
|
|
|
|
| 48 |
)
|
| 49 |
-
|
| 50 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 70 |
-
|
| 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(
|
| 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 |
-
'
|
| 98 |
-
'
|
| 99 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
'
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
),
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 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 |
-
'
|
| 201 |
-
study['heldout_auroc'], study['
|
| 202 |
),
|
| 203 |
-
'
|
| 204 |
-
study['heldout_auroc'], study['
|
| 205 |
),
|
| 206 |
-
'
|
| 207 |
-
study['heldout_f1'], study['
|
| 208 |
),
|
| 209 |
-
'
|
| 210 |
-
study['candidate_resample_support'], study['
|
| 211 |
),
|
| 212 |
}
|
| 213 |
|
|
|
|
|
|
|
|
|
|
| 214 |
most_predictive = study.sort_values('heldout_auroc', ascending=False).iloc[0]
|
| 215 |
-
most_target_specific = study.sort_values('
|
| 216 |
-
most_js_specific = study.sort_values('
|
| 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 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
'most_predictive_concept': {
|
| 225 |
'concept': str(most_predictive['concept']),
|
| 226 |
'heldout_auroc': float(most_predictive['heldout_auroc']),
|
| 227 |
},
|
| 228 |
-
'
|
| 229 |
'concept': str(most_target_specific['concept']),
|
| 230 |
-
'ratio': float(most_target_specific['
|
| 231 |
},
|
| 232 |
-
'
|
| 233 |
'concept': str(most_js_specific['concept']),
|
| 234 |
-
'ratio': float(most_js_specific['
|
| 235 |
},
|
| 236 |
'correlations': correlations,
|
| 237 |
'guardrail': (
|
| 238 |
-
'
|
| 239 |
-
'
|
| 240 |
-
'controls rather than inferred from correlation alone.'
|
| 241 |
),
|
| 242 |
}
|
| 243 |
-
(artifact_dir / 'study_summary.json').write_text(
|
| 244 |
-
|
| 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(
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
return parser.parse_args()
|
| 23 |
|
| 24 |
|
| 25 |
def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
|
| 26 |
scored = catalog.copy()
|
| 27 |
-
scored[
|
|
|
|
|
|
|
| 28 |
ordered = scored.sort_values(
|
| 29 |
-
[
|
| 30 |
ascending=[True, False, False],
|
| 31 |
)
|
| 32 |
-
return ordered.groupby(
|
| 33 |
|
| 34 |
|
| 35 |
def _effect_column(frame: pd.DataFrame) -> str:
|
| 36 |
-
""
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
return 'target_logprob_delta'
|
| 40 |
|
| 41 |
|
| 42 |
-
def
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 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 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
)
|
| 77 |
-
|
| 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 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 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 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 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 |
-
"""
|
| 160 |
metric = _effect_column(frame)
|
| 161 |
sae = (
|
| 162 |
-
frame[frame[
|
| 163 |
.groupby(index, as_index=False)[metric]
|
| 164 |
.first()
|
| 165 |
-
.rename(columns={metric:
|
| 166 |
)
|
| 167 |
random = (
|
| 168 |
-
frame[frame[
|
| 169 |
-
.assign(
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
.mean()
|
| 172 |
-
.rename(columns={
|
| 173 |
)
|
| 174 |
-
paired = sae.merge(random, on=index, how=
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
| 178 |
return {
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
}
|
| 187 |
-
|
| 188 |
-
low, high = paired_bootstrap_difference_ci(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
return {
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
}
|
| 198 |
|
| 199 |
|
| 200 |
-
def
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 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 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 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 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
ratio = float(single['ratio'])
|
| 257 |
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
)
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
)
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
interpretation = (
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
|
|
|
| 281 |
)
|
| 282 |
-
elif
|
| 283 |
interpretation = (
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
| 287 |
)
|
| 288 |
else:
|
| 289 |
interpretation = (
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
| 292 |
)
|
| 293 |
|
| 294 |
-
if
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
)
|
| 318 |
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
headline = (
|
| 321 |
-
f
|
| 322 |
-
f
|
| 323 |
-
|
|
|
|
|
|
|
| 324 |
)
|
|
|
|
| 325 |
highlights = [
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
]
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
| 334 |
highlights.append(
|
| 335 |
-
f
|
| 336 |
-
f
|
| 337 |
-
f
|
| 338 |
-
f'
|
|
|
|
| 339 |
)
|
| 340 |
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
highlights.extend(
|
| 346 |
[
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
]
|
| 351 |
)
|
| 352 |
|
| 353 |
summary = {
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
},
|
| 377 |
}
|
| 378 |
-
(
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 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 |
-
'',
|
| 417 |
-
'',
|
| 418 |
-
'',
|
| 419 |
-
'',
|
| 420 |
-
'',
|
| 421 |
-
]
|
| 422 |
-
if feature_sets is not None and not feature_sets.empty:
|
| 423 |
-
lines.extend(['', ''])
|
| 424 |
-
if not study.empty:
|
| 425 |
-
lines.extend(
|
| 426 |
-
[
|
| 427 |
-
'',
|
| 428 |
-
'',
|
| 429 |
-
'',
|
| 430 |
-
'',
|
| 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 |
-
|
|
|
|
| 451 |
print(headline)
|
| 452 |
-
print(f
|
| 453 |
|
| 454 |
|
| 455 |
-
if __name__ ==
|
| 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 |
+
"",
|
| 500 |
+
"",
|
| 501 |
+
"",
|
| 502 |
+
"",
|
| 503 |
+
"",
|
| 504 |
+
"",
|
| 505 |
+
"",
|
| 506 |
+
]
|
| 507 |
+
|
| 508 |
+
if feature_sets is not None and not feature_sets.empty:
|
| 509 |
+
lines.extend(
|
| 510 |
+
[
|
| 511 |
+
"",
|
| 512 |
+
"",
|
| 513 |
+
]
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
if not study.empty:
|
| 517 |
+
lines.extend(
|
| 518 |
+
[
|
| 519 |
+
"",
|
| 520 |
+
"",
|
| 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 |
-
|
| 84 |
run(
|
| 85 |
'experiments.run_causal',
|
| 86 |
-
outputs=[
|
| 87 |
resume=args.resume,
|
| 88 |
-
extra_args=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
| 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 |
-
|
| 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[:,
|
| 110 |
-
modified[:,
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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, (
|
| 282 |
-
|
| 283 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
|
| 340 |
_write_rows_atomic(args.output, results)
|
| 341 |
marker.write_text('complete\n', encoding='utf-8')
|
| 342 |
-
print(f'Wrote {len(results)} causal
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 78 |
-
permuted = np.abs((signs *
|
| 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 |
-
'
|
|
|
|
| 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
|
| 59 |
-
f'Missing: {missing}{suffix}\n\n'
|
| 60 |
-
'
|
| 61 |
-
'
|
| 62 |
-
'
|
| 63 |
-
'
|
| 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('
|
| 71 |
-
js_corr = correlations.get('
|
| 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 |
-
'
|
| 81 |
-
'
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
},
|
| 15 |
-
|
| 16 |
-
|
| 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.
|
| 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
|
| 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.
|
| 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.
|
| 364 |
-
raise SystemExit('pyproject.toml must declare version 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.
|
| 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 |
-
'
|
|
|
|
|
|
|
| 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 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
_require_columns(
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 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 |
-
'
|
| 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"
|
|
|
|
| 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
|