Spaces:
Running on Zero
Running on Zero
Commit ·
6d68f94
1
Parent(s): b3d11b8
Release FeatureLens v0.4.0
Browse files- CHANGELOG.md +35 -0
- README.md +165 -106
- app.py +718 -349
- docs/HF_DEPLOY.md +57 -23
- docs/METHODOLOGY.md +239 -44
- docs/VALIDATION.md +408 -169
- experiments/make_report.py +19 -10
- experiments/run_causal.py +62 -32
- experiments/run_feature_sets.py +44 -19
- featurelens/config.py +2 -0
- featurelens/runtime.py +599 -217
- pyproject.toml +1 -1
- research_config.json +16 -2
- scripts/release_check.py +24 -14
- tests/test_report_controls.py +50 -0
CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
| 1 |
# Changelog
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## v0.3.0
|
| 4 |
|
| 5 |
### Causal measurement
|
|
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
+
## v0.4.0
|
| 4 |
+
|
| 5 |
+
### Causal correctness
|
| 6 |
+
- Added an explicit **batched zero-edit reference** to live causal batches so intervention effects are measured against the same execution context as edited rows.
|
| 7 |
+
- Changed the scale dose-response reference to the batched `1×` row. The `1×` row is therefore an exact causal no-op by construction rather than a separately executed baseline comparison.
|
| 8 |
+
- Added execution-context drift diagnostics so any remaining single-forward vs batched-forward numerical difference is reported as instrumentation drift, not causal signal.
|
| 9 |
+
|
| 10 |
+
### Stronger negative controls
|
| 11 |
+
- Replaced the single live random residual direction with an **8-direction norm-matched random ensemble**.
|
| 12 |
+
- Single-feature, joint feature-set, and 1/3/5 set-size experiments now report random signed mean, mean absolute effect, standard deviation, targeted/random magnitude ratio, and a small-sample empirical tail probability.
|
| 13 |
+
- Updated offline causal and feature-set runners to use the same zero-edit reference and configurable random-control ensembles.
|
| 14 |
+
- Updated report pairing so each targeted intervention is compared with the mean absolute effect of its complete random-control ensemble rather than an arbitrary first control.
|
| 15 |
+
|
| 16 |
+
### Distributed causality
|
| 17 |
+
- Added **individual-vs-joint ablation decomposition** for 2–5 features.
|
| 18 |
+
- Reports the individual effects, additive expectation, observed joint effect, interaction excess, and normalized non-additivity.
|
| 19 |
+
- The UI explicitly treats non-additivity as a diagnostic, not proof of a direct feature-feature circuit.
|
| 20 |
+
|
| 21 |
+
### Representation robustness and evidence
|
| 22 |
+
- Added **prompt-wide paraphrase robustness** using max activation per SAE feature across all prompt tokens, alongside the stricter selected-token comparison.
|
| 23 |
+
- Added a live **controlled concept contrast scan** over the seven balanced discovery concepts for one selected feature.
|
| 24 |
+
- Concept contrast results remain exploratory and never overwrite `Offline concept hint` or claim a semantic label from the live scan.
|
| 25 |
+
|
| 26 |
+
### UI and export
|
| 27 |
+
- Reworked the visual language toward a restrained, print-inspired interface with serif typography, flatter controls, thin rules, and muted chart colors.
|
| 28 |
+
- Replaced intervention radio pills with conventional dropdown controls and realigned the Feature Sets form.
|
| 29 |
+
- Added extra bottom spacing plus an explicit end-of-workbench footer to avoid an app-controlled abrupt cutoff in embedded Spaces.
|
| 30 |
+
- Added **Copy table with headers** actions for every major output table; copied text is tab-separated and begins with the column names.
|
| 31 |
+
- Added a dedicated **Feature evidence** tab for controlled feature-concept contrast tests.
|
| 32 |
+
|
| 33 |
+
### Validation
|
| 34 |
+
- Expanded the software suite to **29 tests**.
|
| 35 |
+
- Added report tests that verify random-control ensembles are aggregated correctly before paired causal statistics are computed.
|
| 36 |
+
- Rewrote `docs/VALIDATION.md` around exact v0.4 UI labels, including explicit numerical-null, table-copy, adversarial, responsive-layout, and queue tests.
|
| 37 |
+
|
| 38 |
## v0.3.0
|
| 39 |
|
| 40 |
### Causal measurement
|
README.md
CHANGED
|
@@ -13,149 +13,202 @@ license: mit
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
-
> **v0.
|
| 17 |
|
| 18 |
-
**
|
| 19 |
|
| 20 |
> Do sparse features that predict a concept also causally influence model behaviour?
|
| 21 |
|
| 22 |
-
|
| 23 |
|
| 24 |
-
FeatureLens is
|
| 25 |
|
| 26 |
## Evidence ladder
|
| 27 |
|
| 28 |
-
FeatureLens keeps several claims separate:
|
| 29 |
-
|
| 30 |
1. **Reconstruction** — does the SAE represent the residual stream reasonably well?
|
| 31 |
2. **Prediction** — does a feature distinguish a controlled concept on held-out paraphrase groups?
|
| 32 |
-
3. **Robustness** — does
|
| 33 |
-
4. **
|
| 34 |
-
5. **
|
| 35 |
-
6. **
|
| 36 |
-
7. **
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
A
|
| 39 |
|
| 40 |
-
##
|
| 41 |
|
| 42 |
-
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
-
|
| 66 |
-
- **mean target log probability per token**;
|
| 67 |
-
- SAE-edit deltas;
|
| 68 |
-
- norm-matched random-control deltas;
|
| 69 |
-
- a token-by-token decomposition.
|
| 70 |
|
| 71 |
-
|
| 72 |
|
| 73 |
-
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
|
|
|
|
|
|
| 78 |
|
| 79 |
```text
|
| 80 |
-
|
| 81 |
-
scale α: h' = h + (α - 1) z_i d_i
|
| 82 |
-
inject δ: h' = h + δ d_i
|
| 83 |
```
|
| 84 |
|
| 85 |
-
|
| 86 |
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
```text
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
```
|
| 92 |
|
| 93 |
-
|
| 94 |
|
| 95 |
-
##
|
| 96 |
|
| 97 |
-
The
|
| 98 |
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
-
The
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
-
|
| 108 |
|
| 109 |
-
|
| 110 |
|
| 111 |
-
The
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
```text
|
| 114 |
-
|
|
|
|
|
|
|
| 115 |
```
|
| 116 |
|
| 117 |
-
|
| 118 |
|
| 119 |
-
|
| 120 |
|
| 121 |
-
|
| 122 |
|
| 123 |
-
|
| 124 |
|
| 125 |
-
-
|
| 126 |
-
-
|
| 127 |
-
-
|
| 128 |
-
-
|
|
|
|
| 129 |
|
| 130 |
-
|
| 131 |
|
| 132 |
## Offline experiment
|
| 133 |
|
| 134 |
-
The repository ships
|
| 135 |
|
| 136 |
-
- **224 discovery prompts**
|
| 137 |
-
- **112 paraphrase pairs**
|
| 138 |
-
-
|
| 139 |
- **28 separate causal completion tasks**;
|
| 140 |
-
- residual
|
| 141 |
|
| 142 |
-
The
|
| 143 |
|
| 144 |
- SAE reconstruction cosine / NMSE;
|
| 145 |
- held-out feature/concept AUROC and F1;
|
| 146 |
-
- paraphrase TopK Jaccard and sparse
|
| 147 |
-
-
|
| 148 |
- selected-feature ablation and 2× amplification;
|
| 149 |
-
- exact full-target
|
| 150 |
-
- next-token
|
| 151 |
-
-
|
| 152 |
-
-
|
| 153 |
- bootstrap 95% confidence intervals;
|
| 154 |
-
- paired sign-flip tests
|
| 155 |
|
| 156 |
-
Feature selection
|
| 157 |
|
| 158 |
-
## Run the benchmark
|
| 159 |
|
| 160 |
A CUDA machine is strongly recommended.
|
| 161 |
|
|
@@ -166,7 +219,7 @@ pip install -r requirements.txt
|
|
| 166 |
python experiments/run_all.py
|
| 167 |
```
|
| 168 |
|
| 169 |
-
The pipeline
|
| 170 |
|
| 171 |
```text
|
| 172 |
build_dataset
|
|
@@ -177,15 +230,11 @@ build_dataset
|
|
| 177 |
→ make_report
|
| 178 |
```
|
| 179 |
|
| 180 |
-
Outputs are
|
| 181 |
|
| 182 |
```text
|
| 183 |
artifacts/
|
| 184 |
├── activations/
|
| 185 |
-
│ ├── metadata.json
|
| 186 |
-
│ ├── residuals_layer4.npy
|
| 187 |
-
│ ├── features_layer4.npz
|
| 188 |
-
│ └── ...
|
| 189 |
├── feature_catalog.csv
|
| 190 |
├── layer_metrics.csv
|
| 191 |
├── stability.csv
|
|
@@ -194,19 +243,15 @@ artifacts/
|
|
| 194 |
├── summary.json
|
| 195 |
├── report.md
|
| 196 |
└── figures/
|
| 197 |
-
├── feature_auroc.png
|
| 198 |
-
├��─ layer_diagnostics.png
|
| 199 |
-
├── causal_effects.png
|
| 200 |
-
└── feature_set_effects.png
|
| 201 |
```
|
| 202 |
|
| 203 |
-
`
|
| 204 |
|
| 205 |
-
## Hugging Face
|
| 206 |
|
| 207 |
-
FeatureLens is a **Gradio SDK Space**. The
|
| 208 |
|
| 209 |
-
Useful environment
|
| 210 |
|
| 211 |
```text
|
| 212 |
FEATURELENS_MODEL_ID=Qwen/Qwen3-1.7B-Base
|
|
@@ -215,9 +260,22 @@ FEATURELENS_LAYERS=4,14,26
|
|
| 215 |
FEATURELENS_EAGER_LOAD=1
|
| 216 |
FEATURELENS_SAE_DTYPE=float16
|
| 217 |
FEATURELENS_MAX_NEW_TOKENS=32
|
|
|
|
|
|
|
| 218 |
```
|
| 219 |
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
## Repository layout
|
| 223 |
|
|
@@ -257,21 +315,22 @@ python -m compileall -q app.py featurelens experiments scripts
|
|
| 257 |
python scripts/release_check.py
|
| 258 |
```
|
| 259 |
|
| 260 |
-
See [`docs/VALIDATION.md`](docs/VALIDATION.md)
|
| 261 |
|
| 262 |
-
##
|
| 263 |
|
| 264 |
-
-
|
| 265 |
-
-
|
| 266 |
-
-
|
| 267 |
-
- Joint
|
| 268 |
-
-
|
| 269 |
-
-
|
|
|
|
| 270 |
|
| 271 |
## Resume-ready description
|
| 272 |
|
| 273 |
> **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
|
| 274 |
-
> Built an SAE-based interpretability system for Qwen3-1.7B
|
| 275 |
|
| 276 |
## Acknowledgements
|
| 277 |
|
|
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
+
> **v0.4:** batch-context causal baselines, 8-direction norm-matched random ensembles, individual-vs-joint interaction decomposition, prompt-wide paraphrase robustness, a controlled live concept contrast, copy-ready tables with headers, and a more restrained research-instrument UI.
|
| 17 |
|
| 18 |
+
**Research question:**
|
| 19 |
|
| 20 |
> Do sparse features that predict a concept also causally influence model behaviour?
|
| 21 |
|
| 22 |
+
FeatureLens uses **Qwen3-1.7B-Base** with **Qwen-Scope residual-stream sparse autoencoders (SAEs)**. It is not an SAE viewer clone: the project separates representation quality, held-out predictiveness, robustness, and causal intervention evidence.
|
| 23 |
|
| 24 |
+
FeatureLens is independent of thesis code and thesis datasets.
|
| 25 |
|
| 26 |
## Evidence ladder
|
| 27 |
|
|
|
|
|
|
|
| 28 |
1. **Reconstruction** — does the SAE represent the residual stream reasonably well?
|
| 29 |
2. **Prediction** — does a feature distinguish a controlled concept on held-out paraphrase groups?
|
| 30 |
+
3. **Robustness** — does the representation survive rewording locally and prompt-wide?
|
| 31 |
+
4. **Controlled contrast** — does a selected feature prefer one concept group in a balanced live mini-batch?
|
| 32 |
+
5. **Single-feature causality** — does ablation/scaling change downstream probability?
|
| 33 |
+
6. **Dose-response** — does effect size vary coherently with feature coefficient?
|
| 34 |
+
7. **Feature-set causality** — do small sparse subspaces matter more than one feature?
|
| 35 |
+
8. **Non-additivity** — does a joint edit differ from the sum of individual effects?
|
| 36 |
+
9. **Specificity** — are targeted effects larger than norm-matched random residual perturbations?
|
| 37 |
|
| 38 |
+
A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
|
| 39 |
|
| 40 |
+
## Why v0.4 changed the causal baseline
|
| 41 |
|
| 42 |
+
v0.3 batched multiple residual edits for efficiency, but compared them with a **separately executed baseline**. On real Qwen3 inference this produced a small numerical discrepancy in the nominal `1×` no-edit dose-response row.
|
| 43 |
|
| 44 |
+
v0.4 therefore includes an explicit **batched zero-edit reference** in causal batches. Every effect inside that batch is measured against the zero-edit row from the **same execution context**.
|
| 45 |
+
|
| 46 |
+
For the scale dose-response, the `1×` row itself is the no-edit reference:
|
| 47 |
+
|
| 48 |
+
```text
|
| 49 |
+
0× = full ablation
|
| 50 |
+
0.5× = half native coefficient
|
| 51 |
+
1× = batched no-edit reference
|
| 52 |
+
1.5× = 1.5 times native coefficient
|
| 53 |
+
2× = double native coefficient
|
| 54 |
+
3× = triple native coefficient
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
The UI also reports single-forward → batched-null execution drift as a diagnostic instead of silently treating it as a causal effect.
|
| 58 |
+
|
| 59 |
+
## Random-control ensemble
|
| 60 |
+
|
| 61 |
+
A single random residual direction can be unusually weak or unusually disruptive by chance. v0.4 replaces the live single control with **8 deterministic norm-matched random directions**.
|
| 62 |
|
| 63 |
+
For a targeted residual delta `Δh`, each control satisfies:
|
| 64 |
|
| 65 |
+
```text
|
| 66 |
+
||Δh_random||₂ = ||Δh_SAE||₂
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
The app reports:
|
| 70 |
|
| 71 |
+
- random signed mean effect;
|
| 72 |
+
- random mean absolute effect;
|
| 73 |
+
- random-effect standard deviation;
|
| 74 |
+
- targeted/random magnitude ratio;
|
| 75 |
+
- an exploratory empirical tail probability;
|
| 76 |
+
- equivalent next-token Jensen-Shannon statistics.
|
| 77 |
|
| 78 |
+
The live empirical probability is intentionally coarse with only eight controls. The offline benchmark aggregates across held-out tasks and control ensembles.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
## Live application
|
| 81 |
|
| 82 |
+
### Workbench
|
| 83 |
|
| 84 |
+
The main workflow supports:
|
| 85 |
+
|
| 86 |
+
- prompt entry with explicit **Prompt tokens**;
|
| 87 |
+
- layers **4, 14, 26**;
|
| 88 |
+
- token-local SAE inspection;
|
| 89 |
+
- TopK feature activations;
|
| 90 |
+
- reconstruction cosine / NMSE / Top-5 activation mass;
|
| 91 |
+
- feature ablation, scaling, and injection;
|
| 92 |
+
- baseline vs SAE-edited greedy generation;
|
| 93 |
+
- next-token distribution shifts;
|
| 94 |
+
- exact **full-continuation teacher-forced scoring**;
|
| 95 |
+
- per-target-token log-probability decomposition;
|
| 96 |
+
- 8-direction norm-matched random controls;
|
| 97 |
+
- batched scale dose-response.
|
| 98 |
|
| 99 |
+
### Feature sets
|
| 100 |
+
|
| 101 |
+
FeatureLens can jointly ablate or scale selected active features:
|
| 102 |
|
| 103 |
```text
|
| 104 |
+
h' = h + Σᵢ Δzᵢ dᵢ
|
|
|
|
|
|
|
| 105 |
```
|
| 106 |
|
| 107 |
+
The tab includes:
|
| 108 |
|
| 109 |
+
- manual joint feature-set intervention;
|
| 110 |
+
- automatic **1 / 3 / 5 strongest-feature ablation sweep**;
|
| 111 |
+
- random-ensemble specificity for each set size;
|
| 112 |
+
- **individual-vs-joint non-additivity decomposition** for 2–5 selected features.
|
| 113 |
+
|
| 114 |
+
For the interaction decomposition, FeatureLens compares:
|
| 115 |
|
| 116 |
```text
|
| 117 |
+
observed joint effect
|
| 118 |
+
vs
|
| 119 |
+
sum of individual feature effects
|
| 120 |
```
|
| 121 |
|
| 122 |
+
The difference is reported as an **interaction excess**. A non-zero excess means the downstream response is non-additive under these edits; it does not by itself prove a direct feature-feature circuit.
|
| 123 |
|
| 124 |
+
### Feature evidence
|
| 125 |
|
| 126 |
+
The **Controlled concept contrast** takes a selected feature and evaluates it on a small balanced batch drawn from the repository's seven controlled concept groups:
|
| 127 |
|
| 128 |
+
- code;
|
| 129 |
+
- mathematics;
|
| 130 |
+
- positive sentiment;
|
| 131 |
+
- negative sentiment;
|
| 132 |
+
- French language;
|
| 133 |
+
- factual entities;
|
| 134 |
+
- uncertainty.
|
| 135 |
|
| 136 |
+
One wording per paraphrase pair is used to reduce near-duplicate inflation. The live scan reports mean activation, median activation, activation rate, and maximum activation by concept.
|
| 137 |
|
| 138 |
+
This is an exploratory live feature-evidence tool, **not an automatic semantic label**. The held-out offline AUROC/F1 benchmark remains the stronger claim.
|
| 139 |
+
|
| 140 |
+
### Paraphrase robustness
|
| 141 |
+
|
| 142 |
+
v0.4 reports two complementary views:
|
| 143 |
+
|
| 144 |
+
1. **Selected-token robustness** — TopK Jaccard and sparse cosine at the two manually chosen token positions.
|
| 145 |
+
2. **Prompt-wide robustness** — for each SAE feature, take its maximum activation across all prompt tokens, then compare the two sparse prompt profiles.
|
| 146 |
+
|
| 147 |
+
The prompt-wide view reduces sensitivity to accidentally comparing semantically different final tokens.
|
| 148 |
+
|
| 149 |
+
### Layer trajectory
|
| 150 |
+
|
| 151 |
+
The trajectory view compares SAE reconstruction and sparsity/concentration diagnostics at the same prompt token across layers 4, 14 and 26. It does **not** equate feature IDs across layer-specific SAE dictionaries.
|
| 152 |
|
| 153 |
+
## Copy tables with headers
|
| 154 |
|
| 155 |
+
Every result table that is intended for analysis has an explicit **Copy table with headers** button. The app serializes the displayed data as TSV before copying, so column names are preserved.
|
| 156 |
|
| 157 |
+
The native Dataframe fullscreen control is retained, while the custom copy action avoids relying on browser text selection.
|
| 158 |
+
|
| 159 |
+
## Reconstruction-preserving edits
|
| 160 |
+
|
| 161 |
+
Let `h` be the original residual vector, `z_i` the SAE coefficient, and `d_i` the decoder direction.
|
| 162 |
|
| 163 |
```text
|
| 164 |
+
ablate: h' = h - z_i d_i
|
| 165 |
+
scale α: h' = h + (α - 1) z_i d_i
|
| 166 |
+
inject δ: h' = h + δ d_i
|
| 167 |
```
|
| 168 |
|
| 169 |
+
FeatureLens patches only the decoder-direction **delta** into the original residual. It never replaces the residual with the full SAE reconstruction, avoiding reconstruction error as a causal confound.
|
| 170 |
|
| 171 |
+
## Full-continuation scoring
|
| 172 |
|
| 173 |
+
A target such as `2x` can tokenize into more than one token. FeatureLens concatenates the exact target token IDs to the prompt and scores every target token teacher-forced.
|
| 174 |
|
| 175 |
+
It reports:
|
| 176 |
|
| 177 |
+
- target sequence log probability;
|
| 178 |
+
- mean target log probability per token;
|
| 179 |
+
- per-token log probabilities;
|
| 180 |
+
- targeted edit deltas;
|
| 181 |
+
- random-ensemble comparison.
|
| 182 |
|
| 183 |
+
Greedy generations can remain identical while these probability-level metrics move. A deterministic text change only occurs after an edit crosses an argmax boundary.
|
| 184 |
|
| 185 |
## Offline experiment
|
| 186 |
|
| 187 |
+
The repository ships:
|
| 188 |
|
| 189 |
+
- **224 discovery prompts**;
|
| 190 |
+
- **112 paraphrase pairs**;
|
| 191 |
+
- 7 controlled concepts;
|
| 192 |
- **28 separate causal completion tasks**;
|
| 193 |
+
- residual/SAE activation collection at layers 4, 14 and 26.
|
| 194 |
|
| 195 |
+
The offline pipeline computes:
|
| 196 |
|
| 197 |
- SAE reconstruction cosine / NMSE;
|
| 198 |
- held-out feature/concept AUROC and F1;
|
| 199 |
+
- paraphrase TopK Jaccard and sparse activation cosine;
|
| 200 |
+
- dense residual multinomial linear-probe baselines;
|
| 201 |
- selected-feature ablation and 2× amplification;
|
| 202 |
+
- exact full-target mean log probability per token;
|
| 203 |
+
- next-token JS divergence and top-1 changes;
|
| 204 |
+
- **random-control ensembles** rather than one arbitrary random direction;
|
| 205 |
+
- top-1 / top-3 / top-5 joint feature-set ablations;
|
| 206 |
- bootstrap 95% confidence intervals;
|
| 207 |
+
- paired sign-flip tests.
|
| 208 |
|
| 209 |
+
Feature selection is performed using the **training split only**. Paraphrase pairs never cross the train/test boundary.
|
| 210 |
|
| 211 |
+
## Run the offline benchmark
|
| 212 |
|
| 213 |
A CUDA machine is strongly recommended.
|
| 214 |
|
|
|
|
| 219 |
python experiments/run_all.py
|
| 220 |
```
|
| 221 |
|
| 222 |
+
The pipeline is:
|
| 223 |
|
| 224 |
```text
|
| 225 |
build_dataset
|
|
|
|
| 230 |
→ make_report
|
| 231 |
```
|
| 232 |
|
| 233 |
+
Outputs are generated under `artifacts/`:
|
| 234 |
|
| 235 |
```text
|
| 236 |
artifacts/
|
| 237 |
├── activations/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
├── feature_catalog.csv
|
| 239 |
├── layer_metrics.csv
|
| 240 |
├── stability.csv
|
|
|
|
| 243 |
├── summary.json
|
| 244 |
├── report.md
|
| 245 |
└── figures/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
```
|
| 247 |
|
| 248 |
+
`summary.json` and `report.md` are generated from measured artifacts. No scientific benchmark numbers are fabricated in the repository.
|
| 249 |
|
| 250 |
+
## Hugging Face deployment
|
| 251 |
|
| 252 |
+
FeatureLens is a **Gradio SDK Space**. The app keeps SSR disabled and loads only SAE layers 4, 14 and 26 for the live workbench.
|
| 253 |
|
| 254 |
+
Useful environment variables:
|
| 255 |
|
| 256 |
```text
|
| 257 |
FEATURELENS_MODEL_ID=Qwen/Qwen3-1.7B-Base
|
|
|
|
| 260 |
FEATURELENS_EAGER_LOAD=1
|
| 261 |
FEATURELENS_SAE_DTYPE=float16
|
| 262 |
FEATURELENS_MAX_NEW_TOKENS=32
|
| 263 |
+
FEATURELENS_LIVE_RANDOM_CONTROLS=8
|
| 264 |
+
FEATURELENS_CONTRAST_PROMPTS_PER_CONCEPT=4
|
| 265 |
```
|
| 266 |
|
| 267 |
+
## UI design
|
| 268 |
+
|
| 269 |
+
v0.4 intentionally moves away from a rounded, saturated dashboard aesthetic:
|
| 270 |
+
|
| 271 |
+
- Times / Liberation Serif-style formal typography;
|
| 272 |
+
- restrained teal, umber, stone, plum, and muted red chart accents;
|
| 273 |
+
- flatter controls and smaller corner radii;
|
| 274 |
+
- aligned intervention forms;
|
| 275 |
+
- explicit section rules instead of pill badges;
|
| 276 |
+
- extra bottom padding and a visible end-of-workbench footer to avoid an abrupt embedded-page cutoff.
|
| 277 |
+
|
| 278 |
+
The extra bottom spacing cannot control Hugging Face's outer iframe resizing, but it prevents the app itself from ending flush against its final component.
|
| 279 |
|
| 280 |
## Repository layout
|
| 281 |
|
|
|
|
| 315 |
python scripts/release_check.py
|
| 316 |
```
|
| 317 |
|
| 318 |
+
See [`docs/VALIDATION.md`](docs/VALIDATION.md). The validation guide uses the **exact labels shown in the v0.4 UI** and defines expected outcomes for normal, edge, and adversarial cases.
|
| 319 |
|
| 320 |
+
## Limitations
|
| 321 |
|
| 322 |
+
- SAE features are sparse directions, not guaranteed monosemantic concepts.
|
| 323 |
+
- The live concept contrast is a small controlled diagnostic, not a universal ontology.
|
| 324 |
+
- Prompt-wide max pooling measures feature presence anywhere in the prompt and discards token order.
|
| 325 |
+
- Joint residual edits can be non-additive downstream without implying direct feature-feature interaction.
|
| 326 |
+
- Eight live random controls give only a coarse empirical tail probability.
|
| 327 |
+
- Teacher-forced target scoring measures probability assigned to a specified continuation, not sampled free-running sequence probability.
|
| 328 |
+
- All causal effects remain prompt-, token-, layer-, feature-, and intervention-scale dependent.
|
| 329 |
|
| 330 |
## Resume-ready description
|
| 331 |
|
| 332 |
> **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
|
| 333 |
+
> Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, local/prompt-wide paraphrase robustness, reconstruction-preserving single and multi-feature interventions, full-continuation scoring, dose-response analysis, non-additivity tests, and norm-matched random-control ensembles.
|
| 334 |
|
| 335 |
## Acknowledgements
|
| 336 |
|
app.py
CHANGED
|
@@ -7,69 +7,179 @@ from featurelens.config import SETTINGS
|
|
| 7 |
from featurelens.hf_runtime import gpu
|
| 8 |
from featurelens.runtime import RUNTIME
|
| 9 |
|
| 10 |
-
#
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
.
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
.research-q {
|
| 23 |
-
|
| 24 |
padding: 10px 14px;
|
| 25 |
-
|
| 26 |
-
border-
|
|
|
|
| 27 |
background: var(--background-fill-secondary);
|
| 28 |
}
|
| 29 |
-
.
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
font-size:
|
|
|
|
| 36 |
}
|
| 37 |
-
.
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
margin-top:2px;
|
| 44 |
}
|
| 45 |
-
.token-wrap { display:flex; flex-wrap:wrap; gap:5px; padding:6px 2px 10px; line-height:1.7; }
|
| 46 |
.token {
|
| 47 |
-
background:var(--background-fill-secondary);
|
| 48 |
-
border:1px solid var(--border-color-primary);
|
| 49 |
-
border-radius:
|
| 50 |
-
padding:2px 7px;
|
| 51 |
-
font-family:ui-monospace,SFMono-Regular,monospace;
|
| 52 |
-
font-size:12px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
-
.
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
background:var(--background-fill-secondary);
|
| 60 |
-
border-radius:12px;
|
| 61 |
-
padding:10px 12px;
|
| 62 |
}
|
| 63 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
"""
|
| 65 |
|
| 66 |
-
THEME = gr.themes.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
def _raise_ui_error(exc: Exception) -> None:
|
| 70 |
raise gr.Error(f"{type(exc).__name__}: {exc}") from exc
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
def _analysis_metrics_markdown(result) -> str:
|
| 74 |
return (
|
| 75 |
"#### Analysis metrics\n"
|
|
@@ -82,6 +192,10 @@ def _analysis_metrics_markdown(result) -> str:
|
|
| 82 |
|
| 83 |
|
| 84 |
def _intervention_metrics_markdown(result) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
target = (
|
| 86 |
"No target continuation supplied. The causal comparison therefore uses only "
|
| 87 |
"next-token Jensen-Shannon divergence."
|
|
@@ -92,29 +206,31 @@ def _intervention_metrics_markdown(result) -> str:
|
|
| 92 |
f"**Target continuation:** {result.target_token_count} token(s): {tokens} \n"
|
| 93 |
f"Sequence log p — baseline: **{result.baseline_sequence_logprob:.4f}** · "
|
| 94 |
f"SAE edit: **{result.modified_sequence_logprob:.4f}** · "
|
| 95 |
-
f"random
|
| 96 |
f"SAE Δ sequence log p: **{result.sequence_logprob_delta:+.4f}** · "
|
| 97 |
-
f"random Δ: **{result.random_sequence_logprob_delta:+.4f}** \n"
|
| 98 |
f"SAE Δ mean log p/token: **{result.mean_logprob_delta:+.4f}** · "
|
| 99 |
-
f"random Δ: **{result.
|
| 100 |
-
f"
|
|
|
|
| 101 |
)
|
| 102 |
inactive = ""
|
| 103 |
if abs(result.feature_activation) < 1e-12:
|
| 104 |
inactive = (
|
| 105 |
-
" \n
|
| 106 |
-
"
|
| 107 |
)
|
| 108 |
return (
|
| 109 |
f"Original feature activation: **{result.feature_activation:.4f}** · "
|
| 110 |
f"Δ coefficient: **{result.delta_activation:+.4f}** \n"
|
| 111 |
f"Perturbation L2: **{result.perturbation_norm:.4f}** \n"
|
| 112 |
f"Next-token JS: **{result.js_divergence:.6f}** · "
|
| 113 |
-
f"random
|
| 114 |
-
f"
|
| 115 |
-
f"{
|
| 116 |
-
"
|
| 117 |
-
"
|
|
|
|
| 118 |
)
|
| 119 |
|
| 120 |
|
|
@@ -127,7 +243,10 @@ def _dose_metrics_markdown(result) -> str:
|
|
| 127 |
f"Feature activation at baseline: **{result.feature_activation:.4f}** · "
|
| 128 |
f"target continuation: {len(result.target_tokens)} token(s): {tokens}.{inactive} \n\n"
|
| 129 |
"This panel is **always a scale intervention**: 0× = ablation, 1× = no edit, "
|
| 130 |
-
"2× = double the
|
|
|
|
|
|
|
|
|
|
| 131 |
)
|
| 132 |
|
| 133 |
|
|
@@ -135,8 +254,7 @@ def _feature_set_metrics_markdown(result) -> str:
|
|
| 135 |
tokens = " ".join(repr(token) for token in result.target_tokens)
|
| 136 |
inactive_count = sum(abs(float(row[1])) < 1e-12 for row in result.feature_rows)
|
| 137 |
inactive_note = (
|
| 138 |
-
f" \n{inactive_count} selected feature(s) were inactive and
|
| 139 |
-
"delta under ablation/scale."
|
| 140 |
if inactive_count
|
| 141 |
else ""
|
| 142 |
)
|
|
@@ -145,25 +263,58 @@ def _feature_set_metrics_markdown(result) -> str:
|
|
| 145 |
f"perturbation L2: **{result.perturbation_norm:.4f}** \n"
|
| 146 |
f"Target continuation: {len(result.target_tokens)} token(s): {tokens} \n"
|
| 147 |
f"SAE Δ mean log p/token: **{result.mean_logprob_delta:+.4f}** · "
|
| 148 |
-
f"random
|
| 149 |
-
f"
|
|
|
|
| 150 |
f"SAE Δ sequence log p: **{result.sequence_logprob_delta:+.4f}** · "
|
| 151 |
-
f"random Δ: **{result.random_sequence_logprob_delta:+.4f}** \n"
|
| 152 |
-
f"Next-token JS: **{result.js_divergence:.6f}** · "
|
| 153 |
-
f"
|
| 154 |
-
f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
)
|
| 156 |
|
| 157 |
|
| 158 |
def _paraphrase_metrics_markdown(result) -> str:
|
| 159 |
return (
|
| 160 |
"#### Robustness metrics\n"
|
| 161 |
-
|
| 162 |
-
f"
|
| 163 |
-
f"
|
| 164 |
-
f"**{result.shared_top_n}/{result.top_n}** \n\n"
|
| 165 |
-
"
|
| 166 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
)
|
| 168 |
|
| 169 |
|
|
@@ -173,12 +324,12 @@ def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
|
|
| 173 |
if not prompt.strip():
|
| 174 |
raise ValueError("Enter a prompt first.")
|
| 175 |
result = RUNTIME.analyze(prompt, int(layer), int(token_index), int(top_n))
|
|
|
|
|
|
|
| 176 |
choices = [str(int(row[1])) for row in result.rows]
|
| 177 |
feature_update = gr.update(choices=choices, value=choices[0] if choices else None)
|
| 178 |
-
feature_set_update = gr.update(
|
| 179 |
-
|
| 180 |
-
value=choices[: min(3, len(choices))],
|
| 181 |
-
)
|
| 182 |
chart_df = pd.DataFrame(
|
| 183 |
{
|
| 184 |
"Feature": [str(int(row[1])) for row in result.rows],
|
|
@@ -186,13 +337,22 @@ def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
|
|
| 186 |
"Series": ["Activation"] * len(result.rows),
|
| 187 |
}
|
| 188 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
return (
|
| 190 |
RUNTIME.token_html(result.tokens, result.token_index),
|
| 191 |
-
|
| 192 |
chart_df,
|
| 193 |
feature_update,
|
| 194 |
feature_set_update,
|
|
|
|
|
|
|
| 195 |
_analysis_metrics_markdown(result),
|
|
|
|
|
|
|
|
|
|
| 196 |
)
|
| 197 |
except Exception as exc:
|
| 198 |
_raise_ui_error(exc)
|
|
@@ -214,36 +374,43 @@ def run_intervention(
|
|
| 214 |
raise ValueError("Enter a prompt first.")
|
| 215 |
if feature_id is None or str(feature_id).strip() == "":
|
| 216 |
raise ValueError("Choose or enter a feature id.")
|
| 217 |
-
fid = int(float(feature_id))
|
| 218 |
result = RUNTIME.intervene(
|
| 219 |
text=prompt,
|
| 220 |
layer=int(layer),
|
| 221 |
token_index=int(token_index),
|
| 222 |
-
feature_id=
|
| 223 |
mode=mode,
|
| 224 |
coefficient=float(coefficient),
|
| 225 |
target_text=target_text,
|
| 226 |
max_new_tokens=int(max_new_tokens),
|
| 227 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
return (
|
| 229 |
result.baseline_text,
|
| 230 |
result.modified_text,
|
| 231 |
_intervention_metrics_markdown(result),
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
| 234 |
)
|
| 235 |
except Exception as exc:
|
| 236 |
_raise_ui_error(exc)
|
| 237 |
|
| 238 |
|
| 239 |
@gpu(duration=35)
|
| 240 |
-
def run_dose_response(
|
| 241 |
-
prompt: str,
|
| 242 |
-
layer: int,
|
| 243 |
-
token_index: int,
|
| 244 |
-
feature_id: str,
|
| 245 |
-
target_text: str,
|
| 246 |
-
):
|
| 247 |
try:
|
| 248 |
if not prompt.strip():
|
| 249 |
raise ValueError("Enter a prompt first.")
|
|
@@ -262,7 +429,7 @@ def run_dose_response(
|
|
| 262 |
"Multiplier",
|
| 263 |
"Δ feature coefficient",
|
| 264 |
"Perturbation L2",
|
| 265 |
-
"
|
| 266 |
"Modified mean log p/token",
|
| 267 |
"Δ mean log p/token",
|
| 268 |
"Δ sequence log p",
|
|
@@ -271,7 +438,7 @@ def run_dose_response(
|
|
| 271 |
table = pd.DataFrame(result.rows, columns=columns)
|
| 272 |
plot = table[["Multiplier", "Δ mean log p/token"]].copy()
|
| 273 |
plot["Series"] = "SAE feature"
|
| 274 |
-
return table, plot, _dose_metrics_markdown(result)
|
| 275 |
except Exception as exc:
|
| 276 |
_raise_ui_error(exc)
|
| 277 |
|
|
@@ -298,12 +465,12 @@ def run_layer_sweep(prompt: str, token_index: int):
|
|
| 298 |
var_name="Metric",
|
| 299 |
value_name="Value",
|
| 300 |
)
|
| 301 |
-
return RUNTIME.token_html(result.tokens, result.token_index), table, long
|
| 302 |
except Exception as exc:
|
| 303 |
_raise_ui_error(exc)
|
| 304 |
|
| 305 |
|
| 306 |
-
@gpu(duration=
|
| 307 |
def run_feature_set(
|
| 308 |
prompt: str,
|
| 309 |
layer: int,
|
|
@@ -315,7 +482,7 @@ def run_feature_set(
|
|
| 315 |
):
|
| 316 |
try:
|
| 317 |
if not prompt.strip():
|
| 318 |
-
raise ValueError("Enter a prompt in the Workbench first.")
|
| 319 |
selected = [int(float(value)) for value in (feature_ids or [])]
|
| 320 |
if not selected:
|
| 321 |
raise ValueError("Select at least one feature in 'Feature set'.")
|
|
@@ -330,21 +497,34 @@ def run_feature_set(
|
|
| 330 |
coefficient=float(coefficient),
|
| 331 |
target_text=target_text,
|
| 332 |
)
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
except Exception as exc:
|
| 335 |
_raise_ui_error(exc)
|
| 336 |
|
| 337 |
|
| 338 |
-
@gpu(duration=
|
| 339 |
-
def run_feature_set_sweep(
|
| 340 |
-
prompt: str,
|
| 341 |
-
layer: int,
|
| 342 |
-
token_index: int,
|
| 343 |
-
target_text: str,
|
| 344 |
-
):
|
| 345 |
try:
|
| 346 |
if not prompt.strip():
|
| 347 |
-
raise ValueError("Enter a prompt in the Workbench first.")
|
| 348 |
if not target_text.strip():
|
| 349 |
raise ValueError("Enter a target continuation before running the set-size sweep.")
|
| 350 |
result = RUNTIME.feature_set_size_sweep(
|
|
@@ -357,28 +537,78 @@ def run_feature_set_sweep(
|
|
| 357 |
"Set size k",
|
| 358 |
"Feature ids",
|
| 359 |
"Perturbation L2",
|
| 360 |
-
"
|
| 361 |
"SAE mean log p/token",
|
| 362 |
"SAE Δ mean log p/token",
|
| 363 |
-
"Random
|
| 364 |
-
"
|
|
|
|
|
|
|
|
|
|
| 365 |
"SAE Δ sequence log p",
|
| 366 |
"SAE next-token JS",
|
| 367 |
-
"Random
|
|
|
|
|
|
|
| 368 |
]
|
| 369 |
table = pd.DataFrame(result.rows, columns=columns)
|
| 370 |
-
plot_rows = []
|
| 371 |
for _, row in table.iterrows():
|
| 372 |
plot_rows.append([row["Set size k"], "Top-k SAE ablation", row["SAE Δ mean log p/token"]])
|
| 373 |
-
plot_rows.append([row["Set size k"], "
|
| 374 |
plot = pd.DataFrame(plot_rows, columns=["Set size k", "Condition", "Δ mean log p/token"])
|
| 375 |
tokens = " ".join(repr(token) for token in result.target_tokens)
|
| 376 |
note = (
|
| 377 |
-
f"Target continuation: {len(result.target_tokens)} token(s): {tokens}. "
|
| 378 |
-
"
|
| 379 |
-
"
|
|
|
|
|
|
|
|
|
|
| 380 |
)
|
| 381 |
-
return table, plot, note
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
except Exception as exc:
|
| 383 |
_raise_ui_error(exc)
|
| 384 |
|
|
@@ -401,72 +631,75 @@ def run_paraphrase_compare(
|
|
| 401 |
token_index_b=int(token_index_b),
|
| 402 |
top_n=int(top_n),
|
| 403 |
)
|
|
|
|
|
|
|
| 404 |
chart = pd.DataFrame(result.chart_rows, columns=["Feature", "Prompt", "Activation"])
|
| 405 |
return (
|
| 406 |
RUNTIME.token_html(result.tokens_a, result.token_index_a),
|
| 407 |
RUNTIME.token_html(result.tokens_b, result.token_index_b),
|
| 408 |
_paraphrase_metrics_markdown(result),
|
| 409 |
-
|
| 410 |
chart,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
except Exception as exc:
|
| 413 |
_raise_ui_error(exc)
|
| 414 |
|
| 415 |
|
| 416 |
def mode_help(mode: str):
|
| 417 |
if mode == "ablate":
|
| 418 |
-
return gr.update(
|
| 419 |
-
value=0.0,
|
| 420 |
-
interactive=False,
|
| 421 |
-
label="Coefficient (unused for ablation)",
|
| 422 |
-
info="Sets the selected active feature coefficient to zero.",
|
| 423 |
-
)
|
| 424 |
if mode == "scale":
|
| 425 |
-
return gr.update(
|
| 426 |
-
|
| 427 |
-
interactive=True,
|
| 428 |
-
label="Feature multiplier",
|
| 429 |
-
info="1 = unchanged, 0 = ablate, 2 = double the original coefficient.",
|
| 430 |
-
)
|
| 431 |
-
return gr.update(
|
| 432 |
-
value=5.0,
|
| 433 |
-
interactive=True,
|
| 434 |
-
label="Additive feature coefficient",
|
| 435 |
-
info="Adds this amount along the decoder direction, even when the feature is inactive.",
|
| 436 |
-
)
|
| 437 |
|
| 438 |
|
| 439 |
def set_mode_help(mode: str):
|
| 440 |
if mode == "ablate":
|
| 441 |
-
return gr.update(
|
| 442 |
-
|
| 443 |
-
interactive=False,
|
| 444 |
-
label="Set coefficient (unused for ablation)",
|
| 445 |
-
info="Jointly sets every selected active feature coefficient to zero.",
|
| 446 |
-
)
|
| 447 |
-
return gr.update(
|
| 448 |
-
value=2.0,
|
| 449 |
-
interactive=True,
|
| 450 |
-
label="Shared feature multiplier",
|
| 451 |
-
info="Applies the same multiplier to each selected feature before summing decoder deltas.",
|
| 452 |
-
)
|
| 453 |
|
| 454 |
|
| 455 |
with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as demo:
|
| 456 |
gr.HTML(
|
| 457 |
-
'<
|
| 458 |
-
'<
|
| 459 |
-
'<div class="
|
| 460 |
-
'
|
| 461 |
-
'
|
| 462 |
-
'<
|
| 463 |
-
'<
|
| 464 |
-
'
|
| 465 |
-
'<span class="badge">norm-matched controls</span><span class="badge">ZeroGPU</span></div>'
|
| 466 |
)
|
| 467 |
|
| 468 |
with gr.Tab("Workbench"):
|
| 469 |
-
gr.HTML('<div class="
|
| 470 |
with gr.Row(equal_height=False):
|
| 471 |
with gr.Column(scale=5):
|
| 472 |
prompt = gr.Textbox(
|
|
@@ -490,47 +723,50 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 490 |
choices=list(SETTINGS.layers),
|
| 491 |
value=SETTINGS.layers[1] if len(SETTINGS.layers) > 1 else SETTINGS.layers[0],
|
| 492 |
label="Residual layer",
|
| 493 |
-
info="Early / middle / late checkpoints are intentionally sampled.",
|
| 494 |
)
|
| 495 |
token_index = gr.Number(
|
| 496 |
value=-1,
|
| 497 |
precision=0,
|
| 498 |
label="Prompt token index",
|
| 499 |
-
info="-1 = final prompt token.
|
| 500 |
)
|
| 501 |
top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
|
| 502 |
analyze_btn = gr.Button("Inspect sparse features", variant="primary")
|
| 503 |
|
| 504 |
gr.Markdown("#### Prompt tokens\nThe selected prompt token is outlined more strongly.")
|
| 505 |
-
token_view = gr.HTML(
|
|
|
|
|
|
|
| 506 |
analysis_metrics = gr.Markdown()
|
| 507 |
with gr.Row(equal_height=False):
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
|
|
|
|
|
|
|
|
|
| 528 |
|
| 529 |
-
gr.HTML('<div class="
|
| 530 |
gr.Markdown(
|
| 531 |
-
"
|
| 532 |
-
"
|
| 533 |
-
"v0.3 scores the **entire continuation teacher-forced**, not only its first token."
|
| 534 |
)
|
| 535 |
with gr.Row(equal_height=False):
|
| 536 |
with gr.Column(scale=2):
|
|
@@ -538,9 +774,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 538 |
choices=[],
|
| 539 |
allow_custom_value=True,
|
| 540 |
label="Single feature id",
|
| 541 |
-
info="Inspection populates the strongest active features; custom IDs are also allowed.",
|
| 542 |
)
|
| 543 |
-
mode = gr.
|
| 544 |
choices=["ablate", "scale", "inject"],
|
| 545 |
value="ablate",
|
| 546 |
label="Single-feature intervention",
|
|
@@ -553,10 +788,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 553 |
target_text = gr.Textbox(
|
| 554 |
label="Target continuation (optional)",
|
| 555 |
placeholder="e.g. 2x",
|
| 556 |
-
info=
|
| 557 |
-
"Exact text to score after the prompt. v0.3 reports full-sequence and per-token "
|
| 558 |
-
"log probabilities. Include a leading space if that is part of the continuation."
|
| 559 |
-
),
|
| 560 |
)
|
| 561 |
max_new = gr.Slider(
|
| 562 |
4,
|
|
@@ -572,55 +804,60 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 572 |
baseline_out = gr.Textbox(label="Baseline greedy generation", lines=6, interactive=False)
|
| 573 |
modified_out = gr.Textbox(label="SAE-edited greedy generation", lines=6, interactive=False)
|
| 574 |
token_prob_table = gr.Dataframe(
|
| 575 |
-
headers=["Token", "Baseline p", "SAE-edit p", "Δ probability"],
|
| 576 |
-
datatype=["str", "number", "number", "number"],
|
| 577 |
interactive=False,
|
| 578 |
label="Next-token distribution shift",
|
|
|
|
|
|
|
| 579 |
)
|
|
|
|
|
|
|
| 580 |
target_token_table = gr.Dataframe(
|
| 581 |
-
headers=[
|
| 582 |
-
"Target position",
|
| 583 |
-
"Target token",
|
| 584 |
-
"Baseline log p",
|
| 585 |
-
"SAE-edit log p",
|
| 586 |
-
"Random log p",
|
| 587 |
-
"SAE Δ log p",
|
| 588 |
-
"Random Δ log p",
|
| 589 |
-
],
|
| 590 |
-
datatype=["number", "str", "number", "number", "number", "number", "number"],
|
| 591 |
interactive=False,
|
| 592 |
label="Target continuation token-by-token score",
|
|
|
|
|
|
|
| 593 |
)
|
|
|
|
|
|
|
| 594 |
|
| 595 |
-
gr.HTML('<div class="
|
| 596 |
-
with gr.Accordion("
|
| 597 |
gr.Markdown(
|
| 598 |
-
"
|
| 599 |
-
"
|
| 600 |
-
"
|
| 601 |
)
|
| 602 |
dose_btn = gr.Button("Run scale dose-response")
|
| 603 |
dose_metrics = gr.Markdown()
|
| 604 |
-
with gr.Row():
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
|
| 618 |
with gr.Tab("Feature sets"):
|
| 619 |
gr.Markdown(
|
| 620 |
-
"###
|
| 621 |
-
"
|
| 622 |
-
"
|
| 623 |
)
|
|
|
|
| 624 |
feature_set_ids = gr.Dropdown(
|
| 625 |
choices=[],
|
| 626 |
value=[],
|
|
@@ -628,86 +865,180 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 628 |
allow_custom_value=True,
|
| 629 |
max_choices=12,
|
| 630 |
label="Feature set",
|
| 631 |
-
info="Choose several active features from the Workbench inspection, or enter custom IDs.",
|
| 632 |
)
|
| 633 |
-
|
| 634 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
choices=["ablate", "scale"],
|
| 636 |
value="ablate",
|
| 637 |
-
label="
|
| 638 |
-
|
| 639 |
)
|
| 640 |
set_coefficient = gr.Number(
|
| 641 |
value=0.0,
|
| 642 |
interactive=False,
|
| 643 |
-
label="
|
|
|
|
| 644 |
)
|
| 645 |
set_target = gr.Textbox(
|
| 646 |
label="Target continuation",
|
| 647 |
value="2x",
|
| 648 |
-
|
|
|
|
| 649 |
)
|
| 650 |
set_btn = gr.Button("Run joint feature-set causal test", variant="primary")
|
| 651 |
set_metrics = gr.Markdown()
|
| 652 |
-
with gr.Row():
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
"
|
| 666 |
-
"
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
interactive=False,
|
| 672 |
-
label="Target continuation token-by-token score",
|
| 673 |
-
scale=3,
|
| 674 |
-
)
|
| 675 |
|
| 676 |
-
gr.
|
| 677 |
gr.Markdown(
|
| 678 |
-
"
|
| 679 |
-
"
|
| 680 |
-
"are evaluated in one batched forward pass after the baseline."
|
| 681 |
-
)
|
| 682 |
-
set_sweep_target = gr.Textbox(
|
| 683 |
-
label="Target continuation for set-size sweep",
|
| 684 |
-
value="2x",
|
| 685 |
)
|
|
|
|
| 686 |
set_sweep_btn = gr.Button("Run 1/3/5-feature ablation sweep")
|
| 687 |
set_sweep_note = gr.Markdown()
|
| 688 |
-
with gr.Row():
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
scale=2,
|
| 703 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
|
| 705 |
with gr.Tab("Paraphrase robustness"):
|
| 706 |
gr.Markdown(
|
| 707 |
-
"###
|
| 708 |
-
"
|
| 709 |
-
"
|
| 710 |
-
"
|
| 711 |
)
|
| 712 |
with gr.Row():
|
| 713 |
para_a = gr.Textbox(
|
|
@@ -723,7 +1054,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 723 |
with gr.Row():
|
| 724 |
para_layer = gr.Dropdown(
|
| 725 |
choices=list(SETTINGS.layers),
|
| 726 |
-
value=SETTINGS.layers[1]
|
| 727 |
label="Residual layer",
|
| 728 |
)
|
| 729 |
para_idx_a = gr.Number(value=-1, precision=0, label="Original prompt token index")
|
|
@@ -738,33 +1069,33 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 738 |
gr.Markdown("#### Paraphrase tokens")
|
| 739 |
para_tokens_b = gr.HTML()
|
| 740 |
para_metrics = gr.Markdown()
|
| 741 |
-
with gr.Row():
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
|
|
|
|
|
|
| 761 |
|
| 762 |
with gr.Tab("Layer trajectory"):
|
| 763 |
gr.Markdown(
|
| 764 |
-
"###
|
| 765 |
-
"This is
|
| 766 |
-
"It compares reconstruction quality and sparsity/concentration statistics at the same prompt "
|
| 767 |
-
"token across layers 4, 14 and 26."
|
| 768 |
)
|
| 769 |
with gr.Row():
|
| 770 |
trajectory_prompt = gr.Textbox(
|
|
@@ -783,32 +1114,39 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 783 |
trajectory_btn = gr.Button("Compare layers", variant="primary")
|
| 784 |
gr.Markdown("#### Prompt tokens")
|
| 785 |
trajectory_tokens = gr.HTML()
|
| 786 |
-
with gr.Row():
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
|
| 804 |
with gr.Tab("Offline benchmark"):
|
| 805 |
gr.Markdown(RUNTIME.catalog.benchmark_markdown())
|
| 806 |
gr.Markdown(
|
| 807 |
-
"The offline pipeline
|
| 808 |
-
"paraphrase stability, dense residual linear probes,
|
| 809 |
-
"
|
| 810 |
-
"
|
| 811 |
-
"Results are loaded from `artifacts/`; the app never ships invented benchmark numbers."
|
| 812 |
)
|
| 813 |
|
| 814 |
with gr.Tab("Method"):
|
|
@@ -819,42 +1157,41 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 819 |
For residual vector $h$, sparse coefficient $z_i$, decoder direction $d_i$, and scale $\alpha$:
|
| 820 |
|
| 821 |
- **Ablate:** $h' = h - z_i d_i$
|
| 822 |
-
- **Scale:** $h' = h + (\alpha - 1)
|
| 823 |
- **Inject:** $h' = h + \delta d_i$
|
| 824 |
|
| 825 |
-
For a feature set $S$
|
| 826 |
|
| 827 |
-
$$h' = h + \sum_{i \in S}
|
| 828 |
|
| 829 |
-
|
| 830 |
-
as a causal confound.
|
| 831 |
|
| 832 |
-
###
|
| 833 |
|
| 834 |
-
|
| 835 |
-
log probabilities for **every target token**. The primary length-comparable live/offline statistic is the change in
|
| 836 |
-
**mean log probability per target token**. The next-token distribution is still reported separately.
|
| 837 |
|
| 838 |
### Evidence ladder
|
| 839 |
|
| 840 |
-
1.
|
| 841 |
-
2.
|
| 842 |
-
3.
|
| 843 |
-
4.
|
| 844 |
-
5.
|
| 845 |
-
6.
|
| 846 |
-
7.
|
|
|
|
| 847 |
|
| 848 |
-
|
| 849 |
"""
|
| 850 |
)
|
| 851 |
|
| 852 |
-
gr.
|
| 853 |
-
|
| 854 |
-
|
|
|
|
| 855 |
)
|
| 856 |
|
| 857 |
-
# Event wiring
|
| 858 |
analyze_btn.click(
|
| 859 |
analyze_prompt,
|
| 860 |
inputs=[prompt, layer, token_index, top_n],
|
|
@@ -864,7 +1201,12 @@ A high AUROC or high paraphrase overlap alone remains correlational evidence.
|
|
| 864 |
feature_plot,
|
| 865 |
feature_id,
|
| 866 |
feature_set_ids,
|
|
|
|
|
|
|
| 867 |
analysis_metrics,
|
|
|
|
|
|
|
|
|
|
| 868 |
],
|
| 869 |
)
|
| 870 |
mode.change(mode_help, inputs=[mode], outputs=[coefficient])
|
|
@@ -877,37 +1219,64 @@ A high AUROC or high paraphrase overlap alone remains correlational evidence.
|
|
| 877 |
intervention_metrics,
|
| 878 |
token_prob_table,
|
| 879 |
target_token_table,
|
|
|
|
|
|
|
| 880 |
],
|
| 881 |
)
|
| 882 |
dose_btn.click(
|
| 883 |
run_dose_response,
|
| 884 |
inputs=[prompt, layer, token_index, feature_id, target_text],
|
| 885 |
-
outputs=[dose_table, dose_plot, dose_metrics],
|
| 886 |
)
|
| 887 |
set_mode.change(set_mode_help, inputs=[set_mode], outputs=[set_coefficient])
|
| 888 |
set_btn.click(
|
| 889 |
run_feature_set,
|
| 890 |
inputs=[prompt, layer, token_index, feature_set_ids, set_mode, set_coefficient, set_target],
|
| 891 |
-
outputs=[set_feature_table, set_metrics, set_target_table],
|
| 892 |
)
|
| 893 |
set_sweep_btn.click(
|
| 894 |
run_feature_set_sweep,
|
| 895 |
inputs=[prompt, layer, token_index, set_sweep_target],
|
| 896 |
-
outputs=[set_sweep_table, set_sweep_plot, set_sweep_note],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 897 |
)
|
| 898 |
para_btn.click(
|
| 899 |
run_paraphrase_compare,
|
| 900 |
inputs=[para_a, para_b, para_layer, para_idx_a, para_idx_b, para_top_n],
|
| 901 |
-
outputs=[para_tokens_a, para_tokens_b, para_metrics, para_table, para_plot],
|
| 902 |
)
|
| 903 |
trajectory_btn.click(
|
| 904 |
run_layer_sweep,
|
| 905 |
inputs=[trajectory_prompt, trajectory_token],
|
| 906 |
-
outputs=[trajectory_tokens, trajectory_table, trajectory_plot],
|
| 907 |
)
|
| 908 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 909 |
if __name__ == "__main__":
|
| 910 |
-
# Explicit CSR avoids the Gradio SSR auth-coroutine warning seen in earlier Space builds.
|
| 911 |
demo.queue(default_concurrency_limit=1, max_size=8).launch(
|
| 912 |
css=CSS,
|
| 913 |
theme=THEME,
|
|
|
|
| 7 |
from featurelens.hf_runtime import gpu
|
| 8 |
from featurelens.runtime import RUNTIME
|
| 9 |
|
| 10 |
+
# Restrained, print-inspired palette. The app deliberately avoids saturated dashboard colors.
|
| 11 |
+
INK_TEAL = "#708B86"
|
| 12 |
+
INK_UMBER = "#94785D"
|
| 13 |
+
INK_RED = "#986D68"
|
| 14 |
+
INK_PLUM = "#82768F"
|
| 15 |
+
INK_STONE = "#8E8A83"
|
| 16 |
+
INK_BLUEGREY = "#71808A"
|
| 17 |
+
|
| 18 |
+
CSS = r"""
|
| 19 |
+
.gradio-container {
|
| 20 |
+
max-width: 1280px !important;
|
| 21 |
+
padding-bottom: 96px !important;
|
| 22 |
+
font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
|
| 23 |
+
line-height: 1.45;
|
| 24 |
+
}
|
| 25 |
+
.gradio-container input,
|
| 26 |
+
.gradio-container textarea,
|
| 27 |
+
.gradio-container button,
|
| 28 |
+
.gradio-container select,
|
| 29 |
+
.gradio-container label,
|
| 30 |
+
.gradio-container table {
|
| 31 |
+
font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
|
| 32 |
+
}
|
| 33 |
+
.hero {
|
| 34 |
+
padding: 12px 2px 4px;
|
| 35 |
+
border-bottom: 1px solid var(--border-color-primary);
|
| 36 |
+
margin-bottom: 14px;
|
| 37 |
+
}
|
| 38 |
+
.hero h1 {
|
| 39 |
+
margin: 0;
|
| 40 |
+
font-size: 2.15rem;
|
| 41 |
+
font-weight: 600;
|
| 42 |
+
letter-spacing: 0;
|
| 43 |
+
}
|
| 44 |
+
.hero .subtitle {
|
| 45 |
+
margin-top: 3px;
|
| 46 |
+
font-size: 1.02rem;
|
| 47 |
+
opacity: .76;
|
| 48 |
+
}
|
| 49 |
+
.hero .metadata {
|
| 50 |
+
margin-top: 8px;
|
| 51 |
+
font-size: .82rem;
|
| 52 |
+
opacity: .62;
|
| 53 |
+
letter-spacing: .015em;
|
| 54 |
+
}
|
| 55 |
.research-q {
|
| 56 |
+
margin: 12px 0 16px;
|
| 57 |
padding: 10px 14px;
|
| 58 |
+
border-left: 3px solid #708B86;
|
| 59 |
+
border-top: 1px solid var(--border-color-primary);
|
| 60 |
+
border-bottom: 1px solid var(--border-color-primary);
|
| 61 |
background: var(--background-fill-secondary);
|
| 62 |
}
|
| 63 |
+
.section-rule {
|
| 64 |
+
margin: 18px 0 10px;
|
| 65 |
+
padding-top: 8px;
|
| 66 |
+
border-top: 1px solid var(--border-color-primary);
|
| 67 |
+
font-variant: small-caps;
|
| 68 |
+
letter-spacing: .055em;
|
| 69 |
+
font-size: .88rem;
|
| 70 |
+
opacity: .72;
|
| 71 |
}
|
| 72 |
+
.token-wrap {
|
| 73 |
+
display: flex;
|
| 74 |
+
flex-wrap: wrap;
|
| 75 |
+
gap: 5px;
|
| 76 |
+
padding: 7px 2px 13px;
|
| 77 |
+
line-height: 1.9;
|
|
|
|
| 78 |
}
|
|
|
|
| 79 |
.token {
|
| 80 |
+
background: var(--background-fill-secondary);
|
| 81 |
+
border: 1px solid var(--border-color-primary);
|
| 82 |
+
border-radius: 3px;
|
| 83 |
+
padding: 2px 7px;
|
| 84 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
|
| 85 |
+
font-size: 12px;
|
| 86 |
+
}
|
| 87 |
+
.token.selected {
|
| 88 |
+
border: 2px solid #708B86;
|
| 89 |
+
font-weight: 700;
|
| 90 |
+
}
|
| 91 |
+
.token sup { opacity: .55; margin-right: 4px; }
|
| 92 |
+
.small-note { opacity: .70; font-size: 12px; }
|
| 93 |
+
.instrument-note {
|
| 94 |
+
border: 1px solid var(--border-color-primary);
|
| 95 |
+
border-radius: 4px;
|
| 96 |
+
padding: 9px 11px;
|
| 97 |
+
background: var(--background-fill-secondary);
|
| 98 |
+
margin: 5px 0 10px;
|
| 99 |
+
}
|
| 100 |
+
.copy-row { margin-top: -5px; }
|
| 101 |
+
.copy-row button { min-height: 31px !important; }
|
| 102 |
+
.gradio-container button {
|
| 103 |
+
border-radius: 4px !important;
|
| 104 |
+
font-weight: 600 !important;
|
| 105 |
+
}
|
| 106 |
+
.gradio-container .form,
|
| 107 |
+
.gradio-container .block {
|
| 108 |
+
border-radius: 4px !important;
|
| 109 |
+
}
|
| 110 |
+
.gradio-container textarea,
|
| 111 |
+
.gradio-container input {
|
| 112 |
+
border-radius: 3px !important;
|
| 113 |
}
|
| 114 |
+
.form-note {
|
| 115 |
+
margin-top: -3px;
|
| 116 |
+
margin-bottom: 8px;
|
| 117 |
+
font-size: .84rem;
|
| 118 |
+
opacity: .70;
|
|
|
|
|
|
|
|
|
|
| 119 |
}
|
| 120 |
+
.classic-footer {
|
| 121 |
+
margin-top: 36px;
|
| 122 |
+
padding: 18px 2px 42px;
|
| 123 |
+
border-top: 1px solid var(--border-color-primary);
|
| 124 |
+
opacity: .62;
|
| 125 |
+
font-size: .82rem;
|
| 126 |
+
min-height: 92px;
|
| 127 |
+
}
|
| 128 |
+
/* Add breathing room below tabs in embedded Hugging Face iframes. */
|
| 129 |
+
.tabs, .tabitem { padding-bottom: 18px !important; }
|
| 130 |
"""
|
| 131 |
|
| 132 |
+
THEME = gr.themes.Base(
|
| 133 |
+
primary_hue="teal",
|
| 134 |
+
secondary_hue="stone",
|
| 135 |
+
neutral_hue="stone",
|
| 136 |
+
radius_size="sm",
|
| 137 |
+
font=("Times New Roman", "Times", "Liberation Serif", "serif"),
|
| 138 |
+
font_mono=("Liberation Mono", "Consolas", "monospace"),
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
COPY_JS = r"""
|
| 142 |
+
(text) => {
|
| 143 |
+
const value = text || "";
|
| 144 |
+
const fallback = () => {
|
| 145 |
+
const node = document.createElement("textarea");
|
| 146 |
+
node.value = value;
|
| 147 |
+
node.style.position = "fixed";
|
| 148 |
+
node.style.opacity = "0";
|
| 149 |
+
document.body.appendChild(node);
|
| 150 |
+
node.focus();
|
| 151 |
+
node.select();
|
| 152 |
+
document.execCommand("copy");
|
| 153 |
+
document.body.removeChild(node);
|
| 154 |
+
};
|
| 155 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 156 |
+
navigator.clipboard.writeText(value).catch(fallback);
|
| 157 |
+
} else {
|
| 158 |
+
fallback();
|
| 159 |
+
}
|
| 160 |
+
return [];
|
| 161 |
+
}
|
| 162 |
+
"""
|
| 163 |
|
| 164 |
|
| 165 |
def _raise_ui_error(exc: Exception) -> None:
|
| 166 |
raise gr.Error(f"{type(exc).__name__}: {exc}") from exc
|
| 167 |
|
| 168 |
|
| 169 |
+
def _tsv(frame: pd.DataFrame) -> str:
|
| 170 |
+
if frame is None or frame.empty:
|
| 171 |
+
return ""
|
| 172 |
+
return frame.to_csv(sep="\t", index=False, lineterminator="\n")
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _copy_button(label: str = "Copy table with headers") -> gr.Button:
|
| 176 |
+
return gr.Button(label, size="sm", variant="secondary")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _bind_copy(button: gr.Button, source: gr.Textbox) -> None:
|
| 180 |
+
button.click(fn=None, inputs=[source], outputs=None, js=COPY_JS, queue=False)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
def _analysis_metrics_markdown(result) -> str:
|
| 184 |
return (
|
| 185 |
"#### Analysis metrics\n"
|
|
|
|
| 192 |
|
| 193 |
|
| 194 |
def _intervention_metrics_markdown(result) -> str:
|
| 195 |
+
drift = f"Execution-context null drift — JS: **{result.execution_drift_js:.2e}**"
|
| 196 |
+
if result.execution_drift_mean_logprob is not None:
|
| 197 |
+
drift += f" · mean log p/token: **{result.execution_drift_mean_logprob:+.2e}**"
|
| 198 |
+
|
| 199 |
target = (
|
| 200 |
"No target continuation supplied. The causal comparison therefore uses only "
|
| 201 |
"next-token Jensen-Shannon divergence."
|
|
|
|
| 206 |
f"**Target continuation:** {result.target_token_count} token(s): {tokens} \n"
|
| 207 |
f"Sequence log p — baseline: **{result.baseline_sequence_logprob:.4f}** · "
|
| 208 |
f"SAE edit: **{result.modified_sequence_logprob:.4f}** · "
|
| 209 |
+
f"random-ensemble mean: **{result.random_sequence_logprob:.4f}** \n"
|
| 210 |
f"SAE Δ sequence log p: **{result.sequence_logprob_delta:+.4f}** · "
|
| 211 |
+
f"random signed mean Δ: **{result.random_sequence_logprob_delta:+.4f}** \n"
|
| 212 |
f"SAE Δ mean log p/token: **{result.mean_logprob_delta:+.4f}** · "
|
| 213 |
+
f"random mean |Δ|: **{result.random_abs_mean_logprob_delta:.4f}** ± **{result.random_mean_logprob_std:.4f}** · "
|
| 214 |
+
f"magnitude ratio: **{result.target_specificity_ratio:.2f}×** · "
|
| 215 |
+
f"empirical tail p: **{result.target_empirical_p:.3f}**"
|
| 216 |
)
|
| 217 |
inactive = ""
|
| 218 |
if abs(result.feature_activation) < 1e-12:
|
| 219 |
inactive = (
|
| 220 |
+
" \n**Inactive feature:** ablate/scale has zero coefficient delta here; "
|
| 221 |
+
"use `inject` only when you intentionally want a decoder-direction steering test."
|
| 222 |
)
|
| 223 |
return (
|
| 224 |
f"Original feature activation: **{result.feature_activation:.4f}** · "
|
| 225 |
f"Δ coefficient: **{result.delta_activation:+.4f}** \n"
|
| 226 |
f"Perturbation L2: **{result.perturbation_norm:.4f}** \n"
|
| 227 |
f"Next-token JS: **{result.js_divergence:.6f}** · "
|
| 228 |
+
f"random ensemble ({result.random_control_count}) mean JS: **{result.random_js_divergence:.6f}** "
|
| 229 |
+
f"± **{result.random_js_std:.6f}** · ratio: **{result.js_specificity_ratio:.2f}×** · "
|
| 230 |
+
f"empirical tail p: **{result.js_empirical_p:.3f}** \n"
|
| 231 |
+
f"{drift} \n\n{target}{inactive} \n\n"
|
| 232 |
+
"_Greedy text may remain unchanged even when probability-level causal metrics move; "
|
| 233 |
+
"deterministic generation changes only after an argmax boundary is crossed._"
|
| 234 |
)
|
| 235 |
|
| 236 |
|
|
|
|
| 243 |
f"Feature activation at baseline: **{result.feature_activation:.4f}** · "
|
| 244 |
f"target continuation: {len(result.target_tokens)} token(s): {tokens}.{inactive} \n\n"
|
| 245 |
"This panel is **always a scale intervention**: 0× = ablation, 1× = no edit, "
|
| 246 |
+
"2× = double the native coefficient. All rows are compared with the **1× row from the same batched "
|
| 247 |
+
"execution context**, so 1× is the numerical null reference. \n"
|
| 248 |
+
f"Single-forward → batched-null execution drift: mean log p/token "
|
| 249 |
+
f"**{result.execution_drift_mean_logprob:+.2e}**, JS **{result.execution_drift_js:.2e}**."
|
| 250 |
)
|
| 251 |
|
| 252 |
|
|
|
|
| 254 |
tokens = " ".join(repr(token) for token in result.target_tokens)
|
| 255 |
inactive_count = sum(abs(float(row[1])) < 1e-12 for row in result.feature_rows)
|
| 256 |
inactive_note = (
|
| 257 |
+
f" \n{inactive_count} selected feature(s) were inactive and contributed zero delta."
|
|
|
|
| 258 |
if inactive_count
|
| 259 |
else ""
|
| 260 |
)
|
|
|
|
| 263 |
f"perturbation L2: **{result.perturbation_norm:.4f}** \n"
|
| 264 |
f"Target continuation: {len(result.target_tokens)} token(s): {tokens} \n"
|
| 265 |
f"SAE Δ mean log p/token: **{result.mean_logprob_delta:+.4f}** · "
|
| 266 |
+
f"random ensemble ({result.random_control_count}) mean |Δ|: "
|
| 267 |
+
f"**{result.random_abs_mean_logprob_delta:.4f}** ± **{result.random_mean_logprob_std:.4f}** · ratio: **{result.target_specificity_ratio:.2f}×** · "
|
| 268 |
+
f"empirical tail p: **{result.target_empirical_p:.3f}** \n"
|
| 269 |
f"SAE Δ sequence log p: **{result.sequence_logprob_delta:+.4f}** · "
|
| 270 |
+
f"random signed mean Δ: **{result.random_sequence_logprob_delta:+.4f}** \n"
|
| 271 |
+
f"Next-token JS: **{result.js_divergence:.6f}** · random mean JS: "
|
| 272 |
+
f"**{result.random_js_divergence:.6f}** ± **{result.random_js_std:.6f}** · "
|
| 273 |
+
f"ratio: **{result.js_specificity_ratio:.2f}×** · empirical tail p: **{result.js_empirical_p:.3f}** \n"
|
| 274 |
+
f"Execution-context null drift: mean log p/token **{result.execution_drift_mean_logprob:+.2e}**, "
|
| 275 |
+
f"JS **{result.execution_drift_js:.2e}**{inactive_note}"
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _interaction_metrics_markdown(result) -> str:
|
| 280 |
+
tokens = " ".join(repr(token) for token in result.target_tokens)
|
| 281 |
+
return (
|
| 282 |
+
f"Target continuation: {len(result.target_tokens)} token(s): {tokens} \n"
|
| 283 |
+
f"Additive expectation (sum of individual Δ mean log p/token): "
|
| 284 |
+
f"**{result.additive_expected_mean_delta:+.4f}** \n"
|
| 285 |
+
f"Observed joint Δ mean log p/token: **{result.joint_mean_delta:+.4f}** \n"
|
| 286 |
+
f"Interaction excess (joint − additive): **{result.interaction_excess_mean_delta:+.4f}** · "
|
| 287 |
+
f"normalized: **{result.normalized_interaction:+.3f}** \n"
|
| 288 |
+
f"Execution-context null drift: **{result.execution_drift_mean_logprob:+.2e}** mean log p/token. \n\n"
|
| 289 |
+
"A non-zero excess indicates **non-additive downstream response under these edits**; it is not, by itself, "
|
| 290 |
+
"proof that the SAE features form a direct mechanistic circuit."
|
| 291 |
)
|
| 292 |
|
| 293 |
|
| 294 |
def _paraphrase_metrics_markdown(result) -> str:
|
| 295 |
return (
|
| 296 |
"#### Robustness metrics\n"
|
| 297 |
+
"**Selected-token comparison** \n"
|
| 298 |
+
f"TopK feature-set Jaccard: **{result.topk_jaccard:.3f}** · "
|
| 299 |
+
f"sparse activation cosine: **{result.sparse_cosine:.3f}** \n"
|
| 300 |
+
f"Shared features among displayed top-{result.top_n}: **{result.shared_top_n}/{result.top_n}** \n\n"
|
| 301 |
+
"**Prompt-wide comparison** — max activation of each feature across all prompt tokens \n"
|
| 302 |
+
f"Feature-set Jaccard: **{result.promptwide_jaccard:.3f}** · "
|
| 303 |
+
f"activation cosine: **{result.promptwide_cosine:.3f}** \n\n"
|
| 304 |
+
"The prompt-wide view reduces sensitivity to choosing semantically different final tokens, while the "
|
| 305 |
+
"selected-token view remains the stricter local representation test. Neither metric establishes feature semantics."
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def _concept_metrics_markdown(result) -> str:
|
| 310 |
+
ratio_text = "undefined" if result.leading_ratio == 0 else f"{result.leading_ratio:.2f}×"
|
| 311 |
+
return (
|
| 312 |
+
f"Feature **{result.feature_id}**, layer **{result.layer}** · "
|
| 313 |
+
f"{result.prompts_per_concept} controlled prompts/concept. \n"
|
| 314 |
+
f"Highest mean activation in this small live contrast: **{result.leading_concept}** "
|
| 315 |
+
f"({ratio_text} the runner-up mean). \n\n"
|
| 316 |
+
"This is an **exploratory contrast scan**, not an automatic semantic label. The offline held-out AUROC/F1 "
|
| 317 |
+
"benchmark remains the stronger feature/concept evidence."
|
| 318 |
)
|
| 319 |
|
| 320 |
|
|
|
|
| 324 |
if not prompt.strip():
|
| 325 |
raise ValueError("Enter a prompt first.")
|
| 326 |
result = RUNTIME.analyze(prompt, int(layer), int(token_index), int(top_n))
|
| 327 |
+
columns = ["Rank", "Feature id", "Activation", "Offline concept hint"]
|
| 328 |
+
table = pd.DataFrame(result.rows, columns=columns)
|
| 329 |
choices = [str(int(row[1])) for row in result.rows]
|
| 330 |
feature_update = gr.update(choices=choices, value=choices[0] if choices else None)
|
| 331 |
+
feature_set_update = gr.update(choices=choices, value=choices[: min(3, len(choices))])
|
| 332 |
+
contrast_update = gr.update(choices=choices, value=choices[0] if choices else None)
|
|
|
|
|
|
|
| 333 |
chart_df = pd.DataFrame(
|
| 334 |
{
|
| 335 |
"Feature": [str(int(row[1])) for row in result.rows],
|
|
|
|
| 337 |
"Series": ["Activation"] * len(result.rows),
|
| 338 |
}
|
| 339 |
)
|
| 340 |
+
location = (
|
| 341 |
+
f"Current Workbench location — **layer {int(layer)}**, **prompt token {result.token_index}**; "
|
| 342 |
+
f"prompt: `{prompt[:90]}{'…' if len(prompt) > 90 else ''}`"
|
| 343 |
+
)
|
| 344 |
return (
|
| 345 |
RUNTIME.token_html(result.tokens, result.token_index),
|
| 346 |
+
table,
|
| 347 |
chart_df,
|
| 348 |
feature_update,
|
| 349 |
feature_set_update,
|
| 350 |
+
contrast_update,
|
| 351 |
+
gr.update(value=int(layer)),
|
| 352 |
_analysis_metrics_markdown(result),
|
| 353 |
+
location,
|
| 354 |
+
location,
|
| 355 |
+
_tsv(table),
|
| 356 |
)
|
| 357 |
except Exception as exc:
|
| 358 |
_raise_ui_error(exc)
|
|
|
|
| 374 |
raise ValueError("Enter a prompt first.")
|
| 375 |
if feature_id is None or str(feature_id).strip() == "":
|
| 376 |
raise ValueError("Choose or enter a feature id.")
|
|
|
|
| 377 |
result = RUNTIME.intervene(
|
| 378 |
text=prompt,
|
| 379 |
layer=int(layer),
|
| 380 |
token_index=int(token_index),
|
| 381 |
+
feature_id=int(float(feature_id)),
|
| 382 |
mode=mode,
|
| 383 |
coefficient=float(coefficient),
|
| 384 |
target_text=target_text,
|
| 385 |
max_new_tokens=int(max_new_tokens),
|
| 386 |
)
|
| 387 |
+
token_columns = ["Token", "Baseline p", "SAE-edit p", "Δ probability"]
|
| 388 |
+
target_columns = [
|
| 389 |
+
"Target position",
|
| 390 |
+
"Target token",
|
| 391 |
+
"Baseline log p",
|
| 392 |
+
"SAE-edit log p",
|
| 393 |
+
"Random-ensemble mean log p",
|
| 394 |
+
"SAE Δ log p",
|
| 395 |
+
"Random-ensemble mean Δ log p",
|
| 396 |
+
]
|
| 397 |
+
token_df = pd.DataFrame(result.top_token_rows, columns=token_columns)
|
| 398 |
+
target_df = pd.DataFrame(result.target_token_rows, columns=target_columns)
|
| 399 |
return (
|
| 400 |
result.baseline_text,
|
| 401 |
result.modified_text,
|
| 402 |
_intervention_metrics_markdown(result),
|
| 403 |
+
token_df,
|
| 404 |
+
target_df,
|
| 405 |
+
_tsv(token_df),
|
| 406 |
+
_tsv(target_df),
|
| 407 |
)
|
| 408 |
except Exception as exc:
|
| 409 |
_raise_ui_error(exc)
|
| 410 |
|
| 411 |
|
| 412 |
@gpu(duration=35)
|
| 413 |
+
def run_dose_response(prompt: str, layer: int, token_index: int, feature_id: str, target_text: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
try:
|
| 415 |
if not prompt.strip():
|
| 416 |
raise ValueError("Enter a prompt first.")
|
|
|
|
| 429 |
"Multiplier",
|
| 430 |
"Δ feature coefficient",
|
| 431 |
"Perturbation L2",
|
| 432 |
+
"Batched-null mean log p/token",
|
| 433 |
"Modified mean log p/token",
|
| 434 |
"Δ mean log p/token",
|
| 435 |
"Δ sequence log p",
|
|
|
|
| 438 |
table = pd.DataFrame(result.rows, columns=columns)
|
| 439 |
plot = table[["Multiplier", "Δ mean log p/token"]].copy()
|
| 440 |
plot["Series"] = "SAE feature"
|
| 441 |
+
return table, plot, _dose_metrics_markdown(result), _tsv(table)
|
| 442 |
except Exception as exc:
|
| 443 |
_raise_ui_error(exc)
|
| 444 |
|
|
|
|
| 465 |
var_name="Metric",
|
| 466 |
value_name="Value",
|
| 467 |
)
|
| 468 |
+
return RUNTIME.token_html(result.tokens, result.token_index), table, long, _tsv(table)
|
| 469 |
except Exception as exc:
|
| 470 |
_raise_ui_error(exc)
|
| 471 |
|
| 472 |
|
| 473 |
+
@gpu(duration=40)
|
| 474 |
def run_feature_set(
|
| 475 |
prompt: str,
|
| 476 |
layer: int,
|
|
|
|
| 482 |
):
|
| 483 |
try:
|
| 484 |
if not prompt.strip():
|
| 485 |
+
raise ValueError("Enter and inspect a prompt in the Workbench first.")
|
| 486 |
selected = [int(float(value)) for value in (feature_ids or [])]
|
| 487 |
if not selected:
|
| 488 |
raise ValueError("Select at least one feature in 'Feature set'.")
|
|
|
|
| 497 |
coefficient=float(coefficient),
|
| 498 |
target_text=target_text,
|
| 499 |
)
|
| 500 |
+
feature_columns = ["Feature id", "Original activation", "Δ coefficient", "Offline concept hint"]
|
| 501 |
+
target_columns = [
|
| 502 |
+
"Target position",
|
| 503 |
+
"Target token",
|
| 504 |
+
"Baseline log p",
|
| 505 |
+
"SAE-edit log p",
|
| 506 |
+
"Random-ensemble mean log p",
|
| 507 |
+
"SAE Δ log p",
|
| 508 |
+
"Random-ensemble mean Δ log p",
|
| 509 |
+
]
|
| 510 |
+
feature_df = pd.DataFrame(result.feature_rows, columns=feature_columns)
|
| 511 |
+
target_df = pd.DataFrame(result.target_token_rows, columns=target_columns)
|
| 512 |
+
return (
|
| 513 |
+
feature_df,
|
| 514 |
+
_feature_set_metrics_markdown(result),
|
| 515 |
+
target_df,
|
| 516 |
+
_tsv(feature_df),
|
| 517 |
+
_tsv(target_df),
|
| 518 |
+
)
|
| 519 |
except Exception as exc:
|
| 520 |
_raise_ui_error(exc)
|
| 521 |
|
| 522 |
|
| 523 |
+
@gpu(duration=45)
|
| 524 |
+
def run_feature_set_sweep(prompt: str, layer: int, token_index: int, target_text: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
try:
|
| 526 |
if not prompt.strip():
|
| 527 |
+
raise ValueError("Enter and inspect a prompt in the Workbench first.")
|
| 528 |
if not target_text.strip():
|
| 529 |
raise ValueError("Enter a target continuation before running the set-size sweep.")
|
| 530 |
result = RUNTIME.feature_set_size_sweep(
|
|
|
|
| 537 |
"Set size k",
|
| 538 |
"Feature ids",
|
| 539 |
"Perturbation L2",
|
| 540 |
+
"Batched-null mean log p/token",
|
| 541 |
"SAE mean log p/token",
|
| 542 |
"SAE Δ mean log p/token",
|
| 543 |
+
"Random signed mean Δ",
|
| 544 |
+
"Random mean |Δ|",
|
| 545 |
+
"Random |Δ| std",
|
| 546 |
+
"SAE/random magnitude ratio",
|
| 547 |
+
"Empirical tail p",
|
| 548 |
"SAE Δ sequence log p",
|
| 549 |
"SAE next-token JS",
|
| 550 |
+
"Random mean JS",
|
| 551 |
+
"Random JS std",
|
| 552 |
+
"JS empirical tail p",
|
| 553 |
]
|
| 554 |
table = pd.DataFrame(result.rows, columns=columns)
|
| 555 |
+
plot_rows: list[list[object]] = []
|
| 556 |
for _, row in table.iterrows():
|
| 557 |
plot_rows.append([row["Set size k"], "Top-k SAE ablation", row["SAE Δ mean log p/token"]])
|
| 558 |
+
plot_rows.append([row["Set size k"], "Random signed mean", row["Random signed mean Δ"]])
|
| 559 |
plot = pd.DataFrame(plot_rows, columns=["Set size k", "Condition", "Δ mean log p/token"])
|
| 560 |
tokens = " ".join(repr(token) for token in result.target_tokens)
|
| 561 |
note = (
|
| 562 |
+
f"Target continuation: {len(result.target_tokens)} token(s): {tokens}. For each k, FeatureLens "
|
| 563 |
+
f"ablates the k strongest active features and compares the effect with **{result.random_control_count} "
|
| 564 |
+
f"norm-matched random directions**. All conditions share one batched zero-edit reference. \n"
|
| 565 |
+
f"Execution-context null drift: mean log p/token **{result.execution_drift_mean_logprob:+.2e}**, "
|
| 566 |
+
f"JS **{result.execution_drift_js:.2e}**. The live empirical p-value is intentionally coarse because "
|
| 567 |
+
f"it uses only {result.random_control_count} controls; the offline experiment should use more."
|
| 568 |
)
|
| 569 |
+
return table, plot, note, _tsv(table)
|
| 570 |
+
except Exception as exc:
|
| 571 |
+
_raise_ui_error(exc)
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
@gpu(duration=40)
|
| 575 |
+
def run_feature_interaction(
|
| 576 |
+
prompt: str,
|
| 577 |
+
layer: int,
|
| 578 |
+
token_index: int,
|
| 579 |
+
feature_ids: list[str] | None,
|
| 580 |
+
target_text: str,
|
| 581 |
+
):
|
| 582 |
+
try:
|
| 583 |
+
selected = [int(float(value)) for value in (feature_ids or [])]
|
| 584 |
+
if not prompt.strip():
|
| 585 |
+
raise ValueError("Enter and inspect a prompt in the Workbench first.")
|
| 586 |
+
if len(selected) < 2:
|
| 587 |
+
raise ValueError("Select at least two features in 'Feature set'.")
|
| 588 |
+
if len(selected) > 5:
|
| 589 |
+
raise ValueError("Select at most five features for the interaction decomposition.")
|
| 590 |
+
if not target_text.strip():
|
| 591 |
+
raise ValueError("Enter a target continuation for the interaction decomposition.")
|
| 592 |
+
result = RUNTIME.feature_interaction_test(
|
| 593 |
+
text=prompt,
|
| 594 |
+
layer=int(layer),
|
| 595 |
+
token_index=int(token_index),
|
| 596 |
+
feature_ids=selected,
|
| 597 |
+
target_text=target_text,
|
| 598 |
+
)
|
| 599 |
+
columns = [
|
| 600 |
+
"Condition",
|
| 601 |
+
"Feature ids",
|
| 602 |
+
"Activation summary",
|
| 603 |
+
"Perturbation L2",
|
| 604 |
+
"Δ mean log p/token",
|
| 605 |
+
"Δ sequence log p",
|
| 606 |
+
"Next-token JS",
|
| 607 |
+
]
|
| 608 |
+
table = pd.DataFrame(result.rows, columns=columns)
|
| 609 |
+
plot = table[["Condition", "Δ mean log p/token"]].copy()
|
| 610 |
+
plot["Series"] = "Ablation effect"
|
| 611 |
+
return table, _interaction_metrics_markdown(result), plot, _tsv(table)
|
| 612 |
except Exception as exc:
|
| 613 |
_raise_ui_error(exc)
|
| 614 |
|
|
|
|
| 631 |
token_index_b=int(token_index_b),
|
| 632 |
top_n=int(top_n),
|
| 633 |
)
|
| 634 |
+
columns = ["Feature id", "Original activation", "Paraphrase activation", "Status", "Offline concept hint"]
|
| 635 |
+
table = pd.DataFrame(result.rows, columns=columns)
|
| 636 |
chart = pd.DataFrame(result.chart_rows, columns=["Feature", "Prompt", "Activation"])
|
| 637 |
return (
|
| 638 |
RUNTIME.token_html(result.tokens_a, result.token_index_a),
|
| 639 |
RUNTIME.token_html(result.tokens_b, result.token_index_b),
|
| 640 |
_paraphrase_metrics_markdown(result),
|
| 641 |
+
table,
|
| 642 |
chart,
|
| 643 |
+
_tsv(table),
|
| 644 |
+
)
|
| 645 |
+
except Exception as exc:
|
| 646 |
+
_raise_ui_error(exc)
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
@gpu(duration=35)
|
| 650 |
+
def run_concept_contrast(feature_id: str, layer: int, prompts_per_concept: int):
|
| 651 |
+
try:
|
| 652 |
+
if feature_id is None or str(feature_id).strip() == "":
|
| 653 |
+
raise ValueError("Choose a feature id first. Run Workbench inspection if the selector is empty.")
|
| 654 |
+
result = RUNTIME.concept_contrast_scan(
|
| 655 |
+
feature_id=int(float(feature_id)),
|
| 656 |
+
layer=int(layer),
|
| 657 |
+
prompts_per_concept=int(prompts_per_concept),
|
| 658 |
)
|
| 659 |
+
columns = [
|
| 660 |
+
"Concept",
|
| 661 |
+
"Prompts",
|
| 662 |
+
"Mean activation",
|
| 663 |
+
"Median activation",
|
| 664 |
+
"Activation rate",
|
| 665 |
+
"Max activation",
|
| 666 |
+
]
|
| 667 |
+
table = pd.DataFrame(result.rows, columns=columns)
|
| 668 |
+
chart = pd.DataFrame(result.chart_rows, columns=["Concept", "Mean activation"])
|
| 669 |
+
chart["Series"] = "Mean activation"
|
| 670 |
+
return _concept_metrics_markdown(result), table, chart, _tsv(table)
|
| 671 |
except Exception as exc:
|
| 672 |
_raise_ui_error(exc)
|
| 673 |
|
| 674 |
|
| 675 |
def mode_help(mode: str):
|
| 676 |
if mode == "ablate":
|
| 677 |
+
return gr.update(value=0.0, interactive=False, label="Coefficient (unused for ablation)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
if mode == "scale":
|
| 679 |
+
return gr.update(value=2.0, interactive=True, label="Feature multiplier")
|
| 680 |
+
return gr.update(value=5.0, interactive=True, label="Additive feature coefficient")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
|
| 682 |
|
| 683 |
def set_mode_help(mode: str):
|
| 684 |
if mode == "ablate":
|
| 685 |
+
return gr.update(value=0.0, interactive=False, label="Multiplier (unused for ablation)")
|
| 686 |
+
return gr.update(value=2.0, interactive=True, label="Shared feature multiplier")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
|
| 688 |
|
| 689 |
with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as demo:
|
| 690 |
gr.HTML(
|
| 691 |
+
'<header class="hero">'
|
| 692 |
+
'<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.4</span></h1>'
|
| 693 |
+
'<div class="subtitle">Causal Interpretability Workbench</div>'
|
| 694 |
+
'<div class="metadata">Qwen3-1.7B-Base · Qwen-Scope residual SAEs · layers 4 / 14 / 26 · '
|
| 695 |
+
'32,768 features · TopK=50 · ZeroGPU</div>'
|
| 696 |
+
'</header>'
|
| 697 |
+
'<div class="research-q"><strong>Research question.</strong> Do sparse features that predict a concept '
|
| 698 |
+
'also causally influence the model’s behaviour?</div>'
|
|
|
|
| 699 |
)
|
| 700 |
|
| 701 |
with gr.Tab("Workbench"):
|
| 702 |
+
gr.HTML('<div class="section-rule">I. Inspect a prompt location</div>')
|
| 703 |
with gr.Row(equal_height=False):
|
| 704 |
with gr.Column(scale=5):
|
| 705 |
prompt = gr.Textbox(
|
|
|
|
| 723 |
choices=list(SETTINGS.layers),
|
| 724 |
value=SETTINGS.layers[1] if len(SETTINGS.layers) > 1 else SETTINGS.layers[0],
|
| 725 |
label="Residual layer",
|
|
|
|
| 726 |
)
|
| 727 |
token_index = gr.Number(
|
| 728 |
value=-1,
|
| 729 |
precision=0,
|
| 730 |
label="Prompt token index",
|
| 731 |
+
info="-1 = final prompt token.",
|
| 732 |
)
|
| 733 |
top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
|
| 734 |
analyze_btn = gr.Button("Inspect sparse features", variant="primary")
|
| 735 |
|
| 736 |
gr.Markdown("#### Prompt tokens\nThe selected prompt token is outlined more strongly.")
|
| 737 |
+
token_view = gr.HTML(
|
| 738 |
+
'<div class="small-note">Prompt tokens appear here after clicking <b>Inspect sparse features</b>.</div>'
|
| 739 |
+
)
|
| 740 |
analysis_metrics = gr.Markdown()
|
| 741 |
with gr.Row(equal_height=False):
|
| 742 |
+
with gr.Column(scale=3):
|
| 743 |
+
feature_table = gr.Dataframe(
|
| 744 |
+
headers=["Rank", "Feature id", "Activation", "Offline concept hint"],
|
| 745 |
+
datatype=["number", "number", "number", "str"],
|
| 746 |
+
interactive=False,
|
| 747 |
+
label="Strongest active SAE features",
|
| 748 |
+
wrap=True,
|
| 749 |
+
buttons=["fullscreen"],
|
| 750 |
+
)
|
| 751 |
+
feature_tsv = gr.Textbox(visible="hidden")
|
| 752 |
+
feature_copy = _copy_button()
|
| 753 |
+
with gr.Column(scale=2):
|
| 754 |
+
feature_plot = gr.BarPlot(
|
| 755 |
+
x="Feature",
|
| 756 |
+
y="Activation",
|
| 757 |
+
color="Series",
|
| 758 |
+
color_map={"Activation": INK_TEAL},
|
| 759 |
+
title="Activation profile",
|
| 760 |
+
x_title="Feature id",
|
| 761 |
+
y_title="Activation",
|
| 762 |
+
x_label_angle=-35,
|
| 763 |
+
height=330,
|
| 764 |
+
)
|
| 765 |
|
| 766 |
+
gr.HTML('<div class="section-rule">II. Single-feature causal test</div>')
|
| 767 |
gr.Markdown(
|
| 768 |
+
"The edit is compared with an **8-direction norm-matched random ensemble** in the same batched "
|
| 769 |
+
"execution context. If a target continuation is supplied, the complete continuation is scored teacher-forced."
|
|
|
|
| 770 |
)
|
| 771 |
with gr.Row(equal_height=False):
|
| 772 |
with gr.Column(scale=2):
|
|
|
|
| 774 |
choices=[],
|
| 775 |
allow_custom_value=True,
|
| 776 |
label="Single feature id",
|
|
|
|
| 777 |
)
|
| 778 |
+
mode = gr.Dropdown(
|
| 779 |
choices=["ablate", "scale", "inject"],
|
| 780 |
value="ablate",
|
| 781 |
label="Single-feature intervention",
|
|
|
|
| 788 |
target_text = gr.Textbox(
|
| 789 |
label="Target continuation (optional)",
|
| 790 |
placeholder="e.g. 2x",
|
| 791 |
+
info="Exact text appended to the prompt for teacher-forced scoring.",
|
|
|
|
|
|
|
|
|
|
| 792 |
)
|
| 793 |
max_new = gr.Slider(
|
| 794 |
4,
|
|
|
|
| 804 |
baseline_out = gr.Textbox(label="Baseline greedy generation", lines=6, interactive=False)
|
| 805 |
modified_out = gr.Textbox(label="SAE-edited greedy generation", lines=6, interactive=False)
|
| 806 |
token_prob_table = gr.Dataframe(
|
|
|
|
|
|
|
| 807 |
interactive=False,
|
| 808 |
label="Next-token distribution shift",
|
| 809 |
+
buttons=["fullscreen"],
|
| 810 |
+
wrap=True,
|
| 811 |
)
|
| 812 |
+
token_prob_tsv = gr.Textbox(visible="hidden")
|
| 813 |
+
token_prob_copy = _copy_button()
|
| 814 |
target_token_table = gr.Dataframe(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 815 |
interactive=False,
|
| 816 |
label="Target continuation token-by-token score",
|
| 817 |
+
buttons=["fullscreen"],
|
| 818 |
+
wrap=True,
|
| 819 |
)
|
| 820 |
+
target_token_tsv = gr.Textbox(visible="hidden")
|
| 821 |
+
target_token_copy = _copy_button()
|
| 822 |
|
| 823 |
+
gr.HTML('<div class="section-rule">III. Single-feature scale dose-response</div>')
|
| 824 |
+
with gr.Accordion("Scale dose-response", open=False):
|
| 825 |
gr.Markdown(
|
| 826 |
+
"Always a **scale** experiment: 0× = ablation, 1× = numerical no-edit reference, 2× = double "
|
| 827 |
+
"the native coefficient. The six conditions are evaluated together, and all deltas are measured "
|
| 828 |
+
"against the 1× row from that same batch."
|
| 829 |
)
|
| 830 |
dose_btn = gr.Button("Run scale dose-response")
|
| 831 |
dose_metrics = gr.Markdown()
|
| 832 |
+
with gr.Row(equal_height=False):
|
| 833 |
+
with gr.Column(scale=3):
|
| 834 |
+
dose_table = gr.Dataframe(
|
| 835 |
+
interactive=False,
|
| 836 |
+
label="Scale dose-response measurements",
|
| 837 |
+
buttons=["fullscreen"],
|
| 838 |
+
wrap=True,
|
| 839 |
+
)
|
| 840 |
+
dose_tsv = gr.Textbox(visible="hidden")
|
| 841 |
+
dose_copy = _copy_button()
|
| 842 |
+
with gr.Column(scale=2):
|
| 843 |
+
dose_plot = gr.LinePlot(
|
| 844 |
+
x="Multiplier",
|
| 845 |
+
y="Δ mean log p/token",
|
| 846 |
+
color="Series",
|
| 847 |
+
color_map={"SAE feature": INK_TEAL},
|
| 848 |
+
title="Scale dose-response",
|
| 849 |
+
x_title="Feature multiplier",
|
| 850 |
+
y_title="Δ mean log p/token",
|
| 851 |
+
height=330,
|
| 852 |
+
)
|
| 853 |
|
| 854 |
with gr.Tab("Feature sets"):
|
| 855 |
gr.Markdown(
|
| 856 |
+
"### Distributed sparse representations\n"
|
| 857 |
+
"Run **Inspect sparse features** in the Workbench first. The prompt, layer, token index, and active "
|
| 858 |
+
"feature selector below reuse that Workbench state."
|
| 859 |
)
|
| 860 |
+
feature_set_location = gr.Markdown("No Workbench inspection has been run yet.")
|
| 861 |
feature_set_ids = gr.Dropdown(
|
| 862 |
choices=[],
|
| 863 |
value=[],
|
|
|
|
| 865 |
allow_custom_value=True,
|
| 866 |
max_choices=12,
|
| 867 |
label="Feature set",
|
|
|
|
| 868 |
)
|
| 869 |
+
|
| 870 |
+
gr.Markdown("#### Joint feature-set causal test")
|
| 871 |
+
gr.HTML(
|
| 872 |
+
'<div class="instrument-note">Ablation ignores the multiplier. Scale applies one shared multiplier '
|
| 873 |
+
'to every selected feature before decoder deltas are summed. Additive injection is deliberately omitted '
|
| 874 |
+
'because one coefficient is not comparable across unrelated directions.</div>'
|
| 875 |
+
)
|
| 876 |
+
with gr.Row(equal_height=True):
|
| 877 |
+
set_mode = gr.Dropdown(
|
| 878 |
choices=["ablate", "scale"],
|
| 879 |
value="ablate",
|
| 880 |
+
label="Intervention",
|
| 881 |
+
scale=1,
|
| 882 |
)
|
| 883 |
set_coefficient = gr.Number(
|
| 884 |
value=0.0,
|
| 885 |
interactive=False,
|
| 886 |
+
label="Multiplier (unused for ablation)",
|
| 887 |
+
scale=1,
|
| 888 |
)
|
| 889 |
set_target = gr.Textbox(
|
| 890 |
label="Target continuation",
|
| 891 |
value="2x",
|
| 892 |
+
lines=1,
|
| 893 |
+
scale=2,
|
| 894 |
)
|
| 895 |
set_btn = gr.Button("Run joint feature-set causal test", variant="primary")
|
| 896 |
set_metrics = gr.Markdown()
|
| 897 |
+
with gr.Row(equal_height=False):
|
| 898 |
+
with gr.Column(scale=2):
|
| 899 |
+
set_feature_table = gr.Dataframe(
|
| 900 |
+
interactive=False,
|
| 901 |
+
label="Joint intervention features",
|
| 902 |
+
buttons=["fullscreen"],
|
| 903 |
+
wrap=True,
|
| 904 |
+
)
|
| 905 |
+
set_feature_tsv = gr.Textbox(visible="hidden")
|
| 906 |
+
set_feature_copy = _copy_button()
|
| 907 |
+
with gr.Column(scale=3):
|
| 908 |
+
set_target_table = gr.Dataframe(
|
| 909 |
+
interactive=False,
|
| 910 |
+
label="Target continuation token-by-token score",
|
| 911 |
+
buttons=["fullscreen"],
|
| 912 |
+
wrap=True,
|
| 913 |
+
)
|
| 914 |
+
set_target_tsv = gr.Textbox(visible="hidden")
|
| 915 |
+
set_target_copy = _copy_button()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
|
| 917 |
+
gr.HTML('<div class="section-rule">Set-size sensitivity</div>')
|
| 918 |
gr.Markdown(
|
| 919 |
+
"Ablates the **1, 3, and 5 strongest active features**. Each targeted edit is compared with eight "
|
| 920 |
+
"norm-matched random directions, all sharing one batched no-edit baseline."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 921 |
)
|
| 922 |
+
set_sweep_target = gr.Textbox(label="Target continuation for set-size sweep", value="2x")
|
| 923 |
set_sweep_btn = gr.Button("Run 1/3/5-feature ablation sweep")
|
| 924 |
set_sweep_note = gr.Markdown()
|
| 925 |
+
with gr.Row(equal_height=False):
|
| 926 |
+
with gr.Column(scale=3):
|
| 927 |
+
set_sweep_table = gr.Dataframe(
|
| 928 |
+
interactive=False,
|
| 929 |
+
label="Feature-set size measurements",
|
| 930 |
+
buttons=["fullscreen"],
|
| 931 |
+
wrap=True,
|
| 932 |
+
)
|
| 933 |
+
set_sweep_tsv = gr.Textbox(visible="hidden")
|
| 934 |
+
set_sweep_copy = _copy_button()
|
| 935 |
+
with gr.Column(scale=2):
|
| 936 |
+
set_sweep_plot = gr.LinePlot(
|
| 937 |
+
x="Set size k",
|
| 938 |
+
y="Δ mean log p/token",
|
| 939 |
+
color="Condition",
|
| 940 |
+
color_map={
|
| 941 |
+
"Top-k SAE ablation": INK_TEAL,
|
| 942 |
+
"Random signed mean": INK_STONE,
|
| 943 |
+
},
|
| 944 |
+
title="Effect vs feature-set size",
|
| 945 |
+
x_title="Number of jointly ablated features",
|
| 946 |
+
y_title="Δ mean log p/token",
|
| 947 |
+
height=330,
|
| 948 |
+
)
|
| 949 |
+
|
| 950 |
+
gr.HTML('<div class="section-rule">Non-additivity / interaction decomposition</div>')
|
| 951 |
+
gr.Markdown(
|
| 952 |
+
"For the selected 2–5 features, FeatureLens batches each **individual ablation** plus the **joint "
|
| 953 |
+
"ablation**. It compares the observed joint effect with the sum of individual effects. This diagnoses "
|
| 954 |
+
"non-additivity without claiming that the features form a direct circuit."
|
| 955 |
+
)
|
| 956 |
+
interaction_target = gr.Textbox(label="Target continuation for interaction test", value="2x")
|
| 957 |
+
interaction_btn = gr.Button("Run individual-vs-joint decomposition")
|
| 958 |
+
interaction_metrics = gr.Markdown()
|
| 959 |
+
with gr.Row(equal_height=False):
|
| 960 |
+
with gr.Column(scale=3):
|
| 961 |
+
interaction_table = gr.Dataframe(
|
| 962 |
+
interactive=False,
|
| 963 |
+
label="Individual and joint ablation measurements",
|
| 964 |
+
buttons=["fullscreen"],
|
| 965 |
+
wrap=True,
|
| 966 |
+
)
|
| 967 |
+
interaction_tsv = gr.Textbox(visible="hidden")
|
| 968 |
+
interaction_copy = _copy_button()
|
| 969 |
+
with gr.Column(scale=2):
|
| 970 |
+
interaction_plot = gr.BarPlot(
|
| 971 |
+
x="Condition",
|
| 972 |
+
y="Δ mean log p/token",
|
| 973 |
+
color="Series",
|
| 974 |
+
color_map={"Ablation effect": INK_UMBER},
|
| 975 |
+
title="Individual vs joint effect",
|
| 976 |
+
x_title="Intervention condition",
|
| 977 |
+
y_title="Δ mean log p/token",
|
| 978 |
+
x_label_angle=-25,
|
| 979 |
+
height=330,
|
| 980 |
+
)
|
| 981 |
+
|
| 982 |
+
with gr.Tab("Feature evidence"):
|
| 983 |
+
gr.Markdown(
|
| 984 |
+
"### Controlled concept contrast scan\n"
|
| 985 |
+
"This lightweight live test asks whether the selected SAE feature activates preferentially on one of "
|
| 986 |
+
"FeatureLens's seven controlled concept groups. It uses one wording per paraphrase pair and evaluates "
|
| 987 |
+
"the **final prompt token** for every contrast prompt in one batch."
|
| 988 |
+
)
|
| 989 |
+
contrast_location = gr.Markdown("No Workbench inspection has been run yet.")
|
| 990 |
+
with gr.Row(equal_height=True):
|
| 991 |
+
contrast_feature_id = gr.Dropdown(
|
| 992 |
+
choices=[],
|
| 993 |
+
allow_custom_value=True,
|
| 994 |
+
label="Feature id",
|
| 995 |
+
scale=2,
|
| 996 |
+
)
|
| 997 |
+
contrast_layer = gr.Dropdown(
|
| 998 |
+
choices=list(SETTINGS.layers),
|
| 999 |
+
value=SETTINGS.layers[1],
|
| 1000 |
+
label="Residual layer",
|
| 1001 |
+
scale=1,
|
| 1002 |
+
)
|
| 1003 |
+
contrast_n = gr.Slider(
|
| 1004 |
+
2,
|
| 1005 |
+
6,
|
| 1006 |
+
value=SETTINGS.contrast_prompts_per_concept,
|
| 1007 |
+
step=1,
|
| 1008 |
+
label="Prompts per concept",
|
| 1009 |
scale=2,
|
| 1010 |
)
|
| 1011 |
+
contrast_btn = gr.Button("Run controlled concept contrast", variant="primary")
|
| 1012 |
+
contrast_metrics = gr.Markdown()
|
| 1013 |
+
with gr.Row(equal_height=False):
|
| 1014 |
+
with gr.Column(scale=3):
|
| 1015 |
+
contrast_table = gr.Dataframe(
|
| 1016 |
+
interactive=False,
|
| 1017 |
+
label="Feature activation by controlled concept",
|
| 1018 |
+
buttons=["fullscreen"],
|
| 1019 |
+
wrap=True,
|
| 1020 |
+
)
|
| 1021 |
+
contrast_tsv = gr.Textbox(visible="hidden")
|
| 1022 |
+
contrast_copy = _copy_button()
|
| 1023 |
+
with gr.Column(scale=2):
|
| 1024 |
+
contrast_plot = gr.BarPlot(
|
| 1025 |
+
x="Concept",
|
| 1026 |
+
y="Mean activation",
|
| 1027 |
+
color="Series",
|
| 1028 |
+
color_map={"Mean activation": INK_BLUEGREY},
|
| 1029 |
+
title="Controlled concept contrast",
|
| 1030 |
+
x_title="Concept",
|
| 1031 |
+
y_title="Mean feature activation",
|
| 1032 |
+
x_label_angle=-25,
|
| 1033 |
+
height=330,
|
| 1034 |
+
)
|
| 1035 |
|
| 1036 |
with gr.Tab("Paraphrase robustness"):
|
| 1037 |
gr.Markdown(
|
| 1038 |
+
"### Local and prompt-wide robustness\n"
|
| 1039 |
+
"The selected-token comparison is strict but can be misleading if the two chosen tokens play different "
|
| 1040 |
+
"semantic roles. v0.4 therefore also reports a **prompt-wide max-pooled feature profile**: for every SAE "
|
| 1041 |
+
"feature, take its maximum activation across all prompt tokens, then compare the two sparse profiles."
|
| 1042 |
)
|
| 1043 |
with gr.Row():
|
| 1044 |
para_a = gr.Textbox(
|
|
|
|
| 1054 |
with gr.Row():
|
| 1055 |
para_layer = gr.Dropdown(
|
| 1056 |
choices=list(SETTINGS.layers),
|
| 1057 |
+
value=SETTINGS.layers[1],
|
| 1058 |
label="Residual layer",
|
| 1059 |
)
|
| 1060 |
para_idx_a = gr.Number(value=-1, precision=0, label="Original prompt token index")
|
|
|
|
| 1069 |
gr.Markdown("#### Paraphrase tokens")
|
| 1070 |
para_tokens_b = gr.HTML()
|
| 1071 |
para_metrics = gr.Markdown()
|
| 1072 |
+
with gr.Row(equal_height=False):
|
| 1073 |
+
with gr.Column(scale=3):
|
| 1074 |
+
para_table = gr.Dataframe(
|
| 1075 |
+
interactive=False,
|
| 1076 |
+
label="Top-feature overlap at selected tokens",
|
| 1077 |
+
buttons=["fullscreen"],
|
| 1078 |
+
wrap=True,
|
| 1079 |
+
)
|
| 1080 |
+
para_tsv = gr.Textbox(visible="hidden")
|
| 1081 |
+
para_copy = _copy_button()
|
| 1082 |
+
with gr.Column(scale=2):
|
| 1083 |
+
para_plot = gr.BarPlot(
|
| 1084 |
+
x="Feature",
|
| 1085 |
+
y="Activation",
|
| 1086 |
+
color="Prompt",
|
| 1087 |
+
color_map={"Original": INK_TEAL, "Paraphrase": INK_PLUM},
|
| 1088 |
+
title="Selected-token feature activations",
|
| 1089 |
+
x_title="Feature id",
|
| 1090 |
+
y_title="Activation",
|
| 1091 |
+
x_label_angle=-35,
|
| 1092 |
+
height=330,
|
| 1093 |
+
)
|
| 1094 |
|
| 1095 |
with gr.Tab("Layer trajectory"):
|
| 1096 |
gr.Markdown(
|
| 1097 |
+
"### Representation structure across early, middle and late residual streams\n"
|
| 1098 |
+
"This is not a cross-layer feature-ID comparison; the SAE dictionaries are layer-specific."
|
|
|
|
|
|
|
| 1099 |
)
|
| 1100 |
with gr.Row():
|
| 1101 |
trajectory_prompt = gr.Textbox(
|
|
|
|
| 1114 |
trajectory_btn = gr.Button("Compare layers", variant="primary")
|
| 1115 |
gr.Markdown("#### Prompt tokens")
|
| 1116 |
trajectory_tokens = gr.HTML()
|
| 1117 |
+
with gr.Row(equal_height=False):
|
| 1118 |
+
with gr.Column(scale=3):
|
| 1119 |
+
trajectory_table = gr.Dataframe(
|
| 1120 |
+
interactive=False,
|
| 1121 |
+
label="Layer diagnostics",
|
| 1122 |
+
buttons=["fullscreen"],
|
| 1123 |
+
wrap=True,
|
| 1124 |
+
)
|
| 1125 |
+
trajectory_tsv = gr.Textbox(visible="hidden")
|
| 1126 |
+
trajectory_copy = _copy_button()
|
| 1127 |
+
with gr.Column(scale=2):
|
| 1128 |
+
trajectory_plot = gr.LinePlot(
|
| 1129 |
+
x="Layer",
|
| 1130 |
+
y="Value",
|
| 1131 |
+
color="Metric",
|
| 1132 |
+
color_map={
|
| 1133 |
+
"Reconstruction cosine": INK_TEAL,
|
| 1134 |
+
"Top-5 mass": INK_UMBER,
|
| 1135 |
+
"Activation entropy": INK_RED,
|
| 1136 |
+
},
|
| 1137 |
+
title="Representation trajectory",
|
| 1138 |
+
x_title="Layer",
|
| 1139 |
+
y_title="Normalized value",
|
| 1140 |
+
height=330,
|
| 1141 |
+
)
|
| 1142 |
|
| 1143 |
with gr.Tab("Offline benchmark"):
|
| 1144 |
gr.Markdown(RUNTIME.catalog.benchmark_markdown())
|
| 1145 |
gr.Markdown(
|
| 1146 |
+
"The offline pipeline remains the source of held-out feature/concept AUROC + F1, reconstruction "
|
| 1147 |
+
"quality, paraphrase stability, dense residual linear probes, and causal intervention conclusions. "
|
| 1148 |
+
"v0.4 strengthens the live workbench with batch-context null references and random-control ensembles; "
|
| 1149 |
+
"the saved report should be generated only from real experiment artifacts."
|
|
|
|
| 1150 |
)
|
| 1151 |
|
| 1152 |
with gr.Tab("Method"):
|
|
|
|
| 1157 |
For residual vector $h$, sparse coefficient $z_i$, decoder direction $d_i$, and scale $\alpha$:
|
| 1158 |
|
| 1159 |
- **Ablate:** $h' = h - z_i d_i$
|
| 1160 |
+
- **Scale:** $h' = h + (\alpha - 1)z_i d_i$
|
| 1161 |
- **Inject:** $h' = h + \delta d_i$
|
| 1162 |
|
| 1163 |
+
For a feature set $S$:
|
| 1164 |
|
| 1165 |
+
$$h' = h + \sum_{i \in S}\Delta z_i d_i.$$
|
| 1166 |
|
| 1167 |
+
FeatureLens patches the delta into the **original residual**; it never replaces the residual with the complete SAE reconstruction.
|
|
|
|
| 1168 |
|
| 1169 |
+
### v0.4 control discipline
|
| 1170 |
|
| 1171 |
+
Batched experiments include an explicit **zero-edit row**. Causal effects are measured against that row rather than a separately executed baseline, which removes batch-vs-single floating-point drift from the measured effect. Random specificity uses an ensemble of norm-matched residual directions rather than one arbitrary seed.
|
|
|
|
|
|
|
| 1172 |
|
| 1173 |
### Evidence ladder
|
| 1174 |
|
| 1175 |
+
1. SAE reconstruction quality.
|
| 1176 |
+
2. Held-out feature/concept prediction.
|
| 1177 |
+
3. Local and prompt-wide paraphrase robustness.
|
| 1178 |
+
4. Controlled live concept contrast.
|
| 1179 |
+
5. Single-feature causal intervention and dose-response.
|
| 1180 |
+
6. Joint feature-set intervention and set-size sensitivity.
|
| 1181 |
+
7. Individual-vs-joint non-additivity.
|
| 1182 |
+
8. Specificity relative to norm-matched random controls.
|
| 1183 |
|
| 1184 |
+
Association, robustness, and intervention evidence remain separate claims.
|
| 1185 |
"""
|
| 1186 |
)
|
| 1187 |
|
| 1188 |
+
gr.HTML(
|
| 1189 |
+
'<footer class="classic-footer">Built with PyTorch, Transformers, Qwen3-1.7B-Base and Qwen-Scope '
|
| 1190 |
+
'residual-stream SAEs. FeatureLens is independent of thesis code and thesis datasets.<br><br>'
|
| 1191 |
+
'End of workbench.</footer>'
|
| 1192 |
)
|
| 1193 |
|
| 1194 |
+
# Event wiring.
|
| 1195 |
analyze_btn.click(
|
| 1196 |
analyze_prompt,
|
| 1197 |
inputs=[prompt, layer, token_index, top_n],
|
|
|
|
| 1201 |
feature_plot,
|
| 1202 |
feature_id,
|
| 1203 |
feature_set_ids,
|
| 1204 |
+
contrast_feature_id,
|
| 1205 |
+
contrast_layer,
|
| 1206 |
analysis_metrics,
|
| 1207 |
+
feature_set_location,
|
| 1208 |
+
contrast_location,
|
| 1209 |
+
feature_tsv,
|
| 1210 |
],
|
| 1211 |
)
|
| 1212 |
mode.change(mode_help, inputs=[mode], outputs=[coefficient])
|
|
|
|
| 1219 |
intervention_metrics,
|
| 1220 |
token_prob_table,
|
| 1221 |
target_token_table,
|
| 1222 |
+
token_prob_tsv,
|
| 1223 |
+
target_token_tsv,
|
| 1224 |
],
|
| 1225 |
)
|
| 1226 |
dose_btn.click(
|
| 1227 |
run_dose_response,
|
| 1228 |
inputs=[prompt, layer, token_index, feature_id, target_text],
|
| 1229 |
+
outputs=[dose_table, dose_plot, dose_metrics, dose_tsv],
|
| 1230 |
)
|
| 1231 |
set_mode.change(set_mode_help, inputs=[set_mode], outputs=[set_coefficient])
|
| 1232 |
set_btn.click(
|
| 1233 |
run_feature_set,
|
| 1234 |
inputs=[prompt, layer, token_index, feature_set_ids, set_mode, set_coefficient, set_target],
|
| 1235 |
+
outputs=[set_feature_table, set_metrics, set_target_table, set_feature_tsv, set_target_tsv],
|
| 1236 |
)
|
| 1237 |
set_sweep_btn.click(
|
| 1238 |
run_feature_set_sweep,
|
| 1239 |
inputs=[prompt, layer, token_index, set_sweep_target],
|
| 1240 |
+
outputs=[set_sweep_table, set_sweep_plot, set_sweep_note, set_sweep_tsv],
|
| 1241 |
+
)
|
| 1242 |
+
interaction_btn.click(
|
| 1243 |
+
run_feature_interaction,
|
| 1244 |
+
inputs=[prompt, layer, token_index, feature_set_ids, interaction_target],
|
| 1245 |
+
outputs=[interaction_table, interaction_metrics, interaction_plot, interaction_tsv],
|
| 1246 |
+
)
|
| 1247 |
+
contrast_btn.click(
|
| 1248 |
+
run_concept_contrast,
|
| 1249 |
+
inputs=[contrast_feature_id, contrast_layer, contrast_n],
|
| 1250 |
+
outputs=[contrast_metrics, contrast_table, contrast_plot, contrast_tsv],
|
| 1251 |
)
|
| 1252 |
para_btn.click(
|
| 1253 |
run_paraphrase_compare,
|
| 1254 |
inputs=[para_a, para_b, para_layer, para_idx_a, para_idx_b, para_top_n],
|
| 1255 |
+
outputs=[para_tokens_a, para_tokens_b, para_metrics, para_table, para_plot, para_tsv],
|
| 1256 |
)
|
| 1257 |
trajectory_btn.click(
|
| 1258 |
run_layer_sweep,
|
| 1259 |
inputs=[trajectory_prompt, trajectory_token],
|
| 1260 |
+
outputs=[trajectory_tokens, trajectory_table, trajectory_plot, trajectory_tsv],
|
| 1261 |
)
|
| 1262 |
|
| 1263 |
+
for button, source in [
|
| 1264 |
+
(feature_copy, feature_tsv),
|
| 1265 |
+
(token_prob_copy, token_prob_tsv),
|
| 1266 |
+
(target_token_copy, target_token_tsv),
|
| 1267 |
+
(dose_copy, dose_tsv),
|
| 1268 |
+
(set_feature_copy, set_feature_tsv),
|
| 1269 |
+
(set_target_copy, set_target_tsv),
|
| 1270 |
+
(set_sweep_copy, set_sweep_tsv),
|
| 1271 |
+
(interaction_copy, interaction_tsv),
|
| 1272 |
+
(contrast_copy, contrast_tsv),
|
| 1273 |
+
(para_copy, para_tsv),
|
| 1274 |
+
(trajectory_copy, trajectory_tsv),
|
| 1275 |
+
]:
|
| 1276 |
+
_bind_copy(button, source)
|
| 1277 |
+
|
| 1278 |
+
|
| 1279 |
if __name__ == "__main__":
|
|
|
|
| 1280 |
demo.queue(default_concurrency_limit=1, max_size=8).launch(
|
| 1281 |
css=CSS,
|
| 1282 |
theme=THEME,
|
docs/HF_DEPLOY.md
CHANGED
|
@@ -9,46 +9,80 @@ On Hugging Face, `FEATURELENS_EAGER_LOAD` defaults to `1`. The runtime loads:
|
|
| 9 |
- `Qwen/Qwen3-1.7B-Base`;
|
| 10 |
- Qwen-Scope SAE layers **4, 14, 26** only.
|
| 11 |
|
| 12 |
-
The
|
| 13 |
|
| 14 |
-
`app.py`
|
| 15 |
|
| 16 |
## GPU-decorated actions
|
| 17 |
|
| 18 |
-
Current v0.
|
| 19 |
|
| 20 |
-
- **Inspect sparse features**
|
| 21 |
-
- **Run single-feature causal test**
|
| 22 |
-
- **Run scale dose-response**
|
| 23 |
-
- **Run joint feature-set causal test**
|
| 24 |
-
- **Run 1/3/5-feature ablation sweep**
|
| 25 |
-
- **
|
| 26 |
-
- **
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
|
| 30 |
-
##
|
| 31 |
|
| 32 |
-
v0.
|
| 33 |
|
| 34 |
-
|
| 35 |
-
- targeted/random conditions for k=1/3/5 feature-set ablation share one edited forward after the baseline;
|
| 36 |
-
- full-target SAE and random-control scoring is batched when possible.
|
| 37 |
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
|
| 42 |
-
|
| 43 |
|
| 44 |
-
The
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
## Offline benchmark
|
| 47 |
|
| 48 |
-
Do **not** run the
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
```bash
|
| 51 |
-
|
|
|
|
| 52 |
```
|
| 53 |
|
| 54 |
-
|
|
|
|
| 9 |
- `Qwen/Qwen3-1.7B-Base`;
|
| 10 |
- Qwen-Scope SAE layers **4, 14, 26** only.
|
| 11 |
|
| 12 |
+
The live app does not need every SAE layer from the full repository.
|
| 13 |
|
| 14 |
+
`app.py` launches with `ssr_mode=False`, matching the deployment path that removed the earlier SSR/auth coroutine warning during Space testing.
|
| 15 |
|
| 16 |
## GPU-decorated actions
|
| 17 |
|
| 18 |
+
Current v0.4 live actions include:
|
| 19 |
|
| 20 |
+
- **Inspect sparse features**;
|
| 21 |
+
- **Run single-feature causal test**;
|
| 22 |
+
- **Run scale dose-response**;
|
| 23 |
+
- **Run joint feature-set causal test**;
|
| 24 |
+
- **Run 1/3/5-feature ablation sweep**;
|
| 25 |
+
- **Run individual-vs-joint decomposition**;
|
| 26 |
+
- **Run controlled concept contrast**;
|
| 27 |
+
- **Compare paraphrase representations**;
|
| 28 |
+
- **Compare layers**.
|
| 29 |
|
| 30 |
+
Allocation durations in `app.py` are ceilings requested from ZeroGPU, not expected wall-clock runtimes.
|
| 31 |
|
| 32 |
+
## Batch-first causal execution
|
| 33 |
|
| 34 |
+
v0.4 deliberately batches related conditions so stronger diagnostics do not require a separate GPU callback for every condition.
|
| 35 |
|
| 36 |
+
Examples:
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
- scale dose-response stacks the six multipliers in one edited batch;
|
| 39 |
+
- single-feature causal tests stack zero edit, one targeted SAE edit, and eight norm-matched random controls;
|
| 40 |
+
- 1/3/5 feature-set sensitivity batches targeted edits and control ensembles;
|
| 41 |
+
- individual-vs-joint decomposition batches all individual ablations plus the joint ablation;
|
| 42 |
+
- controlled concept contrast evaluates its balanced prompt batch together.
|
| 43 |
|
| 44 |
+
The primary causal reference inside each experiment is a **batched zero-edit row**. This prevents batch-vs-single floating-point drift from being mistaken for an intervention effect.
|
| 45 |
|
| 46 |
+
## Greedy generation vs probability-level scoring
|
| 47 |
|
| 48 |
+
The single-feature causal test retains baseline and edited greedy generation because the visible text comparison is useful in a public demo.
|
| 49 |
+
|
| 50 |
+
The primary targeted causal metric uses teacher-forced **full-continuation** log-probability scoring. Greedy text may remain unchanged while probability-level metrics move.
|
| 51 |
+
|
| 52 |
+
The heavier feature-set, set-size, interaction, and contrast panels avoid unnecessary free-running generations.
|
| 53 |
+
|
| 54 |
+
## Clipboard export
|
| 55 |
+
|
| 56 |
+
Major output tables include a dedicated **Copy table with headers** action. The app serializes the result as tab-separated text before invoking the browser clipboard API. This makes pasted output self-describing and spreadsheet-friendly.
|
| 57 |
+
|
| 58 |
+
If browser clipboard permission is unavailable, the frontend uses a temporary-textarea fallback.
|
| 59 |
+
|
| 60 |
+
## Embedded-Space layout
|
| 61 |
+
|
| 62 |
+
The app uses:
|
| 63 |
+
|
| 64 |
+
- restrained serif typography;
|
| 65 |
+
- small corner radii rather than pill-heavy controls;
|
| 66 |
+
- explicit bottom padding on the Gradio container and tab contents;
|
| 67 |
+
- a visible footer with `End of workbench.`.
|
| 68 |
+
|
| 69 |
+
These changes prevent app-owned content from ending abruptly. The outer Hugging Face embedding frame may still resize asynchronously; that behavior is outside the Python layout itself.
|
| 70 |
|
| 71 |
## Offline benchmark
|
| 72 |
|
| 73 |
+
Do **not** run the complete research benchmark as an interactive public-Space action.
|
| 74 |
+
|
| 75 |
+
Run on separate CUDA compute:
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
python3 experiments/run_all.py
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
Both causal runners accept a configurable random-control count. For more stable offline control estimates, increase the value if compute permits, for example:
|
| 82 |
|
| 83 |
```bash
|
| 84 |
+
python3 -m experiments.run_causal --random-controls 16
|
| 85 |
+
python3 -m experiments.run_feature_sets --random-controls 16
|
| 86 |
```
|
| 87 |
|
| 88 |
+
Then commit only the small report/catalog/CSV/figure artifacts intended for presentation. Large activation arrays remain gitignored.
|
docs/METHODOLOGY.md
CHANGED
|
@@ -1,37 +1,83 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
## Primary
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
## Discovery data
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
```text
|
| 37 |
ablate: h' = h - z_i d_i
|
|
@@ -39,63 +85,212 @@ scale: h' = h + (α - 1) z_i d_i
|
|
| 39 |
inject: h' = h + δ d_i
|
| 40 |
```
|
| 41 |
|
| 42 |
-
|
| 43 |
|
| 44 |
-
|
| 45 |
|
| 46 |
-
|
| 47 |
|
| 48 |
-
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
- total target sequence log probability;
|
| 51 |
-
- mean log probability per
|
| 52 |
-
-
|
| 53 |
-
- SAE-edit deltas;
|
| 54 |
-
- matched-control deltas.
|
| 55 |
|
| 56 |
-
The primary aggregate causal metric is **Δ mean target log probability per token** because it is
|
| 57 |
|
| 58 |
-
Next-token
|
| 59 |
|
| 60 |
-
|
| 61 |
|
| 62 |
-
|
| 63 |
|
| 64 |
-
|
| 65 |
|
| 66 |
-
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
```text
|
| 71 |
0×, 0.5×, 1×, 1.5×, 2×, 3×
|
| 72 |
```
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
|
| 78 |
-
|
| 79 |
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
```text
|
| 83 |
h' = h + Σ_i∈S Δz_i d_i
|
| 84 |
```
|
| 85 |
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
|
| 91 |
|
| 92 |
-
|
| 93 |
|
| 94 |
-
|
| 95 |
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
- bootstrap 95% confidence intervals;
|
| 99 |
-
- paired sign-flip randomization tests
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
-
|
|
|
|
| 1 |
+
# FeatureLens methodology
|
| 2 |
|
| 3 |
+
## Primary question
|
| 4 |
|
| 5 |
+
FeatureLens separates **representation** from **causal influence**.
|
| 6 |
+
|
| 7 |
+
A sparse feature can predict a concept because it correlates with useful information in the residual stream. That does not imply that changing that feature direction will specifically change model behaviour. The project therefore evaluates four distinct properties:
|
| 8 |
+
|
| 9 |
+
1. reconstruction quality;
|
| 10 |
+
2. predictive association;
|
| 11 |
+
3. robustness across paraphrases;
|
| 12 |
+
4. causal sensitivity under controlled residual interventions.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Model and sparse dictionaries
|
| 17 |
+
|
| 18 |
+
The live workbench uses `Qwen/Qwen3-1.7B-Base` and Qwen-Scope residual-stream SAEs at layers `4`, `14`, and `26`.
|
| 19 |
+
|
| 20 |
+
Each selected residual location is encoded with the corresponding layer-specific SAE. Feature IDs are meaningful only **within one SAE dictionary**; numerical IDs must not be compared as if they were shared semantics across layers.
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
|
| 24 |
## Discovery data
|
| 25 |
|
| 26 |
+
The controlled discovery benchmark contains seven concept groups:
|
| 27 |
|
| 28 |
+
- code;
|
| 29 |
+
- mathematics;
|
| 30 |
+
- positive sentiment;
|
| 31 |
+
- negative sentiment;
|
| 32 |
+
- French language;
|
| 33 |
+
- factual entities;
|
| 34 |
+
- uncertainty.
|
| 35 |
|
| 36 |
+
Each concept contains 16 paraphrase pairs, two prompts per pair, for 224 prompts total.
|
| 37 |
|
| 38 |
+
Train/test splitting is grouped by `pair_id`, so the two paraphrases from the same source pair cannot leak across train and held-out test.
|
| 39 |
|
| 40 |
+
The offline discovery benchmark uses the final prompt-token residual. The live workbench remains token-selectable.
|
| 41 |
|
| 42 |
+
---
|
| 43 |
|
| 44 |
+
## Sparse feature discovery
|
| 45 |
|
| 46 |
+
For each configured layer, FeatureLens records the TopK SAE code at the chosen prompt location.
|
| 47 |
|
| 48 |
+
Candidate features are selected **using the training split only**. Training AUROC is the primary selection score, with activation-rate contrast used as a tie-break. Held-out AUROC and F1 are computed only after feature selection.
|
| 49 |
|
| 50 |
+
This prevents the project from selecting whichever SAE feature happened to look best on held-out data.
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## Dense linear-probe baseline
|
| 55 |
+
|
| 56 |
+
A multinomial logistic-regression probe is fit to dense residual vectors from the same layers.
|
| 57 |
+
|
| 58 |
+
The comparison asks whether concept information is linearly available in the dense representation even when no individual sparse feature cleanly isolates it.
|
| 59 |
|
| 60 |
+
A result such as strong linear-probe performance but weak single-feature AUROC would support a more distributed representation rather than a clean monosemantic feature story.
|
| 61 |
|
| 62 |
+
---
|
| 63 |
|
| 64 |
+
## Reconstruction diagnostics
|
| 65 |
+
|
| 66 |
+
For residual `h` and SAE reconstruction `h_hat`, FeatureLens records:
|
| 67 |
+
|
| 68 |
+
- reconstruction cosine;
|
| 69 |
+
- normalized mean squared error;
|
| 70 |
+
- active feature count;
|
| 71 |
+
- Top-5 activation mass;
|
| 72 |
+
- normalized sparse activation entropy in the live layer trajectory.
|
| 73 |
+
|
| 74 |
+
These are diagnostics, not causal evidence. Reconstruction quality can also vary substantially by layer.
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## Reconstruction-preserving residual interventions
|
| 79 |
+
|
| 80 |
+
For selected SAE activation `z_i`, decoder direction `d_i`, and scale multiplier `α`:
|
| 81 |
|
| 82 |
```text
|
| 83 |
ablate: h' = h - z_i d_i
|
|
|
|
| 85 |
inject: h' = h + δ d_i
|
| 86 |
```
|
| 87 |
|
| 88 |
+
Only the decoded **feature delta** is added to the original residual stream. FeatureLens does **not** replace the residual with the complete SAE reconstruction.
|
| 89 |
|
| 90 |
+
This avoids conflating the intervention with full-reconstruction error.
|
| 91 |
|
| 92 |
+
`inject` is intentionally interpreted differently from ablation/scaling: it tests an externally supplied decoder-direction steering coefficient even if that feature was not naturally active.
|
| 93 |
|
| 94 |
+
---
|
| 95 |
|
| 96 |
+
## Full-continuation teacher-forced scoring
|
| 97 |
+
|
| 98 |
+
A target such as `2x` may consist of multiple tokenizer tokens. FeatureLens therefore scores the complete target continuation, not only its first token.
|
| 99 |
+
|
| 100 |
+
The exact target token IDs are appended to the prompt. Teacher-forced model logits provide a log probability for every target token.
|
| 101 |
+
|
| 102 |
+
The live and offline causal metrics include:
|
| 103 |
+
|
| 104 |
+
- per-target-token log probability;
|
| 105 |
- total target sequence log probability;
|
| 106 |
+
- mean target log probability per token;
|
| 107 |
+
- intervention-minus-reference deltas.
|
|
|
|
|
|
|
| 108 |
|
| 109 |
+
The primary aggregate causal metric is **Δ mean target log probability per token**, because it is less directly dependent on target length than total sequence log probability.
|
| 110 |
|
| 111 |
+
Next-token Jensen-Shannon divergence and greedy generation remain complementary diagnostics. Identical greedy outputs do not imply an intervention had zero probability-level effect.
|
| 112 |
|
| 113 |
+
---
|
| 114 |
|
| 115 |
+
## Batched zero-edit causal reference
|
| 116 |
|
| 117 |
+
v0.3 exposed an important numerical issue: batched and separately executed model forwards can differ slightly even when the intended residual edit is zero. A nominal `1×` dose-response condition therefore showed a small non-zero effect when compared with a separately executed baseline.
|
| 118 |
|
| 119 |
+
v0.4 treats this as instrumentation drift, not causal evidence.
|
| 120 |
|
| 121 |
+
Every batched causal experiment now includes an explicit zero-edit condition:
|
| 122 |
+
|
| 123 |
+
```text
|
| 124 |
+
Δh = 0
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
All intervention deltas in that batch are measured against this **same-execution-context reference**.
|
| 128 |
+
|
| 129 |
+
For the scale dose-response, the `1×` row is the zero-edit reference itself, so its causal deltas are exactly zero by construction.
|
| 130 |
+
|
| 131 |
+
When a separately executed capture forward is also needed, FeatureLens reports the single-vs-batch discrepancy as **execution-context null drift**. That diagnostic is kept separate from causal effect size.
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## Norm-matched random-control ensemble
|
| 136 |
+
|
| 137 |
+
A single random residual direction is a fragile negative control: by chance it can be unusually weak or unusually disruptive.
|
| 138 |
+
|
| 139 |
+
For a targeted perturbation `Δh_sae`, FeatureLens generates deterministic random directions `r_j` and rescales each one so that:
|
| 140 |
+
|
| 141 |
+
```text
|
| 142 |
+
||r_j||_2 = ||Δh_sae||_2
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
The live app uses **8 random directions** for each targeted perturbation. Targeted and control conditions are evaluated in the same batched execution context.
|
| 146 |
+
|
| 147 |
+
The live app reports:
|
| 148 |
+
|
| 149 |
+
- signed random mean effect;
|
| 150 |
+
- mean absolute random effect;
|
| 151 |
+
- random-effect standard deviation;
|
| 152 |
+
- `|targeted effect| / mean(|random effect|)`;
|
| 153 |
+
- an empirical two-sided magnitude tail quantity:
|
| 154 |
+
|
| 155 |
+
```text
|
| 156 |
+
(1 + count(|random_j| >= |targeted|)) / (N + 1)
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
With only eight live controls this empirical value is intentionally coarse: the smallest possible value is `1/9`. It is an exploratory specificity diagnostic, **not** a conventional significance test.
|
| 160 |
+
|
| 161 |
+
Offline runs can increase `--random-controls` when compute allows.
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## Single-feature scale dose-response
|
| 166 |
+
|
| 167 |
+
The live dose-response experiment always evaluates:
|
| 168 |
|
| 169 |
```text
|
| 170 |
0×, 0.5×, 1×, 1.5×, 2×, 3×
|
| 171 |
```
|
| 172 |
|
| 173 |
+
where:
|
| 174 |
+
|
| 175 |
+
- `0×` = complete ablation;
|
| 176 |
+
- `1×` = zero edit;
|
| 177 |
+
- `2×` = double the native coefficient.
|
| 178 |
|
| 179 |
+
All six edited residual conditions are stacked along the batch dimension and scored together. The `1×` batch row is the reference for every reported delta.
|
| 180 |
|
| 181 |
+
A monotonic relationship would strengthen a simple directional causal interpretation, but monotonicity is not required. Non-monotonic responses are retained as evidence about the model rather than “corrected” away.
|
| 182 |
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## Joint feature-set interventions
|
| 186 |
+
|
| 187 |
+
A concept may be distributed across multiple sparse features.
|
| 188 |
+
|
| 189 |
+
For a same-layer feature set `S`, FeatureLens sums the individual decoder-direction deltas:
|
| 190 |
|
| 191 |
```text
|
| 192 |
h' = h + Σ_i∈S Δz_i d_i
|
| 193 |
```
|
| 194 |
|
| 195 |
+
The live app supports joint ablation and shared-multiplier scaling. Cross-layer feature sets are never combined into one residual intervention.
|
| 196 |
+
|
| 197 |
+
Additive injection is deliberately omitted from the feature-set UI because one shared additive coefficient across unrelated decoder directions has no uniquely natural interpretation.
|
| 198 |
+
|
| 199 |
+
---
|
| 200 |
+
|
| 201 |
+
## 1 / 3 / 5 feature-set sensitivity
|
| 202 |
+
|
| 203 |
+
At the selected prompt location, FeatureLens jointly ablates the strongest active:
|
| 204 |
+
|
| 205 |
+
```text
|
| 206 |
+
k = 1, 3, 5
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
features.
|
| 210 |
+
|
| 211 |
+
Each targeted joint perturbation receives its **own 8-direction norm-matched random ensemble**. The targeted edit, random controls, and zero-edit reference are scored in a batched execution context.
|
| 212 |
+
|
| 213 |
+
Increasing `k` is not assumed to increase effect magnitude or specificity. Non-monotonic behaviour may indicate redundancy, cancellation, distributed representation, or ordinary model non-linearity.
|
| 214 |
+
|
| 215 |
+
---
|
| 216 |
+
|
| 217 |
+
## Individual-vs-joint non-additivity
|
| 218 |
+
|
| 219 |
+
For a selected set of 2–5 active features, FeatureLens separately evaluates:
|
| 220 |
+
|
| 221 |
+
```text
|
| 222 |
+
feature 1 ablation
|
| 223 |
+
feature 2 ablation
|
| 224 |
+
...
|
| 225 |
+
joint ablation
|
| 226 |
+
zero-edit reference
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
Let `e_i` be the Δ mean target log probability/token for the individual ablation of feature `i`, and `e_joint` the joint effect.
|
| 230 |
+
|
| 231 |
+
FeatureLens reports:
|
| 232 |
+
|
| 233 |
+
```text
|
| 234 |
+
additive expectation = Σ_i e_i
|
| 235 |
+
interaction excess = e_joint - Σ_i e_i
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
and a normalized interaction value scaled by the sum of absolute individual effects.
|
| 239 |
+
|
| 240 |
+
A non-zero excess diagnoses **non-additivity under this intervention**. It does not establish that the selected SAE features directly interact with one another or form a mechanistic circuit; downstream nonlinearities can also produce non-additivity.
|
| 241 |
+
|
| 242 |
+
---
|
| 243 |
+
|
| 244 |
+
## Paraphrase robustness: selected-token and prompt-wide
|
| 245 |
+
|
| 246 |
+
The strict selected-token comparison measures:
|
| 247 |
|
| 248 |
+
- TopK feature-support Jaccard;
|
| 249 |
+
- sparse activation cosine.
|
| 250 |
+
|
| 251 |
+
This can be misleading when the two manually selected tokens play different roles. For example, comparing the final token `is` in one prompt with a final punctuation token in its paraphrase is not a clean semantic-anchor comparison.
|
| 252 |
+
|
| 253 |
+
v0.4 therefore adds a prompt-wide profile. For each SAE feature, FeatureLens takes its maximum activation across all prompt tokens:
|
| 254 |
+
|
| 255 |
+
```text
|
| 256 |
+
profile_i(prompt) = max_t z_{t,i}
|
| 257 |
+
```
|
| 258 |
|
| 259 |
+
The two prompt-wide sparse dictionaries are then compared by support Jaccard and cosine.
|
| 260 |
|
| 261 |
+
The selected-token and prompt-wide metrics answer different questions and are intentionally displayed together.
|
| 262 |
|
| 263 |
+
---
|
| 264 |
|
| 265 |
+
## Controlled concept contrast scan
|
| 266 |
+
|
| 267 |
+
The live **Feature evidence** tab provides an exploratory contrast for one selected feature.
|
| 268 |
+
|
| 269 |
+
It samples a small balanced batch from the same seven controlled concept groups, using only one wording from each paraphrase pair, and measures feature activation at the final prompt token.
|
| 270 |
+
|
| 271 |
+
For each concept it reports:
|
| 272 |
+
|
| 273 |
+
- mean activation;
|
| 274 |
+
- median activation;
|
| 275 |
+
- activation rate;
|
| 276 |
+
- maximum activation.
|
| 277 |
+
|
| 278 |
+
This is a **diagnostic**, not the offline feature-labeling procedure. The live scan never modifies `Offline concept hint` and does not claim semantic identity from a handful of prompts.
|
| 279 |
+
|
| 280 |
+
---
|
| 281 |
+
|
| 282 |
+
## Statistical interpretation in the offline report
|
| 283 |
+
|
| 284 |
+
The generated offline report uses paired targeted-vs-control comparisons.
|
| 285 |
+
|
| 286 |
+
For a causal condition with several random controls, the report first aggregates the control ensemble for that same task/condition rather than selecting an arbitrary random row.
|
| 287 |
+
|
| 288 |
+
It then uses:
|
| 289 |
|
| 290 |
- bootstrap 95% confidence intervals;
|
| 291 |
+
- paired sign-flip randomization tests;
|
| 292 |
+
- targeted/random effect-size ratios.
|
| 293 |
+
|
| 294 |
+
A large point-estimate ratio alone is not sufficient for a strong causal-specificity narrative if paired uncertainty remains weak.
|
| 295 |
|
| 296 |
+
FeatureLens keeps the raw rows even when the resulting conclusion is null, mixed, or contrary to the original hypothesis.
|
docs/VALIDATION.md
CHANGED
|
@@ -1,62 +1,87 @@
|
|
| 1 |
-
# FeatureLens v0.
|
| 2 |
|
| 3 |
-
This
|
| 4 |
|
| 5 |
-
The
|
| 6 |
|
| 7 |
-
1. software correctness;
|
| 8 |
2. Hugging Face / ZeroGPU deployment correctness;
|
| 9 |
-
3.
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
---
|
| 14 |
|
| 15 |
-
## A. Local
|
| 16 |
|
| 17 |
-
From the repository root:
|
| 18 |
|
| 19 |
```bash
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
```
|
| 24 |
|
| 25 |
Expected:
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
Optional lint gate:
|
| 32 |
|
| 33 |
```bash
|
| 34 |
-
|
| 35 |
```
|
| 36 |
|
| 37 |
---
|
| 38 |
|
| 39 |
-
## B. Hugging Face startup
|
| 40 |
|
| 41 |
### Test B1 — container startup
|
| 42 |
|
| 43 |
-
|
| 44 |
|
| 45 |
-
Expected launch
|
| 46 |
|
| 47 |
```text
|
| 48 |
* Running on local URL: http://0.0.0.0:7860
|
| 49 |
```
|
| 50 |
|
| 51 |
-
The earlier warning
|
| 52 |
|
| 53 |
```text
|
| 54 |
coroutine 'App.create_app.<locals>.get_current_user' was never awaited
|
| 55 |
```
|
| 56 |
|
| 57 |
-
The launch output should
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
---
|
| 62 |
|
|
@@ -64,9 +89,7 @@ Pass condition: Space reaches `Running` and the UI loads.
|
|
| 64 |
|
| 65 |
### Test C1 — inspect a mathematics prompt
|
| 66 |
|
| 67 |
-
Open **Workbench**
|
| 68 |
-
|
| 69 |
-
Set:
|
| 70 |
|
| 71 |
- **Prompt**: `The derivative of x squared is`
|
| 72 |
- **Residual layer**: `14`
|
|
@@ -75,35 +98,50 @@ Set:
|
|
| 75 |
|
| 76 |
Click **Inspect sparse features**.
|
| 77 |
|
| 78 |
-
Verify
|
| 79 |
|
| 80 |
-
1. **Prompt tokens** appears
|
| 81 |
-
2. **Analysis metrics**
|
|
|
|
|
|
|
| 82 |
- active SAE features;
|
| 83 |
- reconstruction cosine;
|
| 84 |
- NMSE;
|
| 85 |
- Top-5 activation mass.
|
| 86 |
-
3. **Strongest active SAE features** contains 12 rows unless fewer
|
| 87 |
-
4. **Activation profile** contains the same displayed feature IDs
|
| 88 |
-
5. **Single feature id** is populated
|
| 89 |
-
6. Open **Feature sets** and
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
---
|
| 99 |
|
| 100 |
## D. Single-feature causal test
|
| 101 |
|
| 102 |
-
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
### Test D1 — ablation with a multi-token target
|
| 107 |
|
| 108 |
Set:
|
| 109 |
|
|
@@ -115,149 +153,161 @@ Click **Run single-feature causal test**.
|
|
| 115 |
|
| 116 |
Verify:
|
| 117 |
|
| 118 |
-
1. **Baseline greedy generation**
|
| 119 |
-
2. **
|
| 120 |
-
3. **
|
| 121 |
4. The metrics report:
|
| 122 |
- original feature activation;
|
| 123 |
- Δ coefficient;
|
| 124 |
- perturbation L2;
|
| 125 |
- next-token JS;
|
| 126 |
-
- random-
|
| 127 |
-
-
|
| 128 |
-
-
|
| 129 |
-
-
|
| 130 |
-
-
|
| 131 |
-
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
|
| 136 |
-
|
| 137 |
-
- targeted and random perturbation L2 norms should be matched internally;
|
| 138 |
-
- greedy generations **may be identical** even when log-probability and JS metrics differ. This is not a failure.
|
| 139 |
|
| 140 |
-
|
|
|
|
|
|
|
| 141 |
|
| 142 |
Set:
|
| 143 |
|
| 144 |
- **Single-feature intervention**: `scale`
|
| 145 |
- **Feature multiplier**: `2`
|
| 146 |
-
-
|
| 147 |
|
| 148 |
Click **Run single-feature causal test**.
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
- for an active feature, `Δ coefficient ≈ +original activation`;
|
| 153 |
-
- full-continuation metrics are present;
|
| 154 |
-
- output need not differ at the text level.
|
| 155 |
|
| 156 |
-
### Test
|
| 157 |
|
| 158 |
Set:
|
| 159 |
|
| 160 |
- **Single-feature intervention**: `inject`
|
| 161 |
- **Additive feature coefficient**: `5`
|
| 162 |
-
-
|
|
|
|
|
|
|
| 163 |
|
| 164 |
Expected:
|
| 165 |
|
| 166 |
-
- `Δ coefficient = +5` regardless of
|
| 167 |
-
- perturbation L2 is finite;
|
| 168 |
-
-
|
| 169 |
|
| 170 |
---
|
| 171 |
|
| 172 |
-
## E.
|
| 173 |
|
| 174 |
-
|
| 175 |
|
| 176 |
-
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
This test has no ambiguous intervention mode: the panel is **always a scale sweep**.
|
| 181 |
|
| 182 |
Expected rows in **Scale dose-response measurements**:
|
| 183 |
|
| 184 |
```text
|
| 185 |
-
0
|
| 186 |
0.5
|
| 187 |
-
1
|
| 188 |
1.5
|
| 189 |
-
2
|
| 190 |
-
3
|
| 191 |
```
|
| 192 |
|
| 193 |
-
|
| 194 |
|
| 195 |
- `0×` = ablation;
|
| 196 |
-
- `0.5×` =
|
| 197 |
-
- `1×` = no
|
| 198 |
-
- `1.5×` =
|
| 199 |
- `2×` = double;
|
| 200 |
- `3×` = triple.
|
| 201 |
|
| 202 |
-
|
| 203 |
|
| 204 |
-
|
| 205 |
-
- `Perturbation L2 ≈ 0`;
|
| 206 |
-
- `Δ mean log p/token ≈ 0`;
|
| 207 |
-
- `Δ sequence log p ≈ 0`;
|
| 208 |
-
- `Next-token JS ≈ 0`.
|
| 209 |
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
-
---
|
| 213 |
|
| 214 |
-
|
| 215 |
|
| 216 |
-
|
| 217 |
|
| 218 |
-
|
| 219 |
|
| 220 |
-
|
| 221 |
|
| 222 |
-
|
|
|
|
|
|
|
| 223 |
|
| 224 |
Set:
|
| 225 |
|
| 226 |
-
- **
|
| 227 |
- **Target continuation**: `2x`
|
| 228 |
|
| 229 |
Click **Run joint feature-set causal test**.
|
| 230 |
|
| 231 |
Verify:
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
|
| 243 |
### Test F2 — joint scale
|
| 244 |
|
| 245 |
Set:
|
| 246 |
|
| 247 |
-
- **
|
| 248 |
- **Shared feature multiplier**: `2`
|
| 249 |
|
| 250 |
-
Run again.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
-
|
| 253 |
|
| 254 |
---
|
| 255 |
|
| 256 |
-
## G. 1/3/5
|
| 257 |
|
| 258 |
-
|
| 259 |
|
| 260 |
-
|
| 261 |
|
| 262 |
Click **Run 1/3/5-feature ablation sweep**.
|
| 263 |
|
|
@@ -271,24 +321,72 @@ Expected **Feature-set size measurements** rows:
|
|
| 271 |
|
| 272 |
For each row verify:
|
| 273 |
|
| 274 |
-
-
|
| 275 |
- perturbation L2 is finite;
|
| 276 |
-
-
|
| 277 |
-
-
|
| 278 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
-
|
| 281 |
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
---
|
| 285 |
|
| 286 |
-
##
|
| 287 |
|
| 288 |
-
Open **
|
| 289 |
|
| 290 |
Set:
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
- **Original prompt**: `The derivative of x squared is`
|
| 293 |
- **Paraphrase**: `Differentiate x squared with respect to x:`
|
| 294 |
- **Residual layer**: `14`
|
|
@@ -300,28 +398,40 @@ Click **Compare paraphrase representations**.
|
|
| 300 |
|
| 301 |
Verify:
|
| 302 |
|
| 303 |
-
1. **Original prompt tokens** and **Paraphrase tokens**
|
| 304 |
-
2. **Robustness metrics** reports:
|
| 305 |
-
-
|
| 306 |
-
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
4. **Original vs paraphrase activation** renders both conditions with distinct muted colors.
|
| 310 |
|
| 311 |
Bounds:
|
| 312 |
|
| 313 |
-
-
|
| 314 |
-
-
|
| 315 |
|
| 316 |
-
|
| 317 |
|
| 318 |
-
|
| 319 |
|
| 320 |
-
|
| 321 |
|
| 322 |
-
|
| 323 |
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
- **Prompt**: `The derivative of x squared is`
|
| 327 |
- **Prompt token index**: `-1`
|
|
@@ -331,37 +441,121 @@ Click **Compare layers**.
|
|
| 331 |
Expected:
|
| 332 |
|
| 333 |
- **Prompt tokens** renders;
|
| 334 |
-
- **Layer diagnostics**
|
| 335 |
-
- **Representation trajectory** contains
|
| 336 |
-
- Reconstruction cosine;
|
| 337 |
-
- Top-5 mass;
|
| 338 |
-
- Activation entropy.
|
| 339 |
|
| 340 |
-
Do not compare feature
|
| 341 |
|
| 342 |
---
|
| 343 |
|
| 344 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
|
| 346 |
-
|
| 347 |
|
| 348 |
-
|
| 349 |
|
| 350 |
-
|
| 351 |
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
|
| 356 |
-
|
| 357 |
|
| 358 |
-
|
| 359 |
|
| 360 |
-
|
| 361 |
|
| 362 |
-
|
| 363 |
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
Inspect:
|
| 367 |
|
|
@@ -369,9 +563,11 @@ Inspect:
|
|
| 369 |
This is not a positive review.
|
| 370 |
```
|
| 371 |
|
| 372 |
-
|
|
|
|
|
|
|
| 373 |
|
| 374 |
-
##
|
| 375 |
|
| 376 |
Inspect:
|
| 377 |
|
|
@@ -379,38 +575,81 @@ Inspect:
|
|
| 379 |
The answer est probablement correct, but I am not certain.
|
| 380 |
```
|
| 381 |
|
| 382 |
-
|
| 383 |
|
| 384 |
-
##
|
| 385 |
|
| 386 |
-
|
| 387 |
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
|
| 390 |
---
|
| 391 |
|
| 392 |
-
#
|
| 393 |
|
| 394 |
-
After the
|
| 395 |
|
| 396 |
```bash
|
| 397 |
-
|
| 398 |
```
|
| 399 |
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
|
| 402 |
```text
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
artifacts/feature_set_results.csv
|
|
|
|
|
|
|
| 404 |
```
|
| 405 |
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
- held-out
|
| 411 |
-
-
|
| 412 |
-
-
|
| 413 |
-
-
|
| 414 |
-
-
|
| 415 |
-
-
|
| 416 |
-
- whether
|
|
|
|
|
|
| 1 |
+
# FeatureLens v0.4 validation guide
|
| 2 |
|
| 3 |
+
This guide intentionally uses the **exact labels visible in the v0.4 Gradio UI**. Follow it in order after every Hugging Face deployment that changes runtime or UI code.
|
| 4 |
|
| 5 |
+
The tests are divided into four categories:
|
| 6 |
|
| 7 |
+
1. local software correctness;
|
| 8 |
2. Hugging Face / ZeroGPU deployment correctness;
|
| 9 |
+
3. causal-instrumentation sanity checks;
|
| 10 |
+
4. edge/adversarial behavior.
|
| 11 |
|
| 12 |
+
A successful live prompt is a software sanity check, **not** a scientific conclusion. Scientific claims should come from the saved offline benchmark artifacts and their paired controls.
|
| 13 |
|
| 14 |
---
|
| 15 |
|
| 16 |
+
## A. Local release gate
|
| 17 |
|
| 18 |
+
From the repository root run:
|
| 19 |
|
| 20 |
```bash
|
| 21 |
+
python3 -m pytest -q
|
| 22 |
+
python3 -m compileall -q app.py featurelens experiments scripts
|
| 23 |
+
python3 scripts/release_check.py
|
| 24 |
```
|
| 25 |
|
| 26 |
Expected:
|
| 27 |
|
| 28 |
+
```text
|
| 29 |
+
29 passed
|
| 30 |
+
FeatureLens release check: PASS
|
| 31 |
+
discovery prompts: 224
|
| 32 |
+
causal tasks: 28
|
| 33 |
+
layers: [4, 14, 26]
|
| 34 |
+
feature-set sizes: [1, 3, 5]
|
| 35 |
+
random controls: 8
|
| 36 |
+
release: v0.4.0
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
`compileall` should exit silently with status 0.
|
| 40 |
|
| 41 |
Optional lint gate:
|
| 42 |
|
| 43 |
```bash
|
| 44 |
+
python3 -m ruff check app.py featurelens experiments tests scripts
|
| 45 |
```
|
| 46 |
|
| 47 |
---
|
| 48 |
|
| 49 |
+
## B. Hugging Face startup and layout
|
| 50 |
|
| 51 |
### Test B1 — container startup
|
| 52 |
|
| 53 |
+
After a fresh Space rebuild, open the container logs.
|
| 54 |
|
| 55 |
+
Expected launch line:
|
| 56 |
|
| 57 |
```text
|
| 58 |
* Running on local URL: http://0.0.0.0:7860
|
| 59 |
```
|
| 60 |
|
| 61 |
+
The earlier deployment warning should not return:
|
| 62 |
|
| 63 |
```text
|
| 64 |
coroutine 'App.create_app.<locals>.get_current_user' was never awaited
|
| 65 |
```
|
| 66 |
|
| 67 |
+
The launch output should not contain `with SSR ⚡` because FeatureLens launches with `ssr_mode=False`.
|
| 68 |
+
|
| 69 |
+
**Pass:** the Space reaches `Running`, the UI loads, and no repeated startup exception appears.
|
| 70 |
+
|
| 71 |
+
### Test B2 — page bottom / embedded Space behavior
|
| 72 |
+
|
| 73 |
+
Open **Workbench**, scroll to the bottom of the full application, and then switch through **Feature sets**, **Feature evidence**, **Paraphrase robustness**, and **Layer trajectory**.
|
| 74 |
+
|
| 75 |
+
Verify:
|
| 76 |
+
|
| 77 |
+
- descenders such as `g`, `p`, `q`, and `y` are not clipped inside app content;
|
| 78 |
+
- the last interactive element is followed by visible breathing room;
|
| 79 |
+
- the footer containing `End of workbench.` can be reached;
|
| 80 |
+
- labels do not overlap controls after a tab switch.
|
| 81 |
|
| 82 |
+
Also resize the browser to roughly half desktop width.
|
| 83 |
+
|
| 84 |
+
**Pass:** form rows wrap rather than overlap, controls remain readable, and the footer is reachable. If the outer Hugging Face iframe itself temporarily refuses to resize and then corrects itself after loading, record that separately from an app-level clipping failure.
|
| 85 |
|
| 86 |
---
|
| 87 |
|
|
|
|
| 89 |
|
| 90 |
### Test C1 — inspect a mathematics prompt
|
| 91 |
|
| 92 |
+
Open **Workbench** and set:
|
|
|
|
|
|
|
| 93 |
|
| 94 |
- **Prompt**: `The derivative of x squared is`
|
| 95 |
- **Residual layer**: `14`
|
|
|
|
| 98 |
|
| 99 |
Click **Inspect sparse features**.
|
| 100 |
|
| 101 |
+
Verify:
|
| 102 |
|
| 103 |
+
1. **Prompt tokens** appears and one token has the stronger selected outline.
|
| 104 |
+
2. **Analysis metrics** reports:
|
| 105 |
+
- `Layer 14`;
|
| 106 |
+
- the resolved prompt token index;
|
| 107 |
- active SAE features;
|
| 108 |
- reconstruction cosine;
|
| 109 |
- NMSE;
|
| 110 |
- Top-5 activation mass.
|
| 111 |
+
3. **Strongest active SAE features** contains 12 rows unless fewer are available.
|
| 112 |
+
4. **Activation profile** contains the same displayed feature IDs.
|
| 113 |
+
5. **Single feature id** is populated.
|
| 114 |
+
6. Open **Feature sets** and verify **Feature set** is populated with active feature IDs.
|
| 115 |
+
7. Open **Feature evidence** and verify **Feature id** is populated.
|
| 116 |
+
|
| 117 |
+
Basic bounds:
|
| 118 |
|
| 119 |
+
- reconstruction cosine is finite and lies in `[-1, 1]`;
|
| 120 |
+
- NMSE is finite and non-negative;
|
| 121 |
+
- feature IDs lie in `[0, 32767]`;
|
| 122 |
+
- displayed TopK activations are non-negative.
|
| 123 |
|
| 124 |
+
### Test C2 — copy feature table with headers
|
| 125 |
+
|
| 126 |
+
Under **Strongest active SAE features**, click **Copy table with headers** and paste into a plain-text editor.
|
| 127 |
+
|
| 128 |
+
The **first pasted line must contain the column names**, tab-separated, for example:
|
| 129 |
+
|
| 130 |
+
```text
|
| 131 |
+
Rank Feature ID Activation Offline concept hint
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
The following lines must contain the table rows.
|
| 135 |
+
|
| 136 |
+
Repeat by pasting into a spreadsheet. Columns should split naturally on tabs.
|
| 137 |
|
| 138 |
---
|
| 139 |
|
| 140 |
## D. Single-feature causal test
|
| 141 |
|
| 142 |
+
Continue from Test C1 and choose the strongest value in **Single feature id**.
|
| 143 |
|
| 144 |
+
### Test D1 — active-feature ablation with a multi-token target
|
|
|
|
|
|
|
| 145 |
|
| 146 |
Set:
|
| 147 |
|
|
|
|
| 153 |
|
| 154 |
Verify:
|
| 155 |
|
| 156 |
+
1. **Baseline greedy generation** and **SAE-edited greedy generation** are populated.
|
| 157 |
+
2. **Next-token distribution shift** is populated.
|
| 158 |
+
3. **Target continuation token-by-token score** has one row for every token used to encode `2x`.
|
| 159 |
4. The metrics report:
|
| 160 |
- original feature activation;
|
| 161 |
- Δ coefficient;
|
| 162 |
- perturbation L2;
|
| 163 |
- next-token JS;
|
| 164 |
+
- random-ensemble mean JS and standard deviation;
|
| 165 |
+
- number of random controls (`8`);
|
| 166 |
+
- JS magnitude ratio and empirical tail probability;
|
| 167 |
+
- execution-context null drift;
|
| 168 |
+
- full target token count;
|
| 169 |
+
- sequence and mean-per-token target effects;
|
| 170 |
+
- random-ensemble target effect statistics.
|
| 171 |
+
5. For an active feature under ablation, `Δ coefficient ≈ -original feature activation`.
|
| 172 |
+
|
| 173 |
+
The two greedy generations are **allowed to be identical**. Probability/log-probability metrics are more sensitive than deterministic argmax text.
|
| 174 |
|
| 175 |
+
### Test D2 — full-continuation table copy
|
| 176 |
|
| 177 |
+
Click **Copy table with headers** under **Target continuation token-by-token score** and paste into a text editor.
|
|
|
|
|
|
|
| 178 |
|
| 179 |
+
The first line must contain all target-score headers, followed by one line per target token.
|
| 180 |
+
|
| 181 |
+
### Test D3 — scale
|
| 182 |
|
| 183 |
Set:
|
| 184 |
|
| 185 |
- **Single-feature intervention**: `scale`
|
| 186 |
- **Feature multiplier**: `2`
|
| 187 |
+
- **Target continuation (optional)**: `2x`
|
| 188 |
|
| 189 |
Click **Run single-feature causal test**.
|
| 190 |
|
| 191 |
+
For an active feature, expect `Δ coefficient ≈ +original feature activation`.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
+
### Test D4 — inject
|
| 194 |
|
| 195 |
Set:
|
| 196 |
|
| 197 |
- **Single-feature intervention**: `inject`
|
| 198 |
- **Additive feature coefficient**: `5`
|
| 199 |
+
- **Target continuation (optional)**: `2x`
|
| 200 |
+
|
| 201 |
+
Click **Run single-feature causal test**.
|
| 202 |
|
| 203 |
Expected:
|
| 204 |
|
| 205 |
+
- `Δ coefficient = +5` regardless of native activation;
|
| 206 |
+
- perturbation L2 is finite and non-zero;
|
| 207 |
+
- the random ensemble remains norm-matched to the resulting residual perturbation.
|
| 208 |
|
| 209 |
---
|
| 210 |
|
| 211 |
+
## E. Numerical-null / scale dose-response test
|
| 212 |
|
| 213 |
+
This is the **critical v0.4 regression test** for the small non-zero `1×` drift observed in v0.3.
|
| 214 |
|
| 215 |
+
Keep the Workbench prompt/location and selected active feature from Test C1. Keep **Target continuation (optional)** set to `2x`.
|
| 216 |
|
| 217 |
+
Open **Scale dose-response** and click **Run scale dose-response**.
|
|
|
|
|
|
|
| 218 |
|
| 219 |
Expected rows in **Scale dose-response measurements**:
|
| 220 |
|
| 221 |
```text
|
| 222 |
+
0
|
| 223 |
0.5
|
| 224 |
+
1
|
| 225 |
1.5
|
| 226 |
+
2
|
| 227 |
+
3
|
| 228 |
```
|
| 229 |
|
| 230 |
+
The experiment is always a scale sweep:
|
| 231 |
|
| 232 |
- `0×` = ablation;
|
| 233 |
+
- `0.5×` = half native activation;
|
| 234 |
+
- `1×` = batched no-edit reference;
|
| 235 |
+
- `1.5×` = 1.5 times native activation;
|
| 236 |
- `2×` = double;
|
| 237 |
- `3×` = triple.
|
| 238 |
|
| 239 |
+
### Required `1×` row
|
| 240 |
|
| 241 |
+
The `1×` row must report, apart from harmless display formatting such as `-0.0`:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
+
```text
|
| 244 |
+
Δ feature coefficient = 0
|
| 245 |
+
Perturbation L2 = 0
|
| 246 |
+
Δ mean log p/token = 0
|
| 247 |
+
Δ sequence log p = 0
|
| 248 |
+
Next-token JS = 0
|
| 249 |
+
```
|
| 250 |
|
| 251 |
+
Do **not** accept the old v0.3 behavior where the 1× row had a small non-zero causal delta. Any separately executed single-vs-batch numerical discrepancy should now appear only as an **execution-context null drift** diagnostic and must not be counted as the intervention effect.
|
| 252 |
|
| 253 |
+
The curve itself does not need to be monotonic.
|
| 254 |
|
| 255 |
+
---
|
| 256 |
|
| 257 |
+
## F. Feature-set causal tests
|
| 258 |
|
| 259 |
+
Run Test C1 first so **Feature set** is populated.
|
| 260 |
|
| 261 |
+
### Test F1 — top-three joint ablation
|
| 262 |
+
|
| 263 |
+
Open **Feature sets**. Select exactly the three strongest active values in **Feature set**.
|
| 264 |
|
| 265 |
Set:
|
| 266 |
|
| 267 |
+
- **Intervention**: `ablate`
|
| 268 |
- **Target continuation**: `2x`
|
| 269 |
|
| 270 |
Click **Run joint feature-set causal test**.
|
| 271 |
|
| 272 |
Verify:
|
| 273 |
|
| 274 |
+
- **Joint intervention features** has exactly three rows;
|
| 275 |
+
- every active feature has `Δ coefficient = -original activation`;
|
| 276 |
+
- the total joint perturbation L2 is finite;
|
| 277 |
+
- the target continuation contains all target tokens;
|
| 278 |
+
- random-control count is `8`;
|
| 279 |
+
- random signed mean, random mean absolute effect, random standard deviation, magnitude ratio, and empirical tail probability are shown;
|
| 280 |
+
- execution-context null drift is shown separately from causal effect.
|
| 281 |
+
|
| 282 |
+
Do not require the targeted effect to exceed the random ensemble on every prompt. This is an empirical question.
|
| 283 |
|
| 284 |
### Test F2 — joint scale
|
| 285 |
|
| 286 |
Set:
|
| 287 |
|
| 288 |
+
- **Intervention**: `scale`
|
| 289 |
- **Shared feature multiplier**: `2`
|
| 290 |
|
| 291 |
+
Run **Run joint feature-set causal test** again.
|
| 292 |
+
|
| 293 |
+
Expected: each selected active feature receives its own positive native-activation delta before decoder-direction deltas are summed.
|
| 294 |
+
|
| 295 |
+
### Test F3 — copy joint tables
|
| 296 |
+
|
| 297 |
+
Use **Copy table with headers** under both:
|
| 298 |
+
|
| 299 |
+
- **Joint intervention features**;
|
| 300 |
+
- **Target continuation token-by-token score**.
|
| 301 |
|
| 302 |
+
For both, the first pasted line must contain the table headers.
|
| 303 |
|
| 304 |
---
|
| 305 |
|
| 306 |
+
## G. 1/3/5 feature-set sensitivity
|
| 307 |
|
| 308 |
+
In **Feature sets**, set:
|
| 309 |
|
| 310 |
+
- **Target continuation for set-size sweep**: `2x`
|
| 311 |
|
| 312 |
Click **Run 1/3/5-feature ablation sweep**.
|
| 313 |
|
|
|
|
| 321 |
|
| 322 |
For each row verify:
|
| 323 |
|
| 324 |
+
- exactly `k` feature IDs are listed;
|
| 325 |
- perturbation L2 is finite;
|
| 326 |
+
- targeted Δ mean log p/token is present;
|
| 327 |
+
- random-ensemble signed mean, mean absolute effect, and standard deviation are present;
|
| 328 |
+
- targeted/random magnitude ratio is finite or safely handled when the random effect is numerically zero;
|
| 329 |
+
- empirical tail probability lies in `[0, 1]`;
|
| 330 |
+
- SAE and random next-token JS statistics are present.
|
| 331 |
+
|
| 332 |
+
**Do not require monotonicity with k.** A k=3 edit may be less effective than k=1, and k=5 may reverse the effect. That can be a real non-linear response rather than a bug.
|
| 333 |
+
|
| 334 |
+
---
|
| 335 |
+
|
| 336 |
+
## H. Individual-vs-joint non-additivity
|
| 337 |
+
|
| 338 |
+
In **Feature sets**, select exactly the three strongest active values in **Feature set**.
|
| 339 |
+
|
| 340 |
+
Set:
|
| 341 |
+
|
| 342 |
+
- **Target continuation for interaction test**: `2x`
|
| 343 |
|
| 344 |
+
Click **Run individual-vs-joint decomposition**.
|
| 345 |
|
| 346 |
+
Verify:
|
| 347 |
+
|
| 348 |
+
1. **Individual and joint ablation measurements** contains one row per selected feature plus one `Joint ablation` row.
|
| 349 |
+
2. The summary reports:
|
| 350 |
+
- additive expectation = sum of individual Δ mean log p/token values;
|
| 351 |
+
- observed joint effect;
|
| 352 |
+
- interaction excess = joint − additive;
|
| 353 |
+
- normalized interaction;
|
| 354 |
+
- execution-context null drift.
|
| 355 |
+
3. The joint row perturbation norm is finite.
|
| 356 |
+
|
| 357 |
+
No particular sign is required. A non-zero interaction excess indicates non-additivity under this intervention, **not** proof that the selected SAE features form a direct circuit.
|
| 358 |
|
| 359 |
---
|
| 360 |
|
| 361 |
+
## I. Controlled concept contrast
|
| 362 |
|
| 363 |
+
First run Test C1. Open **Feature evidence**.
|
| 364 |
|
| 365 |
Set:
|
| 366 |
|
| 367 |
+
- **Feature id**: the strongest feature from Test C1;
|
| 368 |
+
- **Residual layer**: `14`;
|
| 369 |
+
- **Prompts per concept**: `4`.
|
| 370 |
+
|
| 371 |
+
Click **Run controlled concept contrast**.
|
| 372 |
+
|
| 373 |
+
Expected **Feature activation by controlled concept**:
|
| 374 |
+
|
| 375 |
+
- exactly seven concept rows;
|
| 376 |
+
- each row reports prompt count, mean activation, median activation, activation rate, and max activation;
|
| 377 |
+
- the chart contains the same seven concepts;
|
| 378 |
+
- the summary may identify the group with the highest mean activation but must explicitly describe the scan as exploratory.
|
| 379 |
+
|
| 380 |
+
This live scan is **not allowed to rename the feature or populate `Offline concept hint`**. A semantic hint should only come from the offline train/held-out discovery pipeline.
|
| 381 |
+
|
| 382 |
+
Use **Copy table with headers** and verify the first copied line contains all columns.
|
| 383 |
+
|
| 384 |
+
---
|
| 385 |
+
|
| 386 |
+
## J. Paraphrase robustness
|
| 387 |
+
|
| 388 |
+
Open **Paraphrase robustness** and set:
|
| 389 |
+
|
| 390 |
- **Original prompt**: `The derivative of x squared is`
|
| 391 |
- **Paraphrase**: `Differentiate x squared with respect to x:`
|
| 392 |
- **Residual layer**: `14`
|
|
|
|
| 398 |
|
| 399 |
Verify:
|
| 400 |
|
| 401 |
+
1. **Original prompt tokens** and **Paraphrase tokens** render.
|
| 402 |
+
2. **Robustness metrics** reports two distinct comparisons:
|
| 403 |
+
- selected-token TopK Jaccard and sparse activation cosine;
|
| 404 |
+
- **prompt-wide** max-pooled Jaccard and cosine.
|
| 405 |
+
3. **Top-feature overlap at selected tokens** marks features `shared`, `original only`, or `paraphrase only`.
|
| 406 |
+
4. The prompt-wide metrics remain available even when the two selected final tokens play different syntactic/semantic roles.
|
|
|
|
| 407 |
|
| 408 |
Bounds:
|
| 409 |
|
| 410 |
+
- Jaccards lie in `[0, 1]`;
|
| 411 |
+
- cosines should lie approximately in `[0, 1]` for non-negative SAE activations.
|
| 412 |
|
| 413 |
+
Do not impose an arbitrary minimum robustness threshold.
|
| 414 |
|
| 415 |
+
### Test J2 — identity sanity check
|
| 416 |
|
| 417 |
+
Use the **same exact prompt** in **Original prompt** and **Paraphrase**, with the same token indices and layer.
|
| 418 |
|
| 419 |
+
Expected, within numerical precision:
|
| 420 |
|
| 421 |
+
```text
|
| 422 |
+
selected-token Jaccard ≈ 1
|
| 423 |
+
selected-token cosine ≈ 1
|
| 424 |
+
prompt-wide Jaccard ≈ 1
|
| 425 |
+
prompt-wide cosine ≈ 1
|
| 426 |
+
```
|
| 427 |
+
|
| 428 |
+
This is a strong sanity check for the comparison implementation.
|
| 429 |
+
|
| 430 |
+
---
|
| 431 |
+
|
| 432 |
+
## K. Layer trajectory
|
| 433 |
+
|
| 434 |
+
Open **Layer trajectory** and set:
|
| 435 |
|
| 436 |
- **Prompt**: `The derivative of x squared is`
|
| 437 |
- **Prompt token index**: `-1`
|
|
|
|
| 441 |
Expected:
|
| 442 |
|
| 443 |
- **Prompt tokens** renders;
|
| 444 |
+
- **Layer diagnostics** has exactly three rows: `4`, `14`, `26`;
|
| 445 |
+
- **Representation trajectory** contains reconstruction cosine, Top-5 mass, and activation entropy.
|
|
|
|
|
|
|
|
|
|
| 446 |
|
| 447 |
+
Do not compare numerical feature IDs across layers. Each residual layer has its own independently learned SAE dictionary.
|
| 448 |
|
| 449 |
---
|
| 450 |
|
| 451 |
+
# L. Edge and adversarial tests
|
| 452 |
+
|
| 453 |
+
These tests are designed to expose software or interpretation failures. They are not meant to produce attractive scientific results.
|
| 454 |
+
|
| 455 |
+
## Test L1 — empty prompt
|
| 456 |
+
|
| 457 |
+
In **Workbench**, clear **Prompt** completely and click **Inspect sparse features**.
|
| 458 |
+
|
| 459 |
+
**Pass:** the UI displays a clear error and the Space remains alive. The container must not restart.
|
| 460 |
|
| 461 |
+
## Test L2 — out-of-range prompt token index
|
| 462 |
|
| 463 |
+
Use the normal mathematics prompt but set **Prompt token index** to `999` and click **Inspect sparse features**.
|
| 464 |
|
| 465 |
+
**Pass:** a clear index/range error is shown; the application remains usable afterward.
|
| 466 |
|
| 467 |
+
## Test L3 — feature-ID boundaries
|
| 468 |
+
|
| 469 |
+
After a successful Workbench inspection, manually enter the following in **Single feature id**:
|
| 470 |
+
|
| 471 |
+
1. `0` — valid boundary;
|
| 472 |
+
2. `32767` — valid boundary;
|
| 473 |
+
3. `32768` — invalid.
|
| 474 |
+
|
| 475 |
+
Run **Run single-feature causal test** with a short target.
|
| 476 |
+
|
| 477 |
+
**Pass:** IDs 0 and 32767 are accepted; 32768 produces a clear validation error without crashing the Space.
|
| 478 |
+
|
| 479 |
+
## Test L4 — inactive-feature semantics
|
| 480 |
+
|
| 481 |
+
Find a feature that has activation 0 at the selected prompt location. A practical way is to enter a valid feature ID that is not among the active TopK IDs.
|
| 482 |
+
|
| 483 |
+
Set **Single-feature intervention** to `ablate` and run the causal test.
|
| 484 |
+
|
| 485 |
+
Expected:
|
| 486 |
|
| 487 |
+
```text
|
| 488 |
+
Original feature activation ≈ 0
|
| 489 |
+
Δ coefficient ≈ 0
|
| 490 |
+
Perturbation L2 ≈ 0
|
| 491 |
+
```
|
| 492 |
+
|
| 493 |
+
The UI should state that ablation/scaling of an inactive feature has zero coefficient delta.
|
| 494 |
+
|
| 495 |
+
Then switch to `inject`, set **Additive feature coefficient** to `5`, and run again.
|
| 496 |
+
|
| 497 |
+
Expected: a non-zero residual perturbation is possible because injection intentionally tests the decoder direction even when the native activation is zero.
|
| 498 |
+
|
| 499 |
+
## Test L5 — zero-perturbation random-control handling
|
| 500 |
+
|
| 501 |
+
Continue Test L4 with the inactive feature and `ablate`.
|
| 502 |
+
|
| 503 |
+
**Pass:** the zero targeted perturbation does not create `NaN`, `inf`, or a crash in random-control statistics. JS/effect values should be zero or numerical zero and ratios/p-values must be safely represented.
|
| 504 |
+
|
| 505 |
+
## Test L6 — exact target whitespace
|
| 506 |
+
|
| 507 |
+
For the same prompt and feature, run the target twice:
|
| 508 |
+
|
| 509 |
+
```text
|
| 510 |
+
2x
|
| 511 |
+
```
|
| 512 |
+
|
| 513 |
+
and
|
| 514 |
+
|
| 515 |
+
```text
|
| 516 |
+
2x
|
| 517 |
+
```
|
| 518 |
|
| 519 |
+
(the second begins with a space).
|
| 520 |
|
| 521 |
+
**Pass:** FeatureLens reports the actual tokenization of each exact string and scores all returned target tokens. Different tokenizations/results are allowed.
|
| 522 |
|
| 523 |
+
## Test L7 — required-target validation
|
| 524 |
|
| 525 |
+
Clear the target and try:
|
| 526 |
|
| 527 |
+
- **Run scale dose-response**;
|
| 528 |
+
- **Run joint feature-set causal test**;
|
| 529 |
+
- **Run 1/3/5-feature ablation sweep**;
|
| 530 |
+
- **Run individual-vs-joint decomposition**.
|
| 531 |
+
|
| 532 |
+
**Pass:** every experiment that mathematically requires a target gives a clear UI error rather than silently inventing one or failing deep in the model call.
|
| 533 |
+
|
| 534 |
+
The ordinary **Run single-feature causal test** may be used without a target because next-token JS and greedy generation are still defined.
|
| 535 |
+
|
| 536 |
+
## Test L8 — identical paraphrases
|
| 537 |
+
|
| 538 |
+
Repeat Test J2 with the exact same text, same layer, and same token index.
|
| 539 |
+
|
| 540 |
+
**Pass:** all robustness metrics are approximately 1. This is the adversarial regression check for the prompt-wide pooling code.
|
| 541 |
+
|
| 542 |
+
## Test L9 — unrelated “paraphrase”
|
| 543 |
+
|
| 544 |
+
Compare:
|
| 545 |
+
|
| 546 |
+
```text
|
| 547 |
+
The derivative of x squared is
|
| 548 |
+
```
|
| 549 |
+
|
| 550 |
+
with:
|
| 551 |
+
|
| 552 |
+
```text
|
| 553 |
+
Je voudrais réserver une table pour demain soir.
|
| 554 |
+
```
|
| 555 |
+
|
| 556 |
+
**Pass:** the app returns finite robustness metrics but does **not** label the prompts as semantically equivalent merely because some features overlap. No hard numerical threshold is required.
|
| 557 |
+
|
| 558 |
+
## Test L10 — lexical trap / negation
|
| 559 |
|
| 560 |
Inspect:
|
| 561 |
|
|
|
|
| 563 |
This is not a positive review.
|
| 564 |
```
|
| 565 |
|
| 566 |
+
Then optionally run **Run controlled concept contrast** on one strongly active feature.
|
| 567 |
+
|
| 568 |
+
**Pass:** the system reports activations without converting the lexical word `positive` into a hard semantic claim. `Offline concept hint` must remain whatever the offline catalog says.
|
| 569 |
|
| 570 |
+
## Test L11 — mixed-language prompt
|
| 571 |
|
| 572 |
Inspect:
|
| 573 |
|
|
|
|
| 575 |
The answer est probablement correct, but I am not certain.
|
| 576 |
```
|
| 577 |
|
| 578 |
+
**Pass:** tokenization, SAE inspection, and causal controls remain finite. Do not expect a single concept feature to dominate.
|
| 579 |
|
| 580 |
+
## Test L12 — long prompt truncation
|
| 581 |
|
| 582 |
+
Paste a prompt substantially longer than the configured prompt-token limit (repeat several paragraphs if needed) and click **Inspect sparse features**.
|
| 583 |
|
| 584 |
+
**Pass:** the request truncates safely to the configured maximum instead of causing an OOM or index corruption. **Prompt tokens** should reflect the actually processed token sequence.
|
| 585 |
+
|
| 586 |
+
## Test L13 — table clipboard behavior
|
| 587 |
+
|
| 588 |
+
For at least three different output tables, click **Copy table with headers** and paste into:
|
| 589 |
+
|
| 590 |
+
1. a plain-text editor;
|
| 591 |
+
2. a spreadsheet.
|
| 592 |
+
|
| 593 |
+
**Pass:** the first row always contains headers, data are tab-separated, and embedded commas in cells do not shift spreadsheet columns.
|
| 594 |
+
|
| 595 |
+
## Test L14 — rapid double click / queue safety
|
| 596 |
+
|
| 597 |
+
Click one GPU-heavy action twice quickly, for example **Run single-feature causal test**.
|
| 598 |
+
|
| 599 |
+
**Pass:** requests queue under the app's concurrency limit rather than corrupting shared activation hooks or crashing the runtime. Do not repeatedly hammer the button beyond this single queue test because ZeroGPU quota is limited.
|
| 600 |
+
|
| 601 |
+
## Test L15 — responsive Feature Sets form
|
| 602 |
+
|
| 603 |
+
At narrow browser width, open **Feature sets**.
|
| 604 |
+
|
| 605 |
+
Verify the row containing:
|
| 606 |
+
|
| 607 |
+
- **Intervention**;
|
| 608 |
+
- **Multiplier (unused for ablation)** or **Shared feature multiplier**;
|
| 609 |
+
- **Target continuation**
|
| 610 |
+
|
| 611 |
+
wraps cleanly. Switch **Intervention** between `ablate` and `scale`.
|
| 612 |
+
|
| 613 |
+
**Pass:** labels remain attached to the correct fields, no control text runs together, and changing the mode updates the multiplier label without shifting the target field into an ambiguous position.
|
| 614 |
|
| 615 |
---
|
| 616 |
|
| 617 |
+
# M. Offline scientific benchmark acceptance
|
| 618 |
|
| 619 |
+
After the public Space passes the live tests, run on separate CUDA compute:
|
| 620 |
|
| 621 |
```bash
|
| 622 |
+
python3 experiments/run_all.py
|
| 623 |
```
|
| 624 |
|
| 625 |
+
v0.4 offline causal runners use:
|
| 626 |
+
|
| 627 |
+
- a batched zero-edit reference;
|
| 628 |
+
- targeted SAE interventions;
|
| 629 |
+
- configurable norm-matched random-control ensembles (default `8`);
|
| 630 |
+
- full-continuation mean log-probability effects.
|
| 631 |
+
|
| 632 |
+
Expected major artifacts include:
|
| 633 |
|
| 634 |
```text
|
| 635 |
+
artifacts/feature_catalog.csv
|
| 636 |
+
artifacts/layer_metrics.csv
|
| 637 |
+
artifacts/stability.csv
|
| 638 |
+
artifacts/causal_results.csv
|
| 639 |
artifacts/feature_set_results.csv
|
| 640 |
+
artifacts/report.md
|
| 641 |
+
artifacts/figures/
|
| 642 |
```
|
| 643 |
|
| 644 |
+
Before putting any number on a resume or portfolio page, manually verify:
|
| 645 |
+
|
| 646 |
+
- train-only feature selection;
|
| 647 |
+
- group-wise paraphrase split integrity;
|
| 648 |
+
- held-out AUROC/F1;
|
| 649 |
+
- reconstruction metrics by layer;
|
| 650 |
+
- selected-token and paraphrase stability;
|
| 651 |
+
- complete random-control ensembles for each causal condition;
|
| 652 |
+
- execution-context null drift;
|
| 653 |
+
- targeted-vs-random paired effect uncertainty;
|
| 654 |
+
- whether single-feature and multi-feature conclusions agree or conflict;
|
| 655 |
+
- whether any narrative claim is stronger than the saved raw rows support.
|
experiments/make_report.py
CHANGED
|
@@ -33,7 +33,7 @@ def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
|
|
| 33 |
|
| 34 |
|
| 35 |
def _effect_column(frame: pd.DataFrame) -> str:
|
| 36 |
-
"""Prefer the
|
| 37 |
if 'target_mean_logprob_delta' in frame.columns:
|
| 38 |
return 'target_mean_logprob_delta'
|
| 39 |
return 'target_logprob_delta'
|
|
@@ -126,15 +126,24 @@ def _paired_stats(
|
|
| 126 |
random_condition: str,
|
| 127 |
seed: int,
|
| 128 |
) -> dict[str, float | list[float]]:
|
|
|
|
| 129 |
metric = _effect_column(frame)
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
)
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
if sae_abs.size == 0:
|
| 139 |
return {
|
| 140 |
'sae_abs': float('nan'),
|
|
@@ -343,7 +352,7 @@ def main() -> None:
|
|
| 343 |
'- Linear baseline: multinomial logistic regression on the dense residual stream.',
|
| 344 |
'- Single-feature causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
|
| 345 |
'- Feature-set causal edit: joint ablation of top same-layer concept features, evaluated at k=1/3/5 by default.',
|
| 346 |
-
'- Negative control: deterministic random residual
|
| 347 |
'- Target metric: exact full target continuation scored teacher-forced; mean log probability per target token is the primary length-comparable effect.',
|
| 348 |
'- Secondary diagnostics: first-token probability/rank, next-token JS divergence, and top-1 changes.',
|
| 349 |
'- Uncertainty: bootstrap 95% confidence intervals and paired sign-flip randomization tests.',
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def _effect_column(frame: pd.DataFrame) -> str:
|
| 36 |
+
"""Prefer the full-continuation length-normalized metric, with legacy fallback."""
|
| 37 |
if 'target_mean_logprob_delta' in frame.columns:
|
| 38 |
return 'target_mean_logprob_delta'
|
| 39 |
return 'target_logprob_delta'
|
|
|
|
| 126 |
random_condition: str,
|
| 127 |
seed: int,
|
| 128 |
) -> dict[str, float | list[float]]:
|
| 129 |
+
"""Pair one SAE effect with the mean absolute effect of its random-control ensemble."""
|
| 130 |
metric = _effect_column(frame)
|
| 131 |
+
sae = (
|
| 132 |
+
frame[frame['condition'] == sae_condition]
|
| 133 |
+
.groupby(index, as_index=False)[metric]
|
| 134 |
+
.first()
|
| 135 |
+
.rename(columns={metric: 'sae_effect'})
|
| 136 |
+
)
|
| 137 |
+
random = (
|
| 138 |
+
frame[frame['condition'] == random_condition]
|
| 139 |
+
.assign(_abs_effect=lambda data: np.abs(data[metric].astype(float)))
|
| 140 |
+
.groupby(index, as_index=False)['_abs_effect']
|
| 141 |
+
.mean()
|
| 142 |
+
.rename(columns={'_abs_effect': 'random_abs_effect'})
|
| 143 |
+
)
|
| 144 |
+
paired = sae.merge(random, on=index, how='inner')
|
| 145 |
+
sae_abs = np.abs(paired['sae_effect'].to_numpy(dtype=float))
|
| 146 |
+
random_abs = paired['random_abs_effect'].to_numpy(dtype=float)
|
| 147 |
if sae_abs.size == 0:
|
| 148 |
return {
|
| 149 |
'sae_abs': float('nan'),
|
|
|
|
| 352 |
'- Linear baseline: multinomial logistic regression on the dense residual stream.',
|
| 353 |
'- Single-feature causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
|
| 354 |
'- Feature-set causal edit: joint ablation of top same-layer concept features, evaluated at k=1/3/5 by default.',
|
| 355 |
+
'- Negative control: ensemble of deterministic random residual directions, each matched to the SAE perturbation L2 norm.',
|
| 356 |
'- Target metric: exact full target continuation scored teacher-forced; mean log probability per target token is the primary length-comparable effect.',
|
| 357 |
'- Secondary diagnostics: first-token probability/rank, next-token JS divergence, and top-1 changes.',
|
| 358 |
'- Uncertainty: bootstrap 95% confidence intervals and paired sign-flip randomization tests.',
|
experiments/run_causal.py
CHANGED
|
@@ -21,6 +21,7 @@ def parse_args() -> argparse.Namespace:
|
|
| 21 |
parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
|
| 22 |
parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'causal_results.csv')
|
| 23 |
parser.add_argument('--seed', type=int, default=42)
|
|
|
|
| 24 |
return parser.parse_args()
|
| 25 |
|
| 26 |
|
|
@@ -65,6 +66,15 @@ def append_target(inputs: dict[str, torch.Tensor], target_ids: list[int]) -> dic
|
|
| 65 |
}
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
@torch.inference_mode()
|
| 69 |
def main() -> None:
|
| 70 |
args = parse_args()
|
|
@@ -117,56 +127,56 @@ def main() -> None:
|
|
| 117 |
capture['hidden'] = hidden_from_output(output).detach()
|
| 118 |
|
| 119 |
handle = model.model.layers[layer].register_forward_hook(capture_hook)
|
| 120 |
-
|
| 121 |
handle.remove()
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
|
| 126 |
-
baseline_logits,
|
| 127 |
prompt_length=prompt_len,
|
| 128 |
target_ids=target_ids,
|
| 129 |
)
|
|
|
|
| 130 |
residual = capture['hidden'][0, prompt_len - 1]
|
| 131 |
encoding = sae.encode(residual)
|
| 132 |
original_activation = encoding.activation_for(feature_id)
|
| 133 |
-
target_id = target_ids[0]
|
| 134 |
-
baseline_prob = float(torch.softmax(baseline_next.float(), dim=-1)[target_id].item())
|
| 135 |
-
baseline_rank = int((baseline_next > baseline_next[target_id]).sum().item()) + 1
|
| 136 |
-
baseline_top1 = int(torch.argmax(baseline_next).item())
|
| 137 |
|
| 138 |
specs = [
|
| 139 |
('ablate', InterventionSpec('ablate', 0.0)),
|
| 140 |
('amplify_2x', InterventionSpec('scale', 2.0)),
|
| 141 |
]
|
| 142 |
-
condition_meta: list[tuple[str, str, InterventionSpec, torch.Tensor, float]] = []
|
| 143 |
for spec_idx, (intervention_name, spec) in enumerate(specs):
|
| 144 |
delta = residual_delta(sae.decoder_direction(feature_id), original_activation, spec)
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
delta,
|
| 147 |
-
seed=args.seed + task_idx *
|
|
|
|
| 148 |
)
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
(
|
| 152 |
-
intervention_name,
|
| 153 |
-
'sae_feature',
|
| 154 |
-
spec,
|
| 155 |
-
delta,
|
| 156 |
-
float(spec.delta_activation(original_activation)),
|
| 157 |
-
),
|
| 158 |
(
|
| 159 |
intervention_name,
|
| 160 |
'random_norm_matched',
|
|
|
|
| 161 |
spec,
|
| 162 |
control_delta,
|
| 163 |
math.nan,
|
| 164 |
-
)
|
| 165 |
-
|
| 166 |
-
)
|
| 167 |
|
| 168 |
-
|
| 169 |
-
deltas = torch.stack([item[
|
|
|
|
| 170 |
applied = {'done': False}
|
| 171 |
|
| 172 |
def batch_edit_hook(_module, _inp, output):
|
|
@@ -175,8 +185,7 @@ def main() -> None:
|
|
| 175 |
hidden = hidden_from_output(output)
|
| 176 |
modified = hidden.clone()
|
| 177 |
modified[:, prompt_len - 1, :] = (
|
| 178 |
-
modified[:, prompt_len - 1, :]
|
| 179 |
-
+ deltas.to(hidden.device, hidden.dtype)
|
| 180 |
)
|
| 181 |
applied['done'] = True
|
| 182 |
return replace_hidden(output, modified)
|
|
@@ -185,7 +194,26 @@ def main() -> None:
|
|
| 185 |
edited_out = model(**repeated, use_cache=False)
|
| 186 |
hook.remove()
|
| 187 |
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
modified_logits = edited_out.logits[row_idx]
|
| 190 |
modified_next = modified_logits[prompt_len - 1]
|
| 191 |
modified_prob = float(torch.softmax(modified_next.float(), dim=-1)[target_id].item())
|
|
@@ -212,9 +240,12 @@ def main() -> None:
|
|
| 212 |
'feature_activation': original_activation,
|
| 213 |
'intervention': intervention_name,
|
| 214 |
'condition': condition,
|
|
|
|
|
|
|
| 215 |
'delta_activation': delta_activation,
|
| 216 |
'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
|
| 217 |
-
|
|
|
|
| 218 |
'baseline_target_prob': baseline_prob,
|
| 219 |
'modified_target_prob': modified_prob,
|
| 220 |
'target_prob_delta': modified_prob - baseline_prob,
|
|
@@ -225,7 +256,6 @@ def main() -> None:
|
|
| 225 |
'baseline_target_rank': baseline_rank,
|
| 226 |
'modified_target_rank': modified_rank,
|
| 227 |
'target_rank_delta': modified_rank - baseline_rank,
|
| 228 |
-
# v0.3 primary target metric: exact full continuation, teacher-forced.
|
| 229 |
'baseline_target_sequence_logprob': baseline_seq,
|
| 230 |
'modified_target_sequence_logprob': modified_seq,
|
| 231 |
'target_sequence_logprob_delta': modified_seq - baseline_seq,
|
|
|
|
| 21 |
parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
|
| 22 |
parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'causal_results.csv')
|
| 23 |
parser.add_argument('--seed', type=int, default=42)
|
| 24 |
+
parser.add_argument('--random-controls', type=int, default=8)
|
| 25 |
return parser.parse_args()
|
| 26 |
|
| 27 |
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
|
| 69 |
+
def make_random_controls(delta: torch.Tensor, seed: int, count: int) -> list[torch.Tensor]:
|
| 70 |
+
if count < 1:
|
| 71 |
+
raise ValueError('--random-controls must be at least 1.')
|
| 72 |
+
return [
|
| 73 |
+
normalized_random_control(delta, seed=int(seed) + 104729 * idx)
|
| 74 |
+
for idx in range(int(count))
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
@torch.inference_mode()
|
| 79 |
def main() -> None:
|
| 80 |
args = parse_args()
|
|
|
|
| 127 |
capture['hidden'] = hidden_from_output(output).detach()
|
| 128 |
|
| 129 |
handle = model.model.layers[layer].register_forward_hook(capture_hook)
|
| 130 |
+
single_baseline_out = model(**full_inputs, use_cache=False)
|
| 131 |
handle.remove()
|
| 132 |
+
single_baseline_logits = single_baseline_out.logits[0]
|
| 133 |
+
_, single_baseline_mean, _ = sequence_logprob_summary(
|
| 134 |
+
single_baseline_logits,
|
|
|
|
|
|
|
| 135 |
prompt_length=prompt_len,
|
| 136 |
target_ids=target_ids,
|
| 137 |
)
|
| 138 |
+
|
| 139 |
residual = capture['hidden'][0, prompt_len - 1]
|
| 140 |
encoding = sae.encode(residual)
|
| 141 |
original_activation = encoding.activation_for(feature_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
specs = [
|
| 144 |
('ablate', InterventionSpec('ablate', 0.0)),
|
| 145 |
('amplify_2x', InterventionSpec('scale', 2.0)),
|
| 146 |
]
|
| 147 |
+
condition_meta: list[tuple[str, str, int, InterventionSpec, torch.Tensor, float]] = []
|
| 148 |
for spec_idx, (intervention_name, spec) in enumerate(specs):
|
| 149 |
delta = residual_delta(sae.decoder_direction(feature_id), original_activation, spec)
|
| 150 |
+
condition_meta.append(
|
| 151 |
+
(
|
| 152 |
+
intervention_name,
|
| 153 |
+
'sae_feature',
|
| 154 |
+
-1,
|
| 155 |
+
spec,
|
| 156 |
+
delta,
|
| 157 |
+
float(spec.delta_activation(original_activation)),
|
| 158 |
+
)
|
| 159 |
+
)
|
| 160 |
+
controls = make_random_controls(
|
| 161 |
delta,
|
| 162 |
+
seed=args.seed + task_idx * 1009 + spec_idx * 100_003,
|
| 163 |
+
count=args.random_controls,
|
| 164 |
)
|
| 165 |
+
for control_id, control_delta in enumerate(controls):
|
| 166 |
+
condition_meta.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
(
|
| 168 |
intervention_name,
|
| 169 |
'random_norm_matched',
|
| 170 |
+
control_id,
|
| 171 |
spec,
|
| 172 |
control_delta,
|
| 173 |
math.nan,
|
| 174 |
+
)
|
| 175 |
+
)
|
|
|
|
| 176 |
|
| 177 |
+
zero = torch.zeros_like(condition_meta[0][4])
|
| 178 |
+
deltas = torch.stack([zero, *[item[4] for item in condition_meta]], dim=0)
|
| 179 |
+
repeated = {key: value.repeat(deltas.shape[0], 1) for key, value in full_inputs.items()}
|
| 180 |
applied = {'done': False}
|
| 181 |
|
| 182 |
def batch_edit_hook(_module, _inp, output):
|
|
|
|
| 185 |
hidden = hidden_from_output(output)
|
| 186 |
modified = hidden.clone()
|
| 187 |
modified[:, prompt_len - 1, :] = (
|
| 188 |
+
modified[:, prompt_len - 1, :] + deltas.to(hidden.device, hidden.dtype)
|
|
|
|
| 189 |
)
|
| 190 |
applied['done'] = True
|
| 191 |
return replace_hidden(output, modified)
|
|
|
|
| 194 |
edited_out = model(**repeated, use_cache=False)
|
| 195 |
hook.remove()
|
| 196 |
|
| 197 |
+
baseline_logits = edited_out.logits[0]
|
| 198 |
+
baseline_next = baseline_logits[prompt_len - 1]
|
| 199 |
+
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
|
| 200 |
+
baseline_logits,
|
| 201 |
+
prompt_length=prompt_len,
|
| 202 |
+
target_ids=target_ids,
|
| 203 |
+
)
|
| 204 |
+
execution_drift_mean = float(baseline_mean - single_baseline_mean)
|
| 205 |
+
execution_drift_js = js_divergence_from_logits(
|
| 206 |
+
single_baseline_logits[prompt_len - 1], baseline_next
|
| 207 |
+
)
|
| 208 |
+
target_id = target_ids[0]
|
| 209 |
+
baseline_prob = float(torch.softmax(baseline_next.float(), dim=-1)[target_id].item())
|
| 210 |
+
baseline_rank = int((baseline_next > baseline_next[target_id]).sum().item()) + 1
|
| 211 |
+
baseline_top1 = int(torch.argmax(baseline_next).item())
|
| 212 |
+
|
| 213 |
+
for row_idx, (intervention_name, condition, control_id, spec, applied_delta, delta_activation) in enumerate(
|
| 214 |
+
condition_meta,
|
| 215 |
+
start=1,
|
| 216 |
+
):
|
| 217 |
modified_logits = edited_out.logits[row_idx]
|
| 218 |
modified_next = modified_logits[prompt_len - 1]
|
| 219 |
modified_prob = float(torch.softmax(modified_next.float(), dim=-1)[target_id].item())
|
|
|
|
| 240 |
'feature_activation': original_activation,
|
| 241 |
'intervention': intervention_name,
|
| 242 |
'condition': condition,
|
| 243 |
+
'control_id': control_id,
|
| 244 |
+
'random_control_count': args.random_controls,
|
| 245 |
'delta_activation': delta_activation,
|
| 246 |
'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
|
| 247 |
+
'execution_context_mean_logprob_drift': execution_drift_mean,
|
| 248 |
+
'execution_context_js_drift': execution_drift_js,
|
| 249 |
'baseline_target_prob': baseline_prob,
|
| 250 |
'modified_target_prob': modified_prob,
|
| 251 |
'target_prob_delta': modified_prob - baseline_prob,
|
|
|
|
| 256 |
'baseline_target_rank': baseline_rank,
|
| 257 |
'modified_target_rank': modified_rank,
|
| 258 |
'target_rank_delta': modified_rank - baseline_rank,
|
|
|
|
| 259 |
'baseline_target_sequence_logprob': baseline_seq,
|
| 260 |
'modified_target_sequence_logprob': modified_seq,
|
| 261 |
'target_sequence_logprob_delta': modified_seq - baseline_seq,
|
experiments/run_feature_sets.py
CHANGED
|
@@ -22,6 +22,7 @@ def parse_args() -> argparse.Namespace:
|
|
| 22 |
parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'feature_set_results.csv')
|
| 23 |
parser.add_argument('--sizes', type=int, nargs='+', default=[1, 3, 5])
|
| 24 |
parser.add_argument('--seed', type=int, default=42)
|
|
|
|
| 25 |
return parser.parse_args()
|
| 26 |
|
| 27 |
|
|
@@ -44,6 +45,14 @@ def append_target(inputs: dict[str, torch.Tensor], target_ids: list[int]) -> dic
|
|
| 44 |
}
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
@torch.inference_mode()
|
| 49 |
def main() -> None:
|
|
@@ -93,7 +102,6 @@ def main() -> None:
|
|
| 93 |
raise RuntimeError(f"Target tokenization empty for task {task['id']}")
|
| 94 |
target_ids = [int(x) for x in target_ids]
|
| 95 |
full_inputs = append_target(prompt_inputs, target_ids)
|
| 96 |
-
|
| 97 |
capture: dict = {}
|
| 98 |
|
| 99 |
def capture_hook(_module, _inp, output):
|
|
@@ -101,12 +109,11 @@ def main() -> None:
|
|
| 101 |
capture['hidden'] = hidden_from_output(output).detach()
|
| 102 |
|
| 103 |
handle = model.model.layers[layer].register_forward_hook(capture_hook)
|
| 104 |
-
|
| 105 |
handle.remove()
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
baseline_logits,
|
| 110 |
prompt_length=prompt_len,
|
| 111 |
target_ids=target_ids,
|
| 112 |
)
|
|
@@ -114,7 +121,7 @@ def main() -> None:
|
|
| 114 |
encoding = sae.encode(residual)
|
| 115 |
|
| 116 |
valid_sizes = [size for size in sizes if size <= len(candidate_ids)]
|
| 117 |
-
condition_meta: list[tuple[int, str, list[int], torch.Tensor]] = []
|
| 118 |
for size in valid_sizes:
|
| 119 |
feature_ids = candidate_ids[:size]
|
| 120 |
activations = [encoding.activation_for(feature_id) for feature_id in feature_ids]
|
|
@@ -124,19 +131,20 @@ def main() -> None:
|
|
| 124 |
activations,
|
| 125 |
InterventionSpec('ablate', 0.0),
|
| 126 |
)
|
| 127 |
-
|
|
|
|
| 128 |
delta,
|
| 129 |
-
seed=args.seed + task_idx *
|
| 130 |
-
|
| 131 |
-
condition_meta.extend(
|
| 132 |
-
[
|
| 133 |
-
(size, 'sae_feature_set', feature_ids, delta),
|
| 134 |
-
(size, 'random_norm_matched', feature_ids, control),
|
| 135 |
-
]
|
| 136 |
)
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
applied = {'done': False}
|
| 141 |
|
| 142 |
def edit_hook(_module, _inp, output):
|
|
@@ -154,7 +162,20 @@ def main() -> None:
|
|
| 154 |
edited_out = model(**repeated, use_cache=False)
|
| 155 |
hook.remove()
|
| 156 |
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
logits = edited_out.logits[row_idx]
|
| 159 |
seq_logp, mean_logp, _ = sequence_logprob_summary(
|
| 160 |
logits,
|
|
@@ -174,7 +195,11 @@ def main() -> None:
|
|
| 174 |
'feature_ids': ','.join(str(x) for x in feature_ids),
|
| 175 |
'active_selected_features': int(active_count),
|
| 176 |
'condition': condition,
|
|
|
|
|
|
|
| 177 |
'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
|
|
|
|
|
|
|
| 178 |
'baseline_target_sequence_logprob': baseline_seq,
|
| 179 |
'modified_target_sequence_logprob': seq_logp,
|
| 180 |
'target_sequence_logprob_delta': seq_logp - baseline_seq,
|
|
|
|
| 22 |
parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'feature_set_results.csv')
|
| 23 |
parser.add_argument('--sizes', type=int, nargs='+', default=[1, 3, 5])
|
| 24 |
parser.add_argument('--seed', type=int, default=42)
|
| 25 |
+
parser.add_argument('--random-controls', type=int, default=8)
|
| 26 |
return parser.parse_args()
|
| 27 |
|
| 28 |
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
|
| 48 |
+
def make_random_controls(delta: torch.Tensor, seed: int, count: int) -> list[torch.Tensor]:
|
| 49 |
+
if count < 1:
|
| 50 |
+
raise ValueError('--random-controls must be at least 1.')
|
| 51 |
+
return [
|
| 52 |
+
normalized_random_control(delta, seed=int(seed) + 104729 * idx)
|
| 53 |
+
for idx in range(int(count))
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
|
| 57 |
@torch.inference_mode()
|
| 58 |
def main() -> None:
|
|
|
|
| 102 |
raise RuntimeError(f"Target tokenization empty for task {task['id']}")
|
| 103 |
target_ids = [int(x) for x in target_ids]
|
| 104 |
full_inputs = append_target(prompt_inputs, target_ids)
|
|
|
|
| 105 |
capture: dict = {}
|
| 106 |
|
| 107 |
def capture_hook(_module, _inp, output):
|
|
|
|
| 109 |
capture['hidden'] = hidden_from_output(output).detach()
|
| 110 |
|
| 111 |
handle = model.model.layers[layer].register_forward_hook(capture_hook)
|
| 112 |
+
single_baseline_out = model(**full_inputs, use_cache=False)
|
| 113 |
handle.remove()
|
| 114 |
+
single_logits = single_baseline_out.logits[0]
|
| 115 |
+
_, single_mean, _ = sequence_logprob_summary(
|
| 116 |
+
single_logits,
|
|
|
|
| 117 |
prompt_length=prompt_len,
|
| 118 |
target_ids=target_ids,
|
| 119 |
)
|
|
|
|
| 121 |
encoding = sae.encode(residual)
|
| 122 |
|
| 123 |
valid_sizes = [size for size in sizes if size <= len(candidate_ids)]
|
| 124 |
+
condition_meta: list[tuple[int, str, int, list[int], torch.Tensor]] = []
|
| 125 |
for size in valid_sizes:
|
| 126 |
feature_ids = candidate_ids[:size]
|
| 127 |
activations = [encoding.activation_for(feature_id) for feature_id in feature_ids]
|
|
|
|
| 131 |
activations,
|
| 132 |
InterventionSpec('ablate', 0.0),
|
| 133 |
)
|
| 134 |
+
condition_meta.append((size, 'sae_feature_set', -1, feature_ids, delta))
|
| 135 |
+
controls = make_random_controls(
|
| 136 |
delta,
|
| 137 |
+
seed=args.seed + task_idx * 1009 + size * 100_003,
|
| 138 |
+
count=args.random_controls,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
)
|
| 140 |
+
for control_id, control in enumerate(controls):
|
| 141 |
+
condition_meta.append(
|
| 142 |
+
(size, 'random_norm_matched', control_id, feature_ids, control)
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
zero = torch.zeros_like(condition_meta[0][4])
|
| 146 |
+
deltas = torch.stack([zero, *[item[4] for item in condition_meta]], dim=0)
|
| 147 |
+
repeated = {key: value.repeat(deltas.shape[0], 1) for key, value in full_inputs.items()}
|
| 148 |
applied = {'done': False}
|
| 149 |
|
| 150 |
def edit_hook(_module, _inp, output):
|
|
|
|
| 162 |
edited_out = model(**repeated, use_cache=False)
|
| 163 |
hook.remove()
|
| 164 |
|
| 165 |
+
baseline_logits = edited_out.logits[0]
|
| 166 |
+
baseline_next = baseline_logits[prompt_len - 1]
|
| 167 |
+
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
|
| 168 |
+
baseline_logits,
|
| 169 |
+
prompt_length=prompt_len,
|
| 170 |
+
target_ids=target_ids,
|
| 171 |
+
)
|
| 172 |
+
execution_drift_mean = float(baseline_mean - single_mean)
|
| 173 |
+
execution_drift_js = js_divergence_from_logits(single_logits[prompt_len - 1], baseline_next)
|
| 174 |
+
|
| 175 |
+
for row_idx, (size, condition, control_id, feature_ids, applied_delta) in enumerate(
|
| 176 |
+
condition_meta,
|
| 177 |
+
start=1,
|
| 178 |
+
):
|
| 179 |
logits = edited_out.logits[row_idx]
|
| 180 |
seq_logp, mean_logp, _ = sequence_logprob_summary(
|
| 181 |
logits,
|
|
|
|
| 195 |
'feature_ids': ','.join(str(x) for x in feature_ids),
|
| 196 |
'active_selected_features': int(active_count),
|
| 197 |
'condition': condition,
|
| 198 |
+
'control_id': control_id,
|
| 199 |
+
'random_control_count': args.random_controls,
|
| 200 |
'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
|
| 201 |
+
'execution_context_mean_logprob_drift': execution_drift_mean,
|
| 202 |
+
'execution_context_js_drift': execution_drift_js,
|
| 203 |
'baseline_target_sequence_logprob': baseline_seq,
|
| 204 |
'modified_target_sequence_logprob': seq_logp,
|
| 205 |
'target_sequence_logprob_delta': seq_logp - baseline_seq,
|
featurelens/config.py
CHANGED
|
@@ -27,6 +27,8 @@ class Settings:
|
|
| 27 |
d_model: int = 2_048
|
| 28 |
max_prompt_tokens: int = int(os.getenv('FEATURELENS_MAX_PROMPT_TOKENS', '256'))
|
| 29 |
max_new_tokens: int = int(os.getenv('FEATURELENS_MAX_NEW_TOKENS', '32'))
|
|
|
|
|
|
|
| 30 |
eager_load: bool = os.getenv(
|
| 31 |
'FEATURELENS_EAGER_LOAD', '1' if os.getenv('SPACE_ID') else '0'
|
| 32 |
).lower() in {'1', 'true', 'yes', 'on'}
|
|
|
|
| 27 |
d_model: int = 2_048
|
| 28 |
max_prompt_tokens: int = int(os.getenv('FEATURELENS_MAX_PROMPT_TOKENS', '256'))
|
| 29 |
max_new_tokens: int = int(os.getenv('FEATURELENS_MAX_NEW_TOKENS', '32'))
|
| 30 |
+
live_random_controls: int = int(os.getenv('FEATURELENS_LIVE_RANDOM_CONTROLS', '8'))
|
| 31 |
+
contrast_prompts_per_concept: int = int(os.getenv('FEATURELENS_CONTRAST_PROMPTS_PER_CONCEPT', '4'))
|
| 32 |
eager_load: bool = os.getenv(
|
| 33 |
'FEATURELENS_EAGER_LOAD', '1' if os.getenv('SPACE_ID') else '0'
|
| 34 |
).lower() in {'1', 'true', 'yes', 'on'}
|
featurelens/runtime.py
CHANGED
|
@@ -4,6 +4,8 @@ import hashlib
|
|
| 4 |
import html
|
| 5 |
import math
|
| 6 |
import os
|
|
|
|
|
|
|
| 7 |
from collections.abc import Iterator, Sequence
|
| 8 |
from contextlib import contextmanager
|
| 9 |
from dataclasses import dataclass
|
|
@@ -68,7 +70,12 @@ class InterventionResult:
|
|
| 68 |
perturbation_norm: float
|
| 69 |
js_divergence: float
|
| 70 |
random_js_divergence: float
|
|
|
|
| 71 |
js_specificity_ratio: float
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
target_text: str
|
| 73 |
target_token_count: int
|
| 74 |
target_tokens: list[str]
|
|
@@ -82,7 +89,10 @@ class InterventionResult:
|
|
| 82 |
random_sequence_logprob_delta: float | None
|
| 83 |
mean_logprob_delta: float | None
|
| 84 |
random_mean_logprob_delta: float | None
|
|
|
|
|
|
|
| 85 |
target_specificity_ratio: float | None
|
|
|
|
| 86 |
target_token_rows: list[list[object]]
|
| 87 |
top_token_rows: list[list[object]]
|
| 88 |
|
|
@@ -98,6 +108,8 @@ class LayerSweepResult:
|
|
| 98 |
class DoseResponseResult:
|
| 99 |
feature_activation: float
|
| 100 |
target_tokens: list[str]
|
|
|
|
|
|
|
| 101 |
rows: list[list[object]]
|
| 102 |
|
| 103 |
|
|
@@ -108,7 +120,12 @@ class FeatureSetResult:
|
|
| 108 |
perturbation_norm: float
|
| 109 |
js_divergence: float
|
| 110 |
random_js_divergence: float
|
|
|
|
| 111 |
js_specificity_ratio: float
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
baseline_sequence_logprob: float
|
| 113 |
modified_sequence_logprob: float
|
| 114 |
random_sequence_logprob: float
|
|
@@ -116,7 +133,10 @@ class FeatureSetResult:
|
|
| 116 |
random_sequence_logprob_delta: float
|
| 117 |
mean_logprob_delta: float
|
| 118 |
random_mean_logprob_delta: float
|
|
|
|
|
|
|
| 119 |
target_specificity_ratio: float
|
|
|
|
| 120 |
target_tokens: list[str]
|
| 121 |
target_token_rows: list[list[object]]
|
| 122 |
|
|
@@ -124,9 +144,35 @@ class FeatureSetResult:
|
|
| 124 |
@dataclass
|
| 125 |
class FeatureSetSweepResult:
|
| 126 |
target_tokens: list[str]
|
|
|
|
|
|
|
|
|
|
| 127 |
rows: list[list[object]]
|
| 128 |
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
@dataclass
|
| 131 |
class ParaphraseResult:
|
| 132 |
tokens_a: list[str]
|
|
@@ -135,6 +181,8 @@ class ParaphraseResult:
|
|
| 135 |
token_index_b: int
|
| 136 |
topk_jaccard: float
|
| 137 |
sparse_cosine: float
|
|
|
|
|
|
|
| 138 |
shared_top_n: int
|
| 139 |
top_n: int
|
| 140 |
rows: list[list[object]]
|
|
@@ -353,6 +401,66 @@ class FeatureLensRuntime:
|
|
| 353 |
payload = f'{text}\0{layer}\0{key}\0{mode}\0{coefficient:.8g}'.encode('utf-8')
|
| 354 |
return int.from_bytes(hashlib.sha256(payload).digest()[:4], 'big', signed=False)
|
| 355 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
@staticmethod
|
| 357 |
def _encoding_map(encoding: SparseEncoding) -> dict[int, float]:
|
| 358 |
return {
|
|
@@ -366,7 +474,13 @@ class FeatureLensRuntime:
|
|
| 366 |
}
|
| 367 |
|
| 368 |
@torch.inference_mode()
|
| 369 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
self.ensure_ready(preload_saes=False)
|
| 371 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 372 |
if int(layer) not in self.settings.layers:
|
|
@@ -377,9 +491,13 @@ class FeatureLensRuntime:
|
|
| 377 |
self.model(**inputs, use_cache=False)
|
| 378 |
hidden = bucket['hidden'][0]
|
| 379 |
idx = self._resolve_index(int(token_index), hidden.shape[0])
|
| 380 |
-
residual = hidden[idx]
|
| 381 |
sae = self.sae_store.get(int(layer))
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
reconstruction = sae.decode_sparse(encoding)
|
| 384 |
metrics = reconstruction_metrics(residual, reconstruction)
|
| 385 |
metrics['active_features'] = float(encoding.active_count)
|
|
@@ -398,7 +516,7 @@ class FeatureLensRuntime:
|
|
| 398 |
rows.append(
|
| 399 |
[rank + 1, feature_id, activation, self.catalog.hint(int(layer), feature_id)]
|
| 400 |
)
|
| 401 |
-
|
| 402 |
tokens=tokens,
|
| 403 |
token_index=idx,
|
| 404 |
layer=int(layer),
|
|
@@ -406,6 +524,12 @@ class FeatureLensRuntime:
|
|
| 406 |
rows=rows,
|
| 407 |
metrics=metrics,
|
| 408 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
@torch.inference_mode()
|
| 411 |
def layer_sweep(self, text: str, token_index: int = -1) -> LayerSweepResult:
|
|
@@ -529,76 +653,120 @@ class FeatureLensRuntime:
|
|
| 529 |
sae = self.sae_store.get(int(layer))
|
| 530 |
|
| 531 |
target_ids: list[int] = []
|
| 532 |
-
baseline_seq = baseline_mean = None
|
| 533 |
-
baseline_token_logps: list[float] = []
|
| 534 |
-
baseline_next_logits: torch.Tensor | None = None
|
| 535 |
capture: dict = {}
|
|
|
|
|
|
|
| 536 |
|
| 537 |
if target_text.strip():
|
| 538 |
target_ids = self._target_ids(target_text)
|
| 539 |
-
|
| 540 |
with self._capture_hook(int(layer), capture):
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
prompt_length=prompt_len,
|
| 546 |
target_ids=target_ids,
|
| 547 |
)
|
| 548 |
else:
|
|
|
|
| 549 |
with self._capture_hook(int(layer), capture):
|
| 550 |
-
|
| 551 |
-
|
| 552 |
|
| 553 |
residual = capture['hidden'][0, idx]
|
| 554 |
encoding = sae.encode(residual)
|
| 555 |
original_activation = encoding.activation_for(int(feature_id))
|
| 556 |
spec = InterventionSpec(mode=mode, coefficient=float(coefficient))
|
| 557 |
delta = residual_delta(sae.decoder_direction(int(feature_id)), original_activation, spec)
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
str(int(feature_id)),
|
| 564 |
-
mode,
|
| 565 |
-
float(coefficient),
|
| 566 |
-
),
|
| 567 |
)
|
| 568 |
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
|
| 575 |
if target_ids:
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
with self._batch_delta_hook(int(layer), idx, deltas):
|
| 580 |
-
edited_full = self.model(**repeated, use_cache=False)
|
| 581 |
-
modified_logits_all = edited_full.logits[0]
|
| 582 |
-
random_logits_all = edited_full.logits[1]
|
| 583 |
-
modified_next_logits = modified_logits_all[prompt_len - 1]
|
| 584 |
-
random_next_logits = random_logits_all[prompt_len - 1]
|
| 585 |
modified_seq, modified_mean, modified_token_logps = sequence_logprob_summary(
|
| 586 |
-
|
| 587 |
-
prompt_length=prompt_len,
|
| 588 |
-
target_ids=target_ids,
|
| 589 |
)
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
)
|
| 595 |
-
else:
|
| 596 |
-
with self._delta_hook(int(layer), idx, delta):
|
| 597 |
-
modified_prompt = self.model(**prompt_inputs, use_cache=False)
|
| 598 |
-
with self._delta_hook(int(layer), idx, control_delta):
|
| 599 |
-
random_prompt = self.model(**prompt_inputs, use_cache=False)
|
| 600 |
-
modified_next_logits = modified_prompt.logits[0, -1]
|
| 601 |
-
random_next_logits = random_prompt.logits[0, -1]
|
| 602 |
|
| 603 |
generation_kwargs = {
|
| 604 |
'max_new_tokens': min(int(max_new_tokens), self.settings.max_new_tokens),
|
|
@@ -610,44 +778,11 @@ class FeatureLensRuntime:
|
|
| 610 |
baseline_generation = self.model.generate(**prompt_inputs, **generation_kwargs)
|
| 611 |
with self._delta_hook(int(layer), idx, delta):
|
| 612 |
modified_generation = self.model.generate(**prompt_inputs, **generation_kwargs)
|
| 613 |
-
|
| 614 |
baseline_ids = baseline_generation.sequences[0, prompt_len:]
|
| 615 |
modified_ids = modified_generation.sequences[0, prompt_len:]
|
| 616 |
baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
|
| 617 |
modified_text = self.tokenizer.decode(modified_ids, skip_special_tokens=True)
|
| 618 |
|
| 619 |
-
assert baseline_next_logits is not None
|
| 620 |
-
js = js_divergence_from_logits(baseline_next_logits, modified_next_logits)
|
| 621 |
-
random_js = js_divergence_from_logits(baseline_next_logits, random_next_logits)
|
| 622 |
-
js_ratio = abs(js) / max(abs(random_js), 1e-12)
|
| 623 |
-
|
| 624 |
-
bp = mp = rp = None
|
| 625 |
-
sequence_delta = random_sequence_delta = mean_delta = random_mean_delta = specificity = None
|
| 626 |
-
target_rows: list[list[object]] = []
|
| 627 |
-
target_tokens: list[str] = []
|
| 628 |
-
if target_ids:
|
| 629 |
-
p = torch.softmax(baseline_next_logits.float(), dim=-1)
|
| 630 |
-
q = torch.softmax(modified_next_logits.float(), dim=-1)
|
| 631 |
-
r = torch.softmax(random_next_logits.float(), dim=-1)
|
| 632 |
-
first_id = int(target_ids[0])
|
| 633 |
-
bp = float(p[first_id].item())
|
| 634 |
-
mp = float(q[first_id].item())
|
| 635 |
-
rp = float(r[first_id].item())
|
| 636 |
-
assert baseline_seq is not None and modified_seq is not None and random_seq is not None
|
| 637 |
-
assert baseline_mean is not None and modified_mean is not None and random_mean is not None
|
| 638 |
-
sequence_delta = float(modified_seq - baseline_seq)
|
| 639 |
-
random_sequence_delta = float(random_seq - baseline_seq)
|
| 640 |
-
mean_delta = float(modified_mean - baseline_mean)
|
| 641 |
-
random_mean_delta = float(random_mean - baseline_mean)
|
| 642 |
-
specificity = abs(mean_delta) / max(abs(random_mean_delta), 1e-12)
|
| 643 |
-
target_tokens = [self.tokenizer.decode([int(token_id)]) for token_id in target_ids]
|
| 644 |
-
target_rows = self._target_rows(
|
| 645 |
-
target_ids,
|
| 646 |
-
baseline_token_logps,
|
| 647 |
-
modified_token_logps,
|
| 648 |
-
random_token_logps,
|
| 649 |
-
)
|
| 650 |
-
|
| 651 |
return InterventionResult(
|
| 652 |
baseline_text=baseline_text,
|
| 653 |
modified_text=modified_text,
|
|
@@ -655,8 +790,13 @@ class FeatureLensRuntime:
|
|
| 655 |
delta_activation=float(spec.delta_activation(original_activation)),
|
| 656 |
perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
|
| 657 |
js_divergence=float(js),
|
| 658 |
-
random_js_divergence=float(
|
|
|
|
| 659 |
js_specificity_ratio=float(js_ratio),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
target_text=target_text,
|
| 661 |
target_token_count=len(target_ids),
|
| 662 |
target_tokens=target_tokens,
|
|
@@ -665,18 +805,18 @@ class FeatureLensRuntime:
|
|
| 665 |
random_target_prob=rp,
|
| 666 |
baseline_sequence_logprob=baseline_seq,
|
| 667 |
modified_sequence_logprob=modified_seq,
|
| 668 |
-
random_sequence_logprob=
|
| 669 |
sequence_logprob_delta=sequence_delta,
|
| 670 |
random_sequence_logprob_delta=random_sequence_delta,
|
| 671 |
mean_logprob_delta=mean_delta,
|
| 672 |
-
random_mean_logprob_delta=
|
|
|
|
|
|
|
| 673 |
target_specificity_ratio=specificity,
|
|
|
|
| 674 |
target_token_rows=target_rows,
|
| 675 |
top_token_rows=self._top_token_rows(
|
| 676 |
-
self.tokenizer,
|
| 677 |
-
baseline_next_logits,
|
| 678 |
-
modified_next_logits,
|
| 679 |
-
k=8,
|
| 680 |
),
|
| 681 |
)
|
| 682 |
|
|
@@ -692,6 +832,8 @@ class FeatureLensRuntime:
|
|
| 692 |
) -> DoseResponseResult:
|
| 693 |
if not target_text.strip():
|
| 694 |
raise ValueError('Dose-response requires a target continuation.')
|
|
|
|
|
|
|
| 695 |
self.ensure_ready(preload_saes=False)
|
| 696 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 697 |
prompt_inputs = self._inputs(text)
|
|
@@ -703,22 +845,19 @@ class FeatureLensRuntime:
|
|
| 703 |
|
| 704 |
capture: dict = {}
|
| 705 |
with self._capture_hook(int(layer), capture):
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
baseline_logits,
|
| 711 |
-
prompt_length=prompt_len,
|
| 712 |
-
target_ids=target_ids,
|
| 713 |
)
|
| 714 |
residual = capture['hidden'][0, idx]
|
| 715 |
encoding = sae.encode(residual)
|
| 716 |
original_activation = encoding.activation_for(int(feature_id))
|
| 717 |
direction = sae.decoder_direction(int(feature_id))
|
| 718 |
|
| 719 |
-
deltas = []
|
| 720 |
-
delta_coefficients = []
|
| 721 |
-
norms = []
|
| 722 |
for multiplier in multipliers:
|
| 723 |
spec = InterventionSpec('scale', float(multiplier))
|
| 724 |
delta = residual_delta(direction, original_activation, spec)
|
|
@@ -730,13 +869,24 @@ class FeatureLensRuntime:
|
|
| 730 |
with self._batch_delta_hook(int(layer), idx, torch.stack(deltas, dim=0)):
|
| 731 |
outputs = self.model(**repeated, use_cache=False)
|
| 732 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
rows: list[list[object]] = []
|
| 734 |
for row_idx, multiplier in enumerate(multipliers):
|
| 735 |
modified_logits = outputs.logits[row_idx]
|
| 736 |
modified_seq, modified_mean, _ = sequence_logprob_summary(
|
| 737 |
-
modified_logits,
|
| 738 |
-
prompt_length=prompt_len,
|
| 739 |
-
target_ids=target_ids,
|
| 740 |
)
|
| 741 |
rows.append(
|
| 742 |
[
|
|
@@ -747,17 +897,14 @@ class FeatureLensRuntime:
|
|
| 747 |
float(modified_mean),
|
| 748 |
float(modified_mean - baseline_mean),
|
| 749 |
float(modified_seq - baseline_seq),
|
| 750 |
-
float(
|
| 751 |
-
js_divergence_from_logits(
|
| 752 |
-
baseline_next,
|
| 753 |
-
modified_logits[prompt_len - 1],
|
| 754 |
-
)
|
| 755 |
-
),
|
| 756 |
]
|
| 757 |
)
|
| 758 |
return DoseResponseResult(
|
| 759 |
feature_activation=float(original_activation),
|
| 760 |
target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
|
|
|
|
|
|
|
| 761 |
rows=rows,
|
| 762 |
)
|
| 763 |
|
|
@@ -793,51 +940,70 @@ class FeatureLensRuntime:
|
|
| 793 |
|
| 794 |
capture: dict = {}
|
| 795 |
with self._capture_hook(int(layer), capture):
|
| 796 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
residual = capture['hidden'][0, idx]
|
| 798 |
encoding = sae.encode(residual)
|
| 799 |
activations = [encoding.activation_for(feature_id) for feature_id in ids]
|
| 800 |
directions = torch.stack([sae.decoder_direction(feature_id) for feature_id in ids], dim=0)
|
| 801 |
spec = InterventionSpec(mode, float(coefficient))
|
| 802 |
delta, coefficient_deltas = joint_residual_delta(directions, activations, spec)
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
','.join(str(x) for x in ids),
|
| 809 |
-
mode,
|
| 810 |
-
float(coefficient),
|
| 811 |
-
),
|
| 812 |
)
|
| 813 |
|
| 814 |
-
|
| 815 |
-
|
|
|
|
|
|
|
| 816 |
outputs = self.model(**repeated, use_cache=False)
|
| 817 |
|
| 818 |
-
baseline_logits =
|
| 819 |
-
modified_logits = outputs.logits[
|
| 820 |
-
random_logits = outputs.logits[
|
| 821 |
baseline_seq, baseline_mean, baseline_tokens = sequence_logprob_summary(
|
| 822 |
-
baseline_logits,
|
| 823 |
-
prompt_length=prompt_len,
|
| 824 |
-
target_ids=target_ids,
|
| 825 |
)
|
| 826 |
modified_seq, modified_mean, modified_tokens = sequence_logprob_summary(
|
| 827 |
-
modified_logits,
|
| 828 |
-
prompt_length=prompt_len,
|
| 829 |
-
target_ids=target_ids,
|
| 830 |
-
)
|
| 831 |
-
random_seq, random_mean, random_tokens = sequence_logprob_summary(
|
| 832 |
-
random_logits,
|
| 833 |
-
prompt_length=prompt_len,
|
| 834 |
-
target_ids=target_ids,
|
| 835 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
mean_delta = float(modified_mean - baseline_mean)
|
| 837 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 838 |
next_idx = prompt_len - 1
|
| 839 |
js = js_divergence_from_logits(baseline_logits[next_idx], modified_logits[next_idx])
|
| 840 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 841 |
|
| 842 |
feature_rows = [
|
| 843 |
[
|
|
@@ -847,10 +1013,7 @@ class FeatureLensRuntime:
|
|
| 847 |
self.catalog.hint(int(layer), feature_id),
|
| 848 |
]
|
| 849 |
for feature_id, activation, delta_coefficient in zip(
|
| 850 |
-
ids,
|
| 851 |
-
activations,
|
| 852 |
-
coefficient_deltas,
|
| 853 |
-
strict=True,
|
| 854 |
)
|
| 855 |
]
|
| 856 |
return FeatureSetResult(
|
|
@@ -858,22 +1021,27 @@ class FeatureLensRuntime:
|
|
| 858 |
feature_rows=feature_rows,
|
| 859 |
perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
|
| 860 |
js_divergence=float(js),
|
| 861 |
-
random_js_divergence=float(
|
| 862 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
baseline_sequence_logprob=float(baseline_seq),
|
| 864 |
modified_sequence_logprob=float(modified_seq),
|
| 865 |
-
random_sequence_logprob=float(
|
| 866 |
-
sequence_logprob_delta=
|
| 867 |
-
random_sequence_logprob_delta=float(
|
| 868 |
mean_logprob_delta=mean_delta,
|
| 869 |
-
random_mean_logprob_delta=
|
| 870 |
-
|
|
|
|
|
|
|
|
|
|
| 871 |
target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
|
| 872 |
target_token_rows=self._target_rows(
|
| 873 |
-
target_ids,
|
| 874 |
-
baseline_tokens,
|
| 875 |
-
modified_tokens,
|
| 876 |
-
random_tokens,
|
| 877 |
),
|
| 878 |
)
|
| 879 |
|
|
@@ -886,7 +1054,7 @@ class FeatureLensRuntime:
|
|
| 886 |
target_text: str,
|
| 887 |
sizes: Sequence[int] = (1, 3, 5),
|
| 888 |
) -> FeatureSetSweepResult:
|
| 889 |
-
"""Jointly ablate the strongest k active features
|
| 890 |
if not target_text.strip():
|
| 891 |
raise ValueError('Feature-set size sweep requires a target continuation.')
|
| 892 |
self.ensure_ready(preload_saes=False)
|
|
@@ -900,7 +1068,11 @@ class FeatureLensRuntime:
|
|
| 900 |
|
| 901 |
capture: dict = {}
|
| 902 |
with self._capture_hook(int(layer), capture):
|
| 903 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
residual = capture['hidden'][0, idx]
|
| 905 |
encoding = sae.encode(residual)
|
| 906 |
active_ids = [
|
|
@@ -916,84 +1088,210 @@ class FeatureLensRuntime:
|
|
| 916 |
if not valid_sizes:
|
| 917 |
raise ValueError('Not enough active features for the requested set sizes.')
|
| 918 |
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
feature_lists: list[list[int]] = []
|
| 922 |
-
norms: list[float] = []
|
| 923 |
for size in valid_sizes:
|
| 924 |
selected = active_ids[:size]
|
| 925 |
activations = [encoding.activation_for(feature_id) for feature_id in selected]
|
| 926 |
directions = torch.stack([sae.decoder_direction(feature_id) for feature_id in selected])
|
| 927 |
delta, _ = joint_residual_delta(
|
| 928 |
-
directions,
|
| 929 |
-
activations,
|
| 930 |
-
InterventionSpec('ablate', 0.0),
|
| 931 |
)
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
'ablate_set',
|
| 939 |
-
0.0,
|
| 940 |
-
),
|
| 941 |
)
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
norms.append(float(torch.linalg.vector_norm(delta.float()).item()))
|
| 946 |
|
| 947 |
-
all_deltas = torch.stack(
|
| 948 |
repeated = self._repeat_inputs(full_inputs, all_deltas.shape[0])
|
| 949 |
with self._batch_delta_hook(int(layer), idx, all_deltas):
|
| 950 |
outputs = self.model(**repeated, use_cache=False)
|
| 951 |
|
| 952 |
-
baseline_logits =
|
| 953 |
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
|
| 954 |
-
baseline_logits,
|
| 955 |
-
prompt_length=prompt_len,
|
| 956 |
-
target_ids=target_ids,
|
| 957 |
)
|
| 958 |
baseline_next = baseline_logits[prompt_len - 1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 959 |
rows: list[list[object]] = []
|
| 960 |
-
for
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
)
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
prompt_length=prompt_len,
|
| 971 |
-
target_ids=target_ids,
|
| 972 |
)
|
| 973 |
-
sae_delta = float(sae_mean - baseline_mean)
|
| 974 |
-
random_delta = float(random_mean - baseline_mean)
|
| 975 |
-
sae_js = js_divergence_from_logits(baseline_next, sae_logits[prompt_len - 1])
|
| 976 |
-
random_js = js_divergence_from_logits(baseline_next, random_logits[prompt_len - 1])
|
| 977 |
rows.append(
|
| 978 |
[
|
| 979 |
int(size),
|
| 980 |
-
', '.join(str(x) for x in
|
| 981 |
-
|
| 982 |
float(baseline_mean),
|
| 983 |
-
float(
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
float(
|
| 989 |
-
float(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 990 |
]
|
| 991 |
)
|
| 992 |
return FeatureSetSweepResult(
|
| 993 |
target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
|
|
|
|
|
|
|
|
|
|
| 994 |
rows=rows,
|
| 995 |
)
|
| 996 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 997 |
@torch.inference_mode()
|
| 998 |
def compare_paraphrases(
|
| 999 |
self,
|
|
@@ -1006,8 +1304,8 @@ class FeatureLensRuntime:
|
|
| 1006 |
) -> ParaphraseResult:
|
| 1007 |
if not text_a.strip() or not text_b.strip():
|
| 1008 |
raise ValueError('Enter both the original prompt and a paraphrase.')
|
| 1009 |
-
a = self.
|
| 1010 |
-
b = self.
|
| 1011 |
|
| 1012 |
map_a = self._encoding_map(a.features)
|
| 1013 |
map_b = self._encoding_map(b.features)
|
|
@@ -1016,11 +1314,10 @@ class FeatureLensRuntime:
|
|
| 1016 |
union = set_a | set_b
|
| 1017 |
jaccard = len(set_a & set_b) / len(union) if union else 1.0
|
| 1018 |
cosine = sparse_topk_cosine(
|
| 1019 |
-
a.features.indices,
|
| 1020 |
-
a.features.values,
|
| 1021 |
-
b.features.indices,
|
| 1022 |
-
b.features.values,
|
| 1023 |
)
|
|
|
|
|
|
|
| 1024 |
|
| 1025 |
top_ids_a = [int(row[1]) for row in a.rows[: int(top_n)]]
|
| 1026 |
top_ids_b = [int(row[1]) for row in b.rows[: int(top_n)]]
|
|
@@ -1033,13 +1330,7 @@ class FeatureLensRuntime:
|
|
| 1033 |
vb = float(map_b.get(feature_id, 0.0))
|
| 1034 |
status = 'shared' if va > 0 and vb > 0 else ('original only' if va > 0 else 'paraphrase only')
|
| 1035 |
rows.append(
|
| 1036 |
-
[
|
| 1037 |
-
feature_id,
|
| 1038 |
-
va,
|
| 1039 |
-
vb,
|
| 1040 |
-
status,
|
| 1041 |
-
self.catalog.hint(int(layer), feature_id),
|
| 1042 |
-
]
|
| 1043 |
)
|
| 1044 |
chart_rows.append([str(feature_id), 'Original', va])
|
| 1045 |
chart_rows.append([str(feature_id), 'Paraphrase', vb])
|
|
@@ -1052,12 +1343,103 @@ class FeatureLensRuntime:
|
|
| 1052 |
token_index_b=b.token_index,
|
| 1053 |
topk_jaccard=float(jaccard),
|
| 1054 |
sparse_cosine=float(cosine),
|
|
|
|
|
|
|
| 1055 |
shared_top_n=int(shared_top_n),
|
| 1056 |
top_n=int(top_n),
|
| 1057 |
rows=rows,
|
| 1058 |
chart_rows=chart_rows,
|
| 1059 |
)
|
| 1060 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1061 |
|
| 1062 |
RUNTIME = FeatureLensRuntime()
|
| 1063 |
|
|
|
|
| 4 |
import html
|
| 5 |
import math
|
| 6 |
import os
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
from collections.abc import Iterator, Sequence
|
| 10 |
from contextlib import contextmanager
|
| 11 |
from dataclasses import dataclass
|
|
|
|
| 70 |
perturbation_norm: float
|
| 71 |
js_divergence: float
|
| 72 |
random_js_divergence: float
|
| 73 |
+
random_js_std: float
|
| 74 |
js_specificity_ratio: float
|
| 75 |
+
js_empirical_p: float
|
| 76 |
+
random_control_count: int
|
| 77 |
+
execution_drift_js: float
|
| 78 |
+
execution_drift_mean_logprob: float | None
|
| 79 |
target_text: str
|
| 80 |
target_token_count: int
|
| 81 |
target_tokens: list[str]
|
|
|
|
| 89 |
random_sequence_logprob_delta: float | None
|
| 90 |
mean_logprob_delta: float | None
|
| 91 |
random_mean_logprob_delta: float | None
|
| 92 |
+
random_abs_mean_logprob_delta: float | None
|
| 93 |
+
random_mean_logprob_std: float | None
|
| 94 |
target_specificity_ratio: float | None
|
| 95 |
+
target_empirical_p: float | None
|
| 96 |
target_token_rows: list[list[object]]
|
| 97 |
top_token_rows: list[list[object]]
|
| 98 |
|
|
|
|
| 108 |
class DoseResponseResult:
|
| 109 |
feature_activation: float
|
| 110 |
target_tokens: list[str]
|
| 111 |
+
execution_drift_mean_logprob: float
|
| 112 |
+
execution_drift_js: float
|
| 113 |
rows: list[list[object]]
|
| 114 |
|
| 115 |
|
|
|
|
| 120 |
perturbation_norm: float
|
| 121 |
js_divergence: float
|
| 122 |
random_js_divergence: float
|
| 123 |
+
random_js_std: float
|
| 124 |
js_specificity_ratio: float
|
| 125 |
+
js_empirical_p: float
|
| 126 |
+
random_control_count: int
|
| 127 |
+
execution_drift_mean_logprob: float
|
| 128 |
+
execution_drift_js: float
|
| 129 |
baseline_sequence_logprob: float
|
| 130 |
modified_sequence_logprob: float
|
| 131 |
random_sequence_logprob: float
|
|
|
|
| 133 |
random_sequence_logprob_delta: float
|
| 134 |
mean_logprob_delta: float
|
| 135 |
random_mean_logprob_delta: float
|
| 136 |
+
random_abs_mean_logprob_delta: float
|
| 137 |
+
random_mean_logprob_std: float
|
| 138 |
target_specificity_ratio: float
|
| 139 |
+
target_empirical_p: float
|
| 140 |
target_tokens: list[str]
|
| 141 |
target_token_rows: list[list[object]]
|
| 142 |
|
|
|
|
| 144 |
@dataclass
|
| 145 |
class FeatureSetSweepResult:
|
| 146 |
target_tokens: list[str]
|
| 147 |
+
random_control_count: int
|
| 148 |
+
execution_drift_mean_logprob: float
|
| 149 |
+
execution_drift_js: float
|
| 150 |
rows: list[list[object]]
|
| 151 |
|
| 152 |
|
| 153 |
+
@dataclass
|
| 154 |
+
class FeatureInteractionResult:
|
| 155 |
+
feature_ids: list[int]
|
| 156 |
+
target_tokens: list[str]
|
| 157 |
+
rows: list[list[object]]
|
| 158 |
+
additive_expected_mean_delta: float
|
| 159 |
+
joint_mean_delta: float
|
| 160 |
+
interaction_excess_mean_delta: float
|
| 161 |
+
normalized_interaction: float
|
| 162 |
+
execution_drift_mean_logprob: float
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@dataclass
|
| 166 |
+
class ConceptContrastResult:
|
| 167 |
+
feature_id: int
|
| 168 |
+
layer: int
|
| 169 |
+
prompts_per_concept: int
|
| 170 |
+
rows: list[list[object]]
|
| 171 |
+
chart_rows: list[list[object]]
|
| 172 |
+
leading_concept: str
|
| 173 |
+
leading_ratio: float
|
| 174 |
+
|
| 175 |
+
|
| 176 |
@dataclass
|
| 177 |
class ParaphraseResult:
|
| 178 |
tokens_a: list[str]
|
|
|
|
| 181 |
token_index_b: int
|
| 182 |
topk_jaccard: float
|
| 183 |
sparse_cosine: float
|
| 184 |
+
promptwide_jaccard: float
|
| 185 |
+
promptwide_cosine: float
|
| 186 |
shared_top_n: int
|
| 187 |
top_n: int
|
| 188 |
rows: list[list[object]]
|
|
|
|
| 401 |
payload = f'{text}\0{layer}\0{key}\0{mode}\0{coefficient:.8g}'.encode('utf-8')
|
| 402 |
return int.from_bytes(hashlib.sha256(payload).digest()[:4], 'big', signed=False)
|
| 403 |
|
| 404 |
+
@staticmethod
|
| 405 |
+
def _random_controls(delta: torch.Tensor, seed: int, count: int) -> list[torch.Tensor]:
|
| 406 |
+
if int(count) < 1:
|
| 407 |
+
raise ValueError('Random-control ensemble must contain at least one direction.')
|
| 408 |
+
return [
|
| 409 |
+
normalized_random_control(delta, seed=int(seed) + 104729 * idx)
|
| 410 |
+
for idx in range(int(count))
|
| 411 |
+
]
|
| 412 |
+
|
| 413 |
+
@staticmethod
|
| 414 |
+
def _random_effect_summary(values: Sequence[float], target_effect: float) -> tuple[float, float, float, float]:
|
| 415 |
+
if not values:
|
| 416 |
+
raise ValueError('Random-control values must not be empty.')
|
| 417 |
+
tensor = torch.tensor([float(x) for x in values], dtype=torch.float64)
|
| 418 |
+
signed_mean = float(tensor.mean().item())
|
| 419 |
+
abs_mean = float(tensor.abs().mean().item())
|
| 420 |
+
std = float(tensor.std(unbiased=False).item())
|
| 421 |
+
empirical_p = float(
|
| 422 |
+
(1 + int((tensor.abs() >= abs(float(target_effect))).sum().item()))
|
| 423 |
+
/ (len(values) + 1)
|
| 424 |
+
)
|
| 425 |
+
return signed_mean, abs_mean, std, empirical_p
|
| 426 |
+
|
| 427 |
+
@staticmethod
|
| 428 |
+
def _dict_cosine(a: dict[int, float], b: dict[int, float]) -> float:
|
| 429 |
+
if not a and not b:
|
| 430 |
+
return 1.0
|
| 431 |
+
if not a or not b:
|
| 432 |
+
return 0.0
|
| 433 |
+
dot = sum(float(value) * float(b.get(feature_id, 0.0)) for feature_id, value in a.items())
|
| 434 |
+
norm_a = math.sqrt(sum(float(value) ** 2 for value in a.values()))
|
| 435 |
+
norm_b = math.sqrt(sum(float(value) ** 2 for value in b.values()))
|
| 436 |
+
if norm_a == 0.0 or norm_b == 0.0:
|
| 437 |
+
return 0.0
|
| 438 |
+
return float(dot / (norm_a * norm_b))
|
| 439 |
+
|
| 440 |
+
@staticmethod
|
| 441 |
+
def _dict_jaccard(a: dict[int, float], b: dict[int, float]) -> float:
|
| 442 |
+
set_a = {feature_id for feature_id, value in a.items() if float(value) > 0}
|
| 443 |
+
set_b = {feature_id for feature_id, value in b.items() if float(value) > 0}
|
| 444 |
+
union = set_a | set_b
|
| 445 |
+
return float(len(set_a & set_b) / len(union)) if union else 1.0
|
| 446 |
+
|
| 447 |
+
@staticmethod
|
| 448 |
+
def _max_pool_encoding(encoding: SparseEncoding) -> dict[int, float]:
|
| 449 |
+
indices = encoding.indices.detach().cpu()
|
| 450 |
+
values = encoding.values.detach().float().cpu()
|
| 451 |
+
if indices.ndim == 1:
|
| 452 |
+
indices = indices.unsqueeze(0)
|
| 453 |
+
values = values.unsqueeze(0)
|
| 454 |
+
pooled: dict[int, float] = {}
|
| 455 |
+
for row_ids, row_values in zip(indices.tolist(), values.tolist(), strict=True):
|
| 456 |
+
for feature_id, value in zip(row_ids, row_values, strict=True):
|
| 457 |
+
value = float(value)
|
| 458 |
+
if value <= 0:
|
| 459 |
+
continue
|
| 460 |
+
feature_id = int(feature_id)
|
| 461 |
+
pooled[feature_id] = max(pooled.get(feature_id, 0.0), value)
|
| 462 |
+
return pooled
|
| 463 |
+
|
| 464 |
@staticmethod
|
| 465 |
def _encoding_map(encoding: SparseEncoding) -> dict[int, float]:
|
| 466 |
return {
|
|
|
|
| 474 |
}
|
| 475 |
|
| 476 |
@torch.inference_mode()
|
| 477 |
+
def _analyze_and_pool(
|
| 478 |
+
self,
|
| 479 |
+
text: str,
|
| 480 |
+
layer: int,
|
| 481 |
+
token_index: int = -1,
|
| 482 |
+
top_n: int = 12,
|
| 483 |
+
) -> tuple[AnalysisResult, dict[int, float]]:
|
| 484 |
self.ensure_ready(preload_saes=False)
|
| 485 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 486 |
if int(layer) not in self.settings.layers:
|
|
|
|
| 491 |
self.model(**inputs, use_cache=False)
|
| 492 |
hidden = bucket['hidden'][0]
|
| 493 |
idx = self._resolve_index(int(token_index), hidden.shape[0])
|
|
|
|
| 494 |
sae = self.sae_store.get(int(layer))
|
| 495 |
+
all_encoding = sae.encode(hidden)
|
| 496 |
+
encoding = SparseEncoding(
|
| 497 |
+
indices=all_encoding.indices[idx],
|
| 498 |
+
values=all_encoding.values[idx],
|
| 499 |
+
)
|
| 500 |
+
residual = hidden[idx]
|
| 501 |
reconstruction = sae.decode_sparse(encoding)
|
| 502 |
metrics = reconstruction_metrics(residual, reconstruction)
|
| 503 |
metrics['active_features'] = float(encoding.active_count)
|
|
|
|
| 516 |
rows.append(
|
| 517 |
[rank + 1, feature_id, activation, self.catalog.hint(int(layer), feature_id)]
|
| 518 |
)
|
| 519 |
+
result = AnalysisResult(
|
| 520 |
tokens=tokens,
|
| 521 |
token_index=idx,
|
| 522 |
layer=int(layer),
|
|
|
|
| 524 |
rows=rows,
|
| 525 |
metrics=metrics,
|
| 526 |
)
|
| 527 |
+
return result, self._max_pool_encoding(all_encoding)
|
| 528 |
+
|
| 529 |
+
@torch.inference_mode()
|
| 530 |
+
def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
|
| 531 |
+
result, _ = self._analyze_and_pool(text, layer, token_index, top_n)
|
| 532 |
+
return result
|
| 533 |
|
| 534 |
@torch.inference_mode()
|
| 535 |
def layer_sweep(self, text: str, token_index: int = -1) -> LayerSweepResult:
|
|
|
|
| 653 |
sae = self.sae_store.get(int(layer))
|
| 654 |
|
| 655 |
target_ids: list[int] = []
|
|
|
|
|
|
|
|
|
|
| 656 |
capture: dict = {}
|
| 657 |
+
single_baseline_logits: torch.Tensor
|
| 658 |
+
single_baseline_mean: float | None = None
|
| 659 |
|
| 660 |
if target_text.strip():
|
| 661 |
target_ids = self._target_ids(target_text)
|
| 662 |
+
scoring_inputs = self._append_target(prompt_inputs, target_ids)
|
| 663 |
with self._capture_hook(int(layer), capture):
|
| 664 |
+
single_baseline_out = self.model(**scoring_inputs, use_cache=False)
|
| 665 |
+
single_baseline_logits = single_baseline_out.logits[0]
|
| 666 |
+
_, single_baseline_mean, _ = sequence_logprob_summary(
|
| 667 |
+
single_baseline_logits,
|
| 668 |
prompt_length=prompt_len,
|
| 669 |
target_ids=target_ids,
|
| 670 |
)
|
| 671 |
else:
|
| 672 |
+
scoring_inputs = prompt_inputs
|
| 673 |
with self._capture_hook(int(layer), capture):
|
| 674 |
+
single_baseline_out = self.model(**scoring_inputs, use_cache=False)
|
| 675 |
+
single_baseline_logits = single_baseline_out.logits[0]
|
| 676 |
|
| 677 |
residual = capture['hidden'][0, idx]
|
| 678 |
encoding = sae.encode(residual)
|
| 679 |
original_activation = encoding.activation_for(int(feature_id))
|
| 680 |
spec = InterventionSpec(mode=mode, coefficient=float(coefficient))
|
| 681 |
delta = residual_delta(sae.decoder_direction(int(feature_id)), original_activation, spec)
|
| 682 |
+
seed = self._control_seed(
|
| 683 |
+
text, int(layer), str(int(feature_id)), mode, float(coefficient)
|
| 684 |
+
)
|
| 685 |
+
random_controls = self._random_controls(
|
| 686 |
+
delta, seed=seed, count=self.settings.live_random_controls
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
)
|
| 688 |
|
| 689 |
+
zero = torch.zeros_like(delta)
|
| 690 |
+
all_deltas = torch.stack([zero, delta, *random_controls], dim=0)
|
| 691 |
+
repeated = self._repeat_inputs(scoring_inputs, all_deltas.shape[0])
|
| 692 |
+
with self._batch_delta_hook(int(layer), idx, all_deltas):
|
| 693 |
+
edited = self.model(**repeated, use_cache=False)
|
| 694 |
+
|
| 695 |
+
baseline_logits = edited.logits[0]
|
| 696 |
+
modified_logits = edited.logits[1]
|
| 697 |
+
random_logits = [edited.logits[row] for row in range(2, edited.logits.shape[0])]
|
| 698 |
+
next_idx = prompt_len - 1
|
| 699 |
+
baseline_next_logits = baseline_logits[next_idx]
|
| 700 |
+
modified_next_logits = modified_logits[next_idx]
|
| 701 |
+
random_next_logits = [row[next_idx] for row in random_logits]
|
| 702 |
+
|
| 703 |
+
execution_drift_js = js_divergence_from_logits(
|
| 704 |
+
single_baseline_logits[next_idx], baseline_next_logits
|
| 705 |
+
)
|
| 706 |
+
js = js_divergence_from_logits(baseline_next_logits, modified_next_logits)
|
| 707 |
+
random_js_values = [
|
| 708 |
+
js_divergence_from_logits(baseline_next_logits, logits)
|
| 709 |
+
for logits in random_next_logits
|
| 710 |
+
]
|
| 711 |
+
random_js_mean, random_js_abs_mean, random_js_std, js_empirical_p = (
|
| 712 |
+
self._random_effect_summary(random_js_values, js)
|
| 713 |
+
)
|
| 714 |
+
# JS divergence is non-negative, so signed and absolute means are identical up to numerical noise.
|
| 715 |
+
random_js_reference = random_js_abs_mean
|
| 716 |
+
js_ratio = abs(js) / max(random_js_reference, 1e-12)
|
| 717 |
+
|
| 718 |
+
baseline_seq = baseline_mean = modified_seq = modified_mean = None
|
| 719 |
+
random_seq_mean = random_mean_signed = random_abs_mean = random_mean_std = None
|
| 720 |
+
sequence_delta = random_sequence_delta = mean_delta = specificity = target_p = None
|
| 721 |
+
target_rows: list[list[object]] = []
|
| 722 |
+
target_tokens: list[str] = []
|
| 723 |
+
bp = mp = rp = None
|
| 724 |
+
execution_drift_mean = None
|
| 725 |
|
| 726 |
if target_ids:
|
| 727 |
+
baseline_seq, baseline_mean, baseline_token_logps = sequence_logprob_summary(
|
| 728 |
+
baseline_logits, prompt_length=prompt_len, target_ids=target_ids
|
| 729 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
modified_seq, modified_mean, modified_token_logps = sequence_logprob_summary(
|
| 731 |
+
modified_logits, prompt_length=prompt_len, target_ids=target_ids
|
|
|
|
|
|
|
| 732 |
)
|
| 733 |
+
random_summaries = [
|
| 734 |
+
sequence_logprob_summary(logits, prompt_length=prompt_len, target_ids=target_ids)
|
| 735 |
+
for logits in random_logits
|
| 736 |
+
]
|
| 737 |
+
random_seqs = [item[0] for item in random_summaries]
|
| 738 |
+
random_means = [item[1] for item in random_summaries]
|
| 739 |
+
random_token_matrix = [item[2] for item in random_summaries]
|
| 740 |
+
random_token_mean = [
|
| 741 |
+
float(sum(row[token_pos] for row in random_token_matrix) / len(random_token_matrix))
|
| 742 |
+
for token_pos in range(len(target_ids))
|
| 743 |
+
]
|
| 744 |
+
random_mean_deltas = [float(value - baseline_mean) for value in random_means]
|
| 745 |
+
random_seq_deltas = [float(value - baseline_seq) for value in random_seqs]
|
| 746 |
+
mean_delta = float(modified_mean - baseline_mean)
|
| 747 |
+
sequence_delta = float(modified_seq - baseline_seq)
|
| 748 |
+
random_mean_signed, random_abs_mean, random_mean_std, target_p = self._random_effect_summary(
|
| 749 |
+
random_mean_deltas, mean_delta
|
| 750 |
+
)
|
| 751 |
+
random_sequence_delta = float(sum(random_seq_deltas) / len(random_seq_deltas))
|
| 752 |
+
random_seq_mean = float(sum(random_seqs) / len(random_seqs))
|
| 753 |
+
specificity = abs(mean_delta) / max(random_abs_mean, 1e-12)
|
| 754 |
+
_, single_mean, _ = sequence_logprob_summary(
|
| 755 |
+
single_baseline_logits, prompt_length=prompt_len, target_ids=target_ids
|
| 756 |
+
)
|
| 757 |
+
execution_drift_mean = float(baseline_mean - single_mean)
|
| 758 |
+
|
| 759 |
+
p = torch.softmax(baseline_next_logits.float(), dim=-1)
|
| 760 |
+
q = torch.softmax(modified_next_logits.float(), dim=-1)
|
| 761 |
+
random_probs = [torch.softmax(logits.float(), dim=-1) for logits in random_next_logits]
|
| 762 |
+
first_id = int(target_ids[0])
|
| 763 |
+
bp = float(p[first_id].item())
|
| 764 |
+
mp = float(q[first_id].item())
|
| 765 |
+
rp = float(sum(prob[first_id].item() for prob in random_probs) / len(random_probs))
|
| 766 |
+
target_tokens = [self.tokenizer.decode([int(token_id)]) for token_id in target_ids]
|
| 767 |
+
target_rows = self._target_rows(
|
| 768 |
+
target_ids, baseline_token_logps, modified_token_logps, random_token_mean
|
| 769 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
|
| 771 |
generation_kwargs = {
|
| 772 |
'max_new_tokens': min(int(max_new_tokens), self.settings.max_new_tokens),
|
|
|
|
| 778 |
baseline_generation = self.model.generate(**prompt_inputs, **generation_kwargs)
|
| 779 |
with self._delta_hook(int(layer), idx, delta):
|
| 780 |
modified_generation = self.model.generate(**prompt_inputs, **generation_kwargs)
|
|
|
|
| 781 |
baseline_ids = baseline_generation.sequences[0, prompt_len:]
|
| 782 |
modified_ids = modified_generation.sequences[0, prompt_len:]
|
| 783 |
baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
|
| 784 |
modified_text = self.tokenizer.decode(modified_ids, skip_special_tokens=True)
|
| 785 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
return InterventionResult(
|
| 787 |
baseline_text=baseline_text,
|
| 788 |
modified_text=modified_text,
|
|
|
|
| 790 |
delta_activation=float(spec.delta_activation(original_activation)),
|
| 791 |
perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
|
| 792 |
js_divergence=float(js),
|
| 793 |
+
random_js_divergence=float(random_js_reference),
|
| 794 |
+
random_js_std=float(random_js_std),
|
| 795 |
js_specificity_ratio=float(js_ratio),
|
| 796 |
+
js_empirical_p=float(js_empirical_p),
|
| 797 |
+
random_control_count=len(random_controls),
|
| 798 |
+
execution_drift_js=float(execution_drift_js),
|
| 799 |
+
execution_drift_mean_logprob=execution_drift_mean,
|
| 800 |
target_text=target_text,
|
| 801 |
target_token_count=len(target_ids),
|
| 802 |
target_tokens=target_tokens,
|
|
|
|
| 805 |
random_target_prob=rp,
|
| 806 |
baseline_sequence_logprob=baseline_seq,
|
| 807 |
modified_sequence_logprob=modified_seq,
|
| 808 |
+
random_sequence_logprob=random_seq_mean,
|
| 809 |
sequence_logprob_delta=sequence_delta,
|
| 810 |
random_sequence_logprob_delta=random_sequence_delta,
|
| 811 |
mean_logprob_delta=mean_delta,
|
| 812 |
+
random_mean_logprob_delta=random_mean_signed,
|
| 813 |
+
random_abs_mean_logprob_delta=random_abs_mean,
|
| 814 |
+
random_mean_logprob_std=random_mean_std,
|
| 815 |
target_specificity_ratio=specificity,
|
| 816 |
+
target_empirical_p=target_p,
|
| 817 |
target_token_rows=target_rows,
|
| 818 |
top_token_rows=self._top_token_rows(
|
| 819 |
+
self.tokenizer, baseline_next_logits, modified_next_logits, k=8
|
|
|
|
|
|
|
|
|
|
| 820 |
),
|
| 821 |
)
|
| 822 |
|
|
|
|
| 832 |
) -> DoseResponseResult:
|
| 833 |
if not target_text.strip():
|
| 834 |
raise ValueError('Dose-response requires a target continuation.')
|
| 835 |
+
if not any(abs(float(multiplier) - 1.0) < 1e-12 for multiplier in multipliers):
|
| 836 |
+
raise ValueError('Dose-response multipliers must include 1.0 as the zero-edit reference.')
|
| 837 |
self.ensure_ready(preload_saes=False)
|
| 838 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 839 |
prompt_inputs = self._inputs(text)
|
|
|
|
| 845 |
|
| 846 |
capture: dict = {}
|
| 847 |
with self._capture_hook(int(layer), capture):
|
| 848 |
+
single_baseline_out = self.model(**full_inputs, use_cache=False)
|
| 849 |
+
single_baseline_logits = single_baseline_out.logits[0]
|
| 850 |
+
_, single_baseline_mean, _ = sequence_logprob_summary(
|
| 851 |
+
single_baseline_logits, prompt_length=prompt_len, target_ids=target_ids
|
|
|
|
|
|
|
|
|
|
| 852 |
)
|
| 853 |
residual = capture['hidden'][0, idx]
|
| 854 |
encoding = sae.encode(residual)
|
| 855 |
original_activation = encoding.activation_for(int(feature_id))
|
| 856 |
direction = sae.decoder_direction(int(feature_id))
|
| 857 |
|
| 858 |
+
deltas: list[torch.Tensor] = []
|
| 859 |
+
delta_coefficients: list[float] = []
|
| 860 |
+
norms: list[float] = []
|
| 861 |
for multiplier in multipliers:
|
| 862 |
spec = InterventionSpec('scale', float(multiplier))
|
| 863 |
delta = residual_delta(direction, original_activation, spec)
|
|
|
|
| 869 |
with self._batch_delta_hook(int(layer), idx, torch.stack(deltas, dim=0)):
|
| 870 |
outputs = self.model(**repeated, use_cache=False)
|
| 871 |
|
| 872 |
+
reference_idx = next(
|
| 873 |
+
idx for idx, multiplier in enumerate(multipliers) if abs(float(multiplier) - 1.0) < 1e-12
|
| 874 |
+
)
|
| 875 |
+
reference_logits = outputs.logits[reference_idx]
|
| 876 |
+
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
|
| 877 |
+
reference_logits, prompt_length=prompt_len, target_ids=target_ids
|
| 878 |
+
)
|
| 879 |
+
baseline_next = reference_logits[prompt_len - 1]
|
| 880 |
+
execution_drift_mean = float(baseline_mean - single_baseline_mean)
|
| 881 |
+
execution_drift_js = js_divergence_from_logits(
|
| 882 |
+
single_baseline_logits[prompt_len - 1], baseline_next
|
| 883 |
+
)
|
| 884 |
+
|
| 885 |
rows: list[list[object]] = []
|
| 886 |
for row_idx, multiplier in enumerate(multipliers):
|
| 887 |
modified_logits = outputs.logits[row_idx]
|
| 888 |
modified_seq, modified_mean, _ = sequence_logprob_summary(
|
| 889 |
+
modified_logits, prompt_length=prompt_len, target_ids=target_ids
|
|
|
|
|
|
|
| 890 |
)
|
| 891 |
rows.append(
|
| 892 |
[
|
|
|
|
| 897 |
float(modified_mean),
|
| 898 |
float(modified_mean - baseline_mean),
|
| 899 |
float(modified_seq - baseline_seq),
|
| 900 |
+
float(js_divergence_from_logits(baseline_next, modified_logits[prompt_len - 1])),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 901 |
]
|
| 902 |
)
|
| 903 |
return DoseResponseResult(
|
| 904 |
feature_activation=float(original_activation),
|
| 905 |
target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
|
| 906 |
+
execution_drift_mean_logprob=execution_drift_mean,
|
| 907 |
+
execution_drift_js=float(execution_drift_js),
|
| 908 |
rows=rows,
|
| 909 |
)
|
| 910 |
|
|
|
|
| 940 |
|
| 941 |
capture: dict = {}
|
| 942 |
with self._capture_hook(int(layer), capture):
|
| 943 |
+
single_baseline_out = self.model(**full_inputs, use_cache=False)
|
| 944 |
+
single_baseline_logits = single_baseline_out.logits[0]
|
| 945 |
+
_, single_baseline_mean, _ = sequence_logprob_summary(
|
| 946 |
+
single_baseline_logits, prompt_length=prompt_len, target_ids=target_ids
|
| 947 |
+
)
|
| 948 |
residual = capture['hidden'][0, idx]
|
| 949 |
encoding = sae.encode(residual)
|
| 950 |
activations = [encoding.activation_for(feature_id) for feature_id in ids]
|
| 951 |
directions = torch.stack([sae.decoder_direction(feature_id) for feature_id in ids], dim=0)
|
| 952 |
spec = InterventionSpec(mode, float(coefficient))
|
| 953 |
delta, coefficient_deltas = joint_residual_delta(directions, activations, spec)
|
| 954 |
+
seed = self._control_seed(
|
| 955 |
+
text, int(layer), ','.join(str(x) for x in ids), mode, float(coefficient)
|
| 956 |
+
)
|
| 957 |
+
controls = self._random_controls(
|
| 958 |
+
delta, seed=seed, count=self.settings.live_random_controls
|
|
|
|
|
|
|
|
|
|
|
|
|
| 959 |
)
|
| 960 |
|
| 961 |
+
zero = torch.zeros_like(delta)
|
| 962 |
+
all_deltas = torch.stack([zero, delta, *controls], dim=0)
|
| 963 |
+
repeated = self._repeat_inputs(full_inputs, all_deltas.shape[0])
|
| 964 |
+
with self._batch_delta_hook(int(layer), idx, all_deltas):
|
| 965 |
outputs = self.model(**repeated, use_cache=False)
|
| 966 |
|
| 967 |
+
baseline_logits = outputs.logits[0]
|
| 968 |
+
modified_logits = outputs.logits[1]
|
| 969 |
+
random_logits = [outputs.logits[row] for row in range(2, outputs.logits.shape[0])]
|
| 970 |
baseline_seq, baseline_mean, baseline_tokens = sequence_logprob_summary(
|
| 971 |
+
baseline_logits, prompt_length=prompt_len, target_ids=target_ids
|
|
|
|
|
|
|
| 972 |
)
|
| 973 |
modified_seq, modified_mean, modified_tokens = sequence_logprob_summary(
|
| 974 |
+
modified_logits, prompt_length=prompt_len, target_ids=target_ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 975 |
)
|
| 976 |
+
random_summaries = [
|
| 977 |
+
sequence_logprob_summary(logits, prompt_length=prompt_len, target_ids=target_ids)
|
| 978 |
+
for logits in random_logits
|
| 979 |
+
]
|
| 980 |
+
random_seqs = [item[0] for item in random_summaries]
|
| 981 |
+
random_means = [item[1] for item in random_summaries]
|
| 982 |
+
random_token_matrix = [item[2] for item in random_summaries]
|
| 983 |
+
random_token_mean = [
|
| 984 |
+
float(sum(row[token_pos] for row in random_token_matrix) / len(random_token_matrix))
|
| 985 |
+
for token_pos in range(len(target_ids))
|
| 986 |
+
]
|
| 987 |
mean_delta = float(modified_mean - baseline_mean)
|
| 988 |
+
sequence_delta = float(modified_seq - baseline_seq)
|
| 989 |
+
random_mean_deltas = [float(value - baseline_mean) for value in random_means]
|
| 990 |
+
random_seq_deltas = [float(value - baseline_seq) for value in random_seqs]
|
| 991 |
+
random_mean_signed, random_abs_mean, random_mean_std, target_p = self._random_effect_summary(
|
| 992 |
+
random_mean_deltas, mean_delta
|
| 993 |
+
)
|
| 994 |
next_idx = prompt_len - 1
|
| 995 |
js = js_divergence_from_logits(baseline_logits[next_idx], modified_logits[next_idx])
|
| 996 |
+
random_js_values = [
|
| 997 |
+
js_divergence_from_logits(baseline_logits[next_idx], logits[next_idx])
|
| 998 |
+
for logits in random_logits
|
| 999 |
+
]
|
| 1000 |
+
_, random_js_abs_mean, random_js_std, js_p = self._random_effect_summary(
|
| 1001 |
+
random_js_values, js
|
| 1002 |
+
)
|
| 1003 |
+
execution_drift_mean = float(baseline_mean - single_baseline_mean)
|
| 1004 |
+
execution_drift_js = js_divergence_from_logits(
|
| 1005 |
+
single_baseline_logits[next_idx], baseline_logits[next_idx]
|
| 1006 |
+
)
|
| 1007 |
|
| 1008 |
feature_rows = [
|
| 1009 |
[
|
|
|
|
| 1013 |
self.catalog.hint(int(layer), feature_id),
|
| 1014 |
]
|
| 1015 |
for feature_id, activation, delta_coefficient in zip(
|
| 1016 |
+
ids, activations, coefficient_deltas, strict=True
|
|
|
|
|
|
|
|
|
|
| 1017 |
)
|
| 1018 |
]
|
| 1019 |
return FeatureSetResult(
|
|
|
|
| 1021 |
feature_rows=feature_rows,
|
| 1022 |
perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
|
| 1023 |
js_divergence=float(js),
|
| 1024 |
+
random_js_divergence=float(random_js_abs_mean),
|
| 1025 |
+
random_js_std=float(random_js_std),
|
| 1026 |
+
js_specificity_ratio=float(abs(js) / max(random_js_abs_mean, 1e-12)),
|
| 1027 |
+
js_empirical_p=float(js_p),
|
| 1028 |
+
random_control_count=len(controls),
|
| 1029 |
+
execution_drift_mean_logprob=execution_drift_mean,
|
| 1030 |
+
execution_drift_js=float(execution_drift_js),
|
| 1031 |
baseline_sequence_logprob=float(baseline_seq),
|
| 1032 |
modified_sequence_logprob=float(modified_seq),
|
| 1033 |
+
random_sequence_logprob=float(sum(random_seqs) / len(random_seqs)),
|
| 1034 |
+
sequence_logprob_delta=sequence_delta,
|
| 1035 |
+
random_sequence_logprob_delta=float(sum(random_seq_deltas) / len(random_seq_deltas)),
|
| 1036 |
mean_logprob_delta=mean_delta,
|
| 1037 |
+
random_mean_logprob_delta=random_mean_signed,
|
| 1038 |
+
random_abs_mean_logprob_delta=random_abs_mean,
|
| 1039 |
+
random_mean_logprob_std=float(random_mean_std),
|
| 1040 |
+
target_specificity_ratio=float(abs(mean_delta) / max(random_abs_mean, 1e-12)),
|
| 1041 |
+
target_empirical_p=float(target_p),
|
| 1042 |
target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
|
| 1043 |
target_token_rows=self._target_rows(
|
| 1044 |
+
target_ids, baseline_tokens, modified_tokens, random_token_mean
|
|
|
|
|
|
|
|
|
|
| 1045 |
),
|
| 1046 |
)
|
| 1047 |
|
|
|
|
| 1054 |
target_text: str,
|
| 1055 |
sizes: Sequence[int] = (1, 3, 5),
|
| 1056 |
) -> FeatureSetSweepResult:
|
| 1057 |
+
"""Jointly ablate the strongest k active features and compare to random ensembles."""
|
| 1058 |
if not target_text.strip():
|
| 1059 |
raise ValueError('Feature-set size sweep requires a target continuation.')
|
| 1060 |
self.ensure_ready(preload_saes=False)
|
|
|
|
| 1068 |
|
| 1069 |
capture: dict = {}
|
| 1070 |
with self._capture_hook(int(layer), capture):
|
| 1071 |
+
single_baseline_out = self.model(**full_inputs, use_cache=False)
|
| 1072 |
+
single_baseline_logits = single_baseline_out.logits[0]
|
| 1073 |
+
_, single_baseline_mean, _ = sequence_logprob_summary(
|
| 1074 |
+
single_baseline_logits, prompt_length=prompt_len, target_ids=target_ids
|
| 1075 |
+
)
|
| 1076 |
residual = capture['hidden'][0, idx]
|
| 1077 |
encoding = sae.encode(residual)
|
| 1078 |
active_ids = [
|
|
|
|
| 1088 |
if not valid_sizes:
|
| 1089 |
raise ValueError('Not enough active features for the requested set sizes.')
|
| 1090 |
|
| 1091 |
+
condition_deltas: list[torch.Tensor] = [torch.zeros_like(residual)]
|
| 1092 |
+
metadata: list[tuple[int, str, list[int], float]] = []
|
|
|
|
|
|
|
| 1093 |
for size in valid_sizes:
|
| 1094 |
selected = active_ids[:size]
|
| 1095 |
activations = [encoding.activation_for(feature_id) for feature_id in selected]
|
| 1096 |
directions = torch.stack([sae.decoder_direction(feature_id) for feature_id in selected])
|
| 1097 |
delta, _ = joint_residual_delta(
|
| 1098 |
+
directions, activations, InterventionSpec('ablate', 0.0)
|
|
|
|
|
|
|
| 1099 |
)
|
| 1100 |
+
norm = float(torch.linalg.vector_norm(delta.float()).item())
|
| 1101 |
+
condition_deltas.append(delta)
|
| 1102 |
+
metadata.append((size, 'sae', selected, norm))
|
| 1103 |
+
seed = self._control_seed(text, int(layer), f'top-{size}', 'ablate_set', 0.0)
|
| 1104 |
+
controls = self._random_controls(
|
| 1105 |
+
delta, seed=seed, count=self.settings.live_random_controls
|
|
|
|
|
|
|
|
|
|
| 1106 |
)
|
| 1107 |
+
for control_idx, control in enumerate(controls):
|
| 1108 |
+
condition_deltas.append(control)
|
| 1109 |
+
metadata.append((size, f'random_{control_idx}', selected, norm))
|
|
|
|
| 1110 |
|
| 1111 |
+
all_deltas = torch.stack(condition_deltas, dim=0)
|
| 1112 |
repeated = self._repeat_inputs(full_inputs, all_deltas.shape[0])
|
| 1113 |
with self._batch_delta_hook(int(layer), idx, all_deltas):
|
| 1114 |
outputs = self.model(**repeated, use_cache=False)
|
| 1115 |
|
| 1116 |
+
baseline_logits = outputs.logits[0]
|
| 1117 |
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
|
| 1118 |
+
baseline_logits, prompt_length=prompt_len, target_ids=target_ids
|
|
|
|
|
|
|
| 1119 |
)
|
| 1120 |
baseline_next = baseline_logits[prompt_len - 1]
|
| 1121 |
+
execution_drift_mean = float(baseline_mean - single_baseline_mean)
|
| 1122 |
+
execution_drift_js = js_divergence_from_logits(
|
| 1123 |
+
single_baseline_logits[prompt_len - 1], baseline_next
|
| 1124 |
+
)
|
| 1125 |
+
|
| 1126 |
+
grouped: dict[int, dict[str, object]] = {
|
| 1127 |
+
size: {'features': active_ids[:size], 'norm': None, 'sae': None, 'random': []}
|
| 1128 |
+
for size in valid_sizes
|
| 1129 |
+
}
|
| 1130 |
+
for output_idx, meta in enumerate(metadata, start=1):
|
| 1131 |
+
size, kind, selected, norm = meta
|
| 1132 |
+
logits = outputs.logits[output_idx]
|
| 1133 |
+
seq_logp, mean_logp, _ = sequence_logprob_summary(
|
| 1134 |
+
logits, prompt_length=prompt_len, target_ids=target_ids
|
| 1135 |
+
)
|
| 1136 |
+
item = {
|
| 1137 |
+
'seq': float(seq_logp),
|
| 1138 |
+
'mean': float(mean_logp),
|
| 1139 |
+
'js': float(js_divergence_from_logits(baseline_next, logits[prompt_len - 1])),
|
| 1140 |
+
}
|
| 1141 |
+
grouped[size]['norm'] = norm
|
| 1142 |
+
grouped[size]['features'] = selected
|
| 1143 |
+
if kind == 'sae':
|
| 1144 |
+
grouped[size]['sae'] = item
|
| 1145 |
+
else:
|
| 1146 |
+
grouped[size]['random'].append(item)
|
| 1147 |
+
|
| 1148 |
rows: list[list[object]] = []
|
| 1149 |
+
for size in valid_sizes:
|
| 1150 |
+
group = grouped[size]
|
| 1151 |
+
sae_item = group['sae']
|
| 1152 |
+
random_items = group['random']
|
| 1153 |
+
assert isinstance(sae_item, dict)
|
| 1154 |
+
assert isinstance(random_items, list) and random_items
|
| 1155 |
+
sae_mean_delta = float(sae_item['mean'] - baseline_mean)
|
| 1156 |
+
sae_seq_delta = float(sae_item['seq'] - baseline_seq)
|
| 1157 |
+
random_mean_deltas = [float(item['mean'] - baseline_mean) for item in random_items]
|
| 1158 |
+
random_js_values = [float(item['js']) for item in random_items]
|
| 1159 |
+
random_signed, random_abs, random_std, target_p = self._random_effect_summary(
|
| 1160 |
+
random_mean_deltas, sae_mean_delta
|
| 1161 |
)
|
| 1162 |
+
_, random_js_abs, random_js_std, js_p = self._random_effect_summary(
|
| 1163 |
+
random_js_values, float(sae_item['js'])
|
|
|
|
|
|
|
| 1164 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1165 |
rows.append(
|
| 1166 |
[
|
| 1167 |
int(size),
|
| 1168 |
+
', '.join(str(x) for x in group['features']),
|
| 1169 |
+
float(group['norm']),
|
| 1170 |
float(baseline_mean),
|
| 1171 |
+
float(sae_item['mean']),
|
| 1172 |
+
sae_mean_delta,
|
| 1173 |
+
random_signed,
|
| 1174 |
+
random_abs,
|
| 1175 |
+
random_std,
|
| 1176 |
+
float(abs(sae_mean_delta) / max(random_abs, 1e-12)),
|
| 1177 |
+
float(target_p),
|
| 1178 |
+
sae_seq_delta,
|
| 1179 |
+
float(sae_item['js']),
|
| 1180 |
+
random_js_abs,
|
| 1181 |
+
random_js_std,
|
| 1182 |
+
float(js_p),
|
| 1183 |
]
|
| 1184 |
)
|
| 1185 |
return FeatureSetSweepResult(
|
| 1186 |
target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
|
| 1187 |
+
random_control_count=self.settings.live_random_controls,
|
| 1188 |
+
execution_drift_mean_logprob=execution_drift_mean,
|
| 1189 |
+
execution_drift_js=float(execution_drift_js),
|
| 1190 |
rows=rows,
|
| 1191 |
)
|
| 1192 |
|
| 1193 |
+
@torch.inference_mode()
|
| 1194 |
+
def feature_interaction_test(
|
| 1195 |
+
self,
|
| 1196 |
+
text: str,
|
| 1197 |
+
layer: int,
|
| 1198 |
+
token_index: int,
|
| 1199 |
+
feature_ids: Sequence[int],
|
| 1200 |
+
target_text: str,
|
| 1201 |
+
) -> FeatureInteractionResult:
|
| 1202 |
+
"""Compare individual ablations with their joint ablation to measure non-additivity."""
|
| 1203 |
+
if not target_text.strip():
|
| 1204 |
+
raise ValueError('Feature interaction testing requires a target continuation.')
|
| 1205 |
+
ids = list(dict.fromkeys(int(x) for x in feature_ids))
|
| 1206 |
+
if len(ids) < 2:
|
| 1207 |
+
raise ValueError('Select at least two features for the interaction decomposition.')
|
| 1208 |
+
if len(ids) > 5:
|
| 1209 |
+
raise ValueError('Select at most five features for the live interaction decomposition.')
|
| 1210 |
+
self.ensure_ready(preload_saes=False)
|
| 1211 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 1212 |
+
prompt_inputs = self._inputs(text)
|
| 1213 |
+
prompt_len = int(prompt_inputs['input_ids'].shape[1])
|
| 1214 |
+
idx = self._resolve_index(int(token_index), prompt_len)
|
| 1215 |
+
target_ids = self._target_ids(target_text)
|
| 1216 |
+
full_inputs = self._append_target(prompt_inputs, target_ids)
|
| 1217 |
+
sae = self.sae_store.get(int(layer))
|
| 1218 |
+
|
| 1219 |
+
capture: dict = {}
|
| 1220 |
+
with self._capture_hook(int(layer), capture):
|
| 1221 |
+
single_baseline_out = self.model(**full_inputs, use_cache=False)
|
| 1222 |
+
single_logits = single_baseline_out.logits[0]
|
| 1223 |
+
_, single_mean, _ = sequence_logprob_summary(
|
| 1224 |
+
single_logits, prompt_length=prompt_len, target_ids=target_ids
|
| 1225 |
+
)
|
| 1226 |
+
residual = capture['hidden'][0, idx]
|
| 1227 |
+
encoding = sae.encode(residual)
|
| 1228 |
+
activations = [encoding.activation_for(feature_id) for feature_id in ids]
|
| 1229 |
+
directions = [sae.decoder_direction(feature_id) for feature_id in ids]
|
| 1230 |
+
individual_deltas = [
|
| 1231 |
+
residual_delta(direction, activation, InterventionSpec('ablate', 0.0))
|
| 1232 |
+
for direction, activation in zip(directions, activations, strict=True)
|
| 1233 |
+
]
|
| 1234 |
+
joint_delta = torch.stack(individual_deltas, dim=0).sum(dim=0)
|
| 1235 |
+
all_deltas = torch.stack([torch.zeros_like(joint_delta), *individual_deltas, joint_delta], dim=0)
|
| 1236 |
+
repeated = self._repeat_inputs(full_inputs, all_deltas.shape[0])
|
| 1237 |
+
with self._batch_delta_hook(int(layer), idx, all_deltas):
|
| 1238 |
+
outputs = self.model(**repeated, use_cache=False)
|
| 1239 |
+
|
| 1240 |
+
baseline_logits = outputs.logits[0]
|
| 1241 |
+
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
|
| 1242 |
+
baseline_logits, prompt_length=prompt_len, target_ids=target_ids
|
| 1243 |
+
)
|
| 1244 |
+
baseline_next = baseline_logits[prompt_len - 1]
|
| 1245 |
+
rows: list[list[object]] = []
|
| 1246 |
+
individual_mean_deltas: list[float] = []
|
| 1247 |
+
for feature_idx, feature_id in enumerate(ids, start=1):
|
| 1248 |
+
logits = outputs.logits[feature_idx]
|
| 1249 |
+
seq_logp, mean_logp, _ = sequence_logprob_summary(
|
| 1250 |
+
logits, prompt_length=prompt_len, target_ids=target_ids
|
| 1251 |
+
)
|
| 1252 |
+
delta_mean = float(mean_logp - baseline_mean)
|
| 1253 |
+
individual_mean_deltas.append(delta_mean)
|
| 1254 |
+
rows.append(
|
| 1255 |
+
[
|
| 1256 |
+
f'Feature {feature_id}',
|
| 1257 |
+
str(feature_id),
|
| 1258 |
+
float(activations[feature_idx - 1]),
|
| 1259 |
+
float(torch.linalg.vector_norm(individual_deltas[feature_idx - 1].float()).item()),
|
| 1260 |
+
delta_mean,
|
| 1261 |
+
float(seq_logp - baseline_seq),
|
| 1262 |
+
float(js_divergence_from_logits(baseline_next, logits[prompt_len - 1])),
|
| 1263 |
+
]
|
| 1264 |
+
)
|
| 1265 |
+
joint_logits = outputs.logits[len(ids) + 1]
|
| 1266 |
+
joint_seq, joint_mean, _ = sequence_logprob_summary(
|
| 1267 |
+
joint_logits, prompt_length=prompt_len, target_ids=target_ids
|
| 1268 |
+
)
|
| 1269 |
+
joint_mean_delta = float(joint_mean - baseline_mean)
|
| 1270 |
+
rows.append(
|
| 1271 |
+
[
|
| 1272 |
+
'Joint ablation',
|
| 1273 |
+
', '.join(str(x) for x in ids),
|
| 1274 |
+
float(sum(activations)),
|
| 1275 |
+
float(torch.linalg.vector_norm(joint_delta.float()).item()),
|
| 1276 |
+
joint_mean_delta,
|
| 1277 |
+
float(joint_seq - baseline_seq),
|
| 1278 |
+
float(js_divergence_from_logits(baseline_next, joint_logits[prompt_len - 1])),
|
| 1279 |
+
]
|
| 1280 |
+
)
|
| 1281 |
+
additive_expected = float(sum(individual_mean_deltas))
|
| 1282 |
+
interaction_excess = float(joint_mean_delta - additive_expected)
|
| 1283 |
+
scale = max(sum(abs(value) for value in individual_mean_deltas), 1e-12)
|
| 1284 |
+
return FeatureInteractionResult(
|
| 1285 |
+
feature_ids=ids,
|
| 1286 |
+
target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
|
| 1287 |
+
rows=rows,
|
| 1288 |
+
additive_expected_mean_delta=additive_expected,
|
| 1289 |
+
joint_mean_delta=joint_mean_delta,
|
| 1290 |
+
interaction_excess_mean_delta=interaction_excess,
|
| 1291 |
+
normalized_interaction=float(interaction_excess / scale),
|
| 1292 |
+
execution_drift_mean_logprob=float(baseline_mean - single_mean),
|
| 1293 |
+
)
|
| 1294 |
+
|
| 1295 |
@torch.inference_mode()
|
| 1296 |
def compare_paraphrases(
|
| 1297 |
self,
|
|
|
|
| 1304 |
) -> ParaphraseResult:
|
| 1305 |
if not text_a.strip() or not text_b.strip():
|
| 1306 |
raise ValueError('Enter both the original prompt and a paraphrase.')
|
| 1307 |
+
a, pooled_a = self._analyze_and_pool(text_a, int(layer), int(token_index_a), max(int(top_n), 12))
|
| 1308 |
+
b, pooled_b = self._analyze_and_pool(text_b, int(layer), int(token_index_b), max(int(top_n), 12))
|
| 1309 |
|
| 1310 |
map_a = self._encoding_map(a.features)
|
| 1311 |
map_b = self._encoding_map(b.features)
|
|
|
|
| 1314 |
union = set_a | set_b
|
| 1315 |
jaccard = len(set_a & set_b) / len(union) if union else 1.0
|
| 1316 |
cosine = sparse_topk_cosine(
|
| 1317 |
+
a.features.indices, a.features.values, b.features.indices, b.features.values
|
|
|
|
|
|
|
|
|
|
| 1318 |
)
|
| 1319 |
+
promptwide_jaccard = self._dict_jaccard(pooled_a, pooled_b)
|
| 1320 |
+
promptwide_cosine = self._dict_cosine(pooled_a, pooled_b)
|
| 1321 |
|
| 1322 |
top_ids_a = [int(row[1]) for row in a.rows[: int(top_n)]]
|
| 1323 |
top_ids_b = [int(row[1]) for row in b.rows[: int(top_n)]]
|
|
|
|
| 1330 |
vb = float(map_b.get(feature_id, 0.0))
|
| 1331 |
status = 'shared' if va > 0 and vb > 0 else ('original only' if va > 0 else 'paraphrase only')
|
| 1332 |
rows.append(
|
| 1333 |
+
[feature_id, va, vb, status, self.catalog.hint(int(layer), feature_id)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1334 |
)
|
| 1335 |
chart_rows.append([str(feature_id), 'Original', va])
|
| 1336 |
chart_rows.append([str(feature_id), 'Paraphrase', vb])
|
|
|
|
| 1343 |
token_index_b=b.token_index,
|
| 1344 |
topk_jaccard=float(jaccard),
|
| 1345 |
sparse_cosine=float(cosine),
|
| 1346 |
+
promptwide_jaccard=float(promptwide_jaccard),
|
| 1347 |
+
promptwide_cosine=float(promptwide_cosine),
|
| 1348 |
shared_top_n=int(shared_top_n),
|
| 1349 |
top_n=int(top_n),
|
| 1350 |
rows=rows,
|
| 1351 |
chart_rows=chart_rows,
|
| 1352 |
)
|
| 1353 |
|
| 1354 |
+
def _contrast_prompt_rows(self, prompts_per_concept: int) -> list[dict]:
|
| 1355 |
+
data_path = Path(__file__).resolve().parents[1] / 'data' / 'prompts.jsonl'
|
| 1356 |
+
rows = [
|
| 1357 |
+
json.loads(line)
|
| 1358 |
+
for line in data_path.read_text(encoding='utf-8').splitlines()
|
| 1359 |
+
if line.strip()
|
| 1360 |
+
]
|
| 1361 |
+
selected: list[dict] = []
|
| 1362 |
+
by_concept: dict[str, list[dict]] = {}
|
| 1363 |
+
for row in rows:
|
| 1364 |
+
# Use one wording per paraphrase pair so the live contrast is not dominated by near-duplicates.
|
| 1365 |
+
if int(row.get('variant', 0)) != 0:
|
| 1366 |
+
continue
|
| 1367 |
+
by_concept.setdefault(str(row['concept']), []).append(row)
|
| 1368 |
+
for concept in sorted(by_concept):
|
| 1369 |
+
selected.extend(by_concept[concept][: int(prompts_per_concept)])
|
| 1370 |
+
return selected
|
| 1371 |
+
|
| 1372 |
+
@torch.inference_mode()
|
| 1373 |
+
def concept_contrast_scan(
|
| 1374 |
+
self,
|
| 1375 |
+
feature_id: int,
|
| 1376 |
+
layer: int,
|
| 1377 |
+
prompts_per_concept: int | None = None,
|
| 1378 |
+
) -> ConceptContrastResult:
|
| 1379 |
+
"""Measure one SAE feature across a small balanced concept contrast batch."""
|
| 1380 |
+
self.ensure_ready(preload_saes=False)
|
| 1381 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 1382 |
+
n = int(prompts_per_concept or self.settings.contrast_prompts_per_concept)
|
| 1383 |
+
if n < 1 or n > 8:
|
| 1384 |
+
raise ValueError('Contrast prompts per concept must be between 1 and 8.')
|
| 1385 |
+
rows = self._contrast_prompt_rows(n)
|
| 1386 |
+
if not rows:
|
| 1387 |
+
raise RuntimeError('No controlled contrast prompts are available.')
|
| 1388 |
+
texts = [str(row['text']) for row in rows]
|
| 1389 |
+
batch = self.tokenizer(
|
| 1390 |
+
texts,
|
| 1391 |
+
return_tensors='pt',
|
| 1392 |
+
padding=True,
|
| 1393 |
+
truncation=True,
|
| 1394 |
+
max_length=self.settings.max_prompt_tokens,
|
| 1395 |
+
)
|
| 1396 |
+
batch = {key: value.to(self.device) for key, value in batch.items()}
|
| 1397 |
+
capture: dict = {}
|
| 1398 |
+
with self._capture_hook(int(layer), capture):
|
| 1399 |
+
self.model(**batch, use_cache=False)
|
| 1400 |
+
# Left padding means the last position is the final real token for every prompt.
|
| 1401 |
+
residuals = capture['hidden'][:, -1, :]
|
| 1402 |
+
sae = self.sae_store.get(int(layer))
|
| 1403 |
+
encoding = sae.encode(residuals)
|
| 1404 |
+
mask = encoding.indices == int(feature_id)
|
| 1405 |
+
activations = torch.where(mask, encoding.values, torch.zeros_like(encoding.values)).sum(dim=-1)
|
| 1406 |
+
values = activations.detach().float().cpu().tolist()
|
| 1407 |
+
|
| 1408 |
+
grouped: dict[str, list[float]] = {}
|
| 1409 |
+
for row, value in zip(rows, values, strict=True):
|
| 1410 |
+
grouped.setdefault(str(row['concept']), []).append(float(value))
|
| 1411 |
+
table_rows: list[list[object]] = []
|
| 1412 |
+
for concept in sorted(grouped):
|
| 1413 |
+
vals = grouped[concept]
|
| 1414 |
+
tensor = torch.tensor(vals, dtype=torch.float64)
|
| 1415 |
+
active = [value for value in vals if value > 0]
|
| 1416 |
+
median = float(torch.median(tensor).item())
|
| 1417 |
+
table_rows.append(
|
| 1418 |
+
[
|
| 1419 |
+
concept,
|
| 1420 |
+
len(vals),
|
| 1421 |
+
float(tensor.mean().item()),
|
| 1422 |
+
median,
|
| 1423 |
+
float(len(active) / len(vals)),
|
| 1424 |
+
float(max(vals) if vals else 0.0),
|
| 1425 |
+
]
|
| 1426 |
+
)
|
| 1427 |
+
table_rows.sort(key=lambda row: (float(row[2]), float(row[4])), reverse=True)
|
| 1428 |
+
leader = str(table_rows[0][0])
|
| 1429 |
+
first = float(table_rows[0][2])
|
| 1430 |
+
second = float(table_rows[1][2]) if len(table_rows) > 1 else 0.0
|
| 1431 |
+
ratio = first / max(second, 1e-12) if first > 0 else 0.0
|
| 1432 |
+
chart_rows = [[str(row[0]), float(row[2])] for row in table_rows]
|
| 1433 |
+
return ConceptContrastResult(
|
| 1434 |
+
feature_id=int(feature_id),
|
| 1435 |
+
layer=int(layer),
|
| 1436 |
+
prompts_per_concept=n,
|
| 1437 |
+
rows=table_rows,
|
| 1438 |
+
chart_rows=chart_rows,
|
| 1439 |
+
leading_concept=leader,
|
| 1440 |
+
leading_ratio=float(ratio),
|
| 1441 |
+
)
|
| 1442 |
+
|
| 1443 |
|
| 1444 |
RUNTIME = FeatureLensRuntime()
|
| 1445 |
|
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.4.0"
|
| 4 |
description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
|
| 5 |
requires-python = ">=3.10"
|
| 6 |
|
research_config.json
CHANGED
|
@@ -62,6 +62,20 @@
|
|
| 62 |
"scale"
|
| 63 |
],
|
| 64 |
"feature_set_negative_control": "norm-matched random residual direction",
|
| 65 |
-
"dose_response_execution": "
|
| 66 |
-
"feature_set_sweep_execution": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
|
|
|
| 62 |
"scale"
|
| 63 |
],
|
| 64 |
"feature_set_negative_control": "norm-matched random residual direction",
|
| 65 |
+
"dose_response_execution": "all scale conditions share the 1x batched no-edit reference",
|
| 66 |
+
"feature_set_sweep_execution": "zero-edit reference + SAE edits + random-control ensembles evaluated in one batched forward",
|
| 67 |
+
"live_random_controls": 8,
|
| 68 |
+
"offline_random_controls_default": 8,
|
| 69 |
+
"control_reference": "batched zero-edit residual row",
|
| 70 |
+
"paraphrase_promptwide_pooling": "max activation per SAE feature across all prompt tokens",
|
| 71 |
+
"live_features_v0_4": [
|
| 72 |
+
"batch_context_null_reference",
|
| 73 |
+
"random_control_ensemble",
|
| 74 |
+
"individual_vs_joint_interaction_decomposition",
|
| 75 |
+
"promptwide_paraphrase_robustness",
|
| 76 |
+
"controlled_concept_contrast_scan",
|
| 77 |
+
"copy_tables_with_headers"
|
| 78 |
+
],
|
| 79 |
+
"concept_contrast_prompts_per_concept": 4,
|
| 80 |
+
"interaction_feature_limit": 5
|
| 81 |
}
|
scripts/release_check.py
CHANGED
|
@@ -77,22 +77,28 @@ def check_config(config: dict) -> None:
|
|
| 77 |
'sae_width': 32768,
|
| 78 |
'dose_response_multipliers': [0.0, 0.5, 1.0, 1.5, 2.0, 3.0],
|
| 79 |
'feature_set_sizes': [1, 3, 5],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
for key, value in expected.items():
|
| 82 |
if config.get(key) != value:
|
| 83 |
raise SystemExit(f'Unexpected {key}: {config.get(key)!r}. Expected {value!r}.')
|
| 84 |
|
| 85 |
-
|
| 86 |
-
'
|
| 87 |
-
'
|
| 88 |
-
'
|
| 89 |
-
'
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
-
|
| 92 |
-
if
|
| 93 |
raise SystemExit(
|
| 94 |
-
'research_config.json
|
| 95 |
-
f'{sorted(
|
| 96 |
)
|
| 97 |
|
| 98 |
|
|
@@ -151,17 +157,20 @@ def check_readme() -> None:
|
|
| 151 |
'full-continuation',
|
| 152 |
'feature-set',
|
| 153 |
'paraphrase',
|
| 154 |
-
'
|
|
|
|
|
|
|
|
|
|
| 155 |
]
|
| 156 |
missing = [value for value in required_strings if value.lower() not in readme.lower()]
|
| 157 |
if missing:
|
| 158 |
-
raise SystemExit(f'README.md is missing required v0.
|
| 159 |
|
| 160 |
|
| 161 |
def check_pyproject() -> None:
|
| 162 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 163 |
-
if 'version = "0.
|
| 164 |
-
raise SystemExit('pyproject.toml must declare version 0.
|
| 165 |
|
| 166 |
|
| 167 |
def main() -> None:
|
|
@@ -178,7 +187,8 @@ def main() -> None:
|
|
| 178 |
print(f' causal tasks: {len(causal)}')
|
| 179 |
print(f' layers: {config["layers"]}')
|
| 180 |
print(f' feature-set sizes: {config["feature_set_sizes"]}')
|
| 181 |
-
print('
|
|
|
|
| 182 |
|
| 183 |
|
| 184 |
if __name__ == '__main__':
|
|
|
|
| 77 |
'sae_width': 32768,
|
| 78 |
'dose_response_multipliers': [0.0, 0.5, 1.0, 1.5, 2.0, 3.0],
|
| 79 |
'feature_set_sizes': [1, 3, 5],
|
| 80 |
+
'live_random_controls': 8,
|
| 81 |
+
'offline_random_controls_default': 8,
|
| 82 |
+
'concept_contrast_prompts_per_concept': 4,
|
| 83 |
+
'interaction_feature_limit': 5,
|
| 84 |
}
|
| 85 |
for key, value in expected.items():
|
| 86 |
if config.get(key) != value:
|
| 87 |
raise SystemExit(f'Unexpected {key}: {config.get(key)!r}. Expected {value!r}.')
|
| 88 |
|
| 89 |
+
required_live_v04 = {
|
| 90 |
+
'batch_context_null_reference',
|
| 91 |
+
'random_control_ensemble',
|
| 92 |
+
'individual_vs_joint_interaction_decomposition',
|
| 93 |
+
'promptwide_paraphrase_robustness',
|
| 94 |
+
'controlled_concept_contrast_scan',
|
| 95 |
+
'copy_tables_with_headers',
|
| 96 |
}
|
| 97 |
+
actual_live_v04 = set(config.get('live_features_v0_4', []))
|
| 98 |
+
if actual_live_v04 != required_live_v04:
|
| 99 |
raise SystemExit(
|
| 100 |
+
'research_config.json live_features_v0_4 mismatch: '
|
| 101 |
+
f'{sorted(actual_live_v04)}'
|
| 102 |
)
|
| 103 |
|
| 104 |
|
|
|
|
| 157 |
'full-continuation',
|
| 158 |
'feature-set',
|
| 159 |
'paraphrase',
|
| 160 |
+
'random ensemble',
|
| 161 |
+
'batched zero-edit',
|
| 162 |
+
'concept contrast',
|
| 163 |
+
'non-additivity',
|
| 164 |
]
|
| 165 |
missing = [value for value in required_strings if value.lower() not in readme.lower()]
|
| 166 |
if missing:
|
| 167 |
+
raise SystemExit(f'README.md is missing required v0.4 content: {missing}')
|
| 168 |
|
| 169 |
|
| 170 |
def check_pyproject() -> None:
|
| 171 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 172 |
+
if 'version = "0.4.0"' not in text:
|
| 173 |
+
raise SystemExit('pyproject.toml must declare version 0.4.0.')
|
| 174 |
|
| 175 |
|
| 176 |
def main() -> None:
|
|
|
|
| 187 |
print(f' causal tasks: {len(causal)}')
|
| 188 |
print(f' layers: {config["layers"]}')
|
| 189 |
print(f' feature-set sizes: {config["feature_set_sizes"]}')
|
| 190 |
+
print(f' random controls: {config["live_random_controls"]}')
|
| 191 |
+
print(' release: v0.4.0')
|
| 192 |
|
| 193 |
|
| 194 |
if __name__ == '__main__':
|
tests/test_report_controls.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
from experiments.make_report import _paired_stats
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_paired_stats_uses_random_control_ensemble_mean_absolute_effect() -> None:
|
| 11 |
+
frame = pd.DataFrame(
|
| 12 |
+
[
|
| 13 |
+
{'task_id': 'a', 'intervention': 'ablate', 'condition': 'sae_feature', 'target_mean_logprob_delta': 0.30},
|
| 14 |
+
{'task_id': 'a', 'intervention': 'ablate', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': 0.10},
|
| 15 |
+
{'task_id': 'a', 'intervention': 'ablate', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': -0.20},
|
| 16 |
+
{'task_id': 'b', 'intervention': 'ablate', 'condition': 'sae_feature', 'target_mean_logprob_delta': -0.40},
|
| 17 |
+
{'task_id': 'b', 'intervention': 'ablate', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': 0.05},
|
| 18 |
+
{'task_id': 'b', 'intervention': 'ablate', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': -0.15},
|
| 19 |
+
]
|
| 20 |
+
)
|
| 21 |
+
stats = _paired_stats(
|
| 22 |
+
frame,
|
| 23 |
+
index=['task_id', 'intervention'],
|
| 24 |
+
sae_condition='sae_feature',
|
| 25 |
+
random_condition='random_norm_matched',
|
| 26 |
+
seed=7,
|
| 27 |
+
)
|
| 28 |
+
# Per-task random absolute means are 0.15 and 0.10 -> overall 0.125.
|
| 29 |
+
assert math.isclose(float(stats['random_abs']), 0.125, rel_tol=1e-9)
|
| 30 |
+
assert math.isclose(float(stats['sae_abs']), 0.35, rel_tol=1e-9)
|
| 31 |
+
assert int(stats['n_pairs']) == 2
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_paired_stats_ignores_missing_random_pairs() -> None:
|
| 35 |
+
frame = pd.DataFrame(
|
| 36 |
+
[
|
| 37 |
+
{'task_id': 'a', 'intervention': 'ablate', 'condition': 'sae_feature', 'target_mean_logprob_delta': 0.30},
|
| 38 |
+
{'task_id': 'a', 'intervention': 'ablate', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': 0.10},
|
| 39 |
+
{'task_id': 'b', 'intervention': 'ablate', 'condition': 'sae_feature', 'target_mean_logprob_delta': 0.90},
|
| 40 |
+
]
|
| 41 |
+
)
|
| 42 |
+
stats = _paired_stats(
|
| 43 |
+
frame,
|
| 44 |
+
index=['task_id', 'intervention'],
|
| 45 |
+
sae_condition='sae_feature',
|
| 46 |
+
random_condition='random_norm_matched',
|
| 47 |
+
seed=8,
|
| 48 |
+
)
|
| 49 |
+
assert int(stats['n_pairs']) == 1
|
| 50 |
+
assert math.isclose(float(stats['sae_abs']), 0.30, rel_tol=1e-9)
|