Spaces:
Running on Zero
Running on Zero
Commit ·
b3d11b8
1
Parent(s): 9f9fbec
Release FeatureLens v0.3.0
Browse files- .github/workflows/ci.yml +1 -1
- CHANGELOG.md +25 -18
- README.md +147 -80
- app.py +534 -103
- artifacts/README.md +13 -3
- docs/HF_DEPLOY.md +42 -12
- docs/METHODOLOGY.md +77 -15
- docs/VALIDATION.md +402 -62
- experiments/make_report.py +214 -70
- experiments/run_all.py +1 -0
- experiments/run_causal.py +131 -76
- experiments/run_feature_sets.py +201 -0
- featurelens/interventions.py +42 -0
- featurelens/metrics.py +77 -1
- featurelens/runtime.py +635 -94
- featurelens/selection.py +61 -0
- pyproject.toml +1 -1
- research_config.json +20 -2
- scripts/release_check.py +90 -167
- tests/test_feature_sets.py +21 -0
- tests/test_interventions.py +43 -1
- tests/test_metrics.py +56 -1
.github/workflows/ci.yml
CHANGED
|
@@ -17,7 +17,7 @@ jobs:
|
|
| 17 |
python -m pip install --upgrade pip
|
| 18 |
pip install "torch>=2.8,<2.12" "huggingface_hub>=0.34,<2" "numpy>=2,<3" "pytest>=8.3,<10" "ruff>=0.9,<1"
|
| 19 |
- name: Ruff
|
| 20 |
-
run: python -m ruff check featurelens tests scripts
|
| 21 |
- name: Unit tests
|
| 22 |
run: python -m pytest -q
|
| 23 |
- name: Compile
|
|
|
|
| 17 |
python -m pip install --upgrade pip
|
| 18 |
pip install "torch>=2.8,<2.12" "huggingface_hub>=0.34,<2" "numpy>=2,<3" "pytest>=8.3,<10" "ruff>=0.9,<1"
|
| 19 |
- name: Ruff
|
| 20 |
+
run: python -m ruff check app.py featurelens experiments tests scripts
|
| 21 |
- name: Unit tests
|
| 22 |
run: python -m pytest -q
|
| 23 |
- name: Compile
|
CHANGELOG.md
CHANGED
|
@@ -1,26 +1,33 @@
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
-
##
|
| 4 |
|
| 5 |
-
###
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
-
|
| 11 |
-
- Native Gradio activation, dose-response and trajectory plots.
|
| 12 |
-
- Bootstrap 95% confidence intervals in the generated offline report.
|
| 13 |
-
- Paired sign-flip randomization test for SAE-vs-random causal-effect differences.
|
| 14 |
-
- Hosted/local validation matrix.
|
| 15 |
|
| 16 |
-
###
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
-
|
| 20 |
-
-
|
| 21 |
-
- More explicit inactive-feature warning and error surfacing.
|
| 22 |
-
- Dark/light-mode-safe UI styling using Gradio theme variables.
|
| 23 |
|
| 24 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
+
## v0.3.0
|
| 4 |
|
| 5 |
+
### Causal measurement
|
| 6 |
+
- Replaced first-token-only target evaluation with exact full-continuation teacher-forced log-probability scoring.
|
| 7 |
+
- Added total sequence and mean-per-token log-probability deltas plus per-target-token decomposition.
|
| 8 |
+
- Retained next-token probability/JS diagnostics and greedy generation as complementary outputs.
|
| 9 |
|
| 10 |
+
### Distributed feature causality
|
| 11 |
+
- Added joint multi-feature ablation/scaling using summed reconstruction-preserving SAE decoder deltas.
|
| 12 |
+
- Added live top-1/top-3/top-5 joint-ablation sweep with norm-matched random controls.
|
| 13 |
+
- Added offline `experiments/run_feature_sets.py` and report integration.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
### Robustness
|
| 16 |
+
- Added a live paraphrase-robustness explorer with TopK Jaccard, sparse cosine, overlap table, and activation comparison.
|
| 17 |
|
| 18 |
+
### Efficiency
|
| 19 |
+
- Batched all six single-feature dose-response edits into one model forward after the baseline.
|
| 20 |
+
- Batched targeted/random 1/3/5 feature-set sweep conditions into one model forward after the baseline.
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
### UI / deployment
|
| 23 |
+
- Replaced the bright blue visual emphasis with muted teal/stone accents and explicit chart palettes.
|
| 24 |
+
- Added visible `Prompt tokens` headings and aligned validation terminology with actual UI labels.
|
| 25 |
+
- Explicitly labels the dose-response panel as a scale intervention: 0× = ablation, 1× = no edit.
|
| 26 |
+
- Kept SSR disabled for the Hugging Face Space.
|
| 27 |
|
| 28 |
+
## v0.2.0
|
| 29 |
+
- Added live norm-matched random controls.
|
| 30 |
+
- Added single-feature causal dose-response.
|
| 31 |
+
- Added layer trajectory diagnostics.
|
| 32 |
+
- Added bootstrap confidence intervals and paired sign-flip tests.
|
| 33 |
+
- Hardened Gradio / ZeroGPU deployment.
|
README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
---
|
| 2 |
title: FeatureLens
|
| 3 |
emoji: 🔬
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
python_version: "3.12.12"
|
| 8 |
sdk_version: "6.24.0"
|
|
@@ -13,49 +13,68 @@ license: mit
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
-
> **v0.
|
| 17 |
|
| 18 |
**FeatureLens asks one concrete question:**
|
| 19 |
|
| 20 |
> Do sparse features that predict a concept also causally influence model behaviour?
|
| 21 |
|
| 22 |
-
It uses **Qwen3-1.7B-Base** with the official **Qwen-Scope residual-stream sparse autoencoders (SAEs)**. The live Hugging Face Space inspects
|
| 23 |
|
| 24 |
-
|
| 25 |
|
| 26 |
-
##
|
| 27 |
|
| 28 |
-
FeatureLens
|
| 29 |
|
| 30 |
-
1. **
|
| 31 |
-
2. **Prediction
|
| 32 |
-
3. **
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
A feature can
|
| 35 |
|
| 36 |
## Live workbench
|
| 37 |
|
| 38 |
-
The Gradio
|
| 39 |
|
| 40 |
-
- prompt input and
|
| 41 |
-
-
|
| 42 |
-
- strongest TopK SAE features
|
| 43 |
-
-
|
| 44 |
-
-
|
| 45 |
-
-
|
| 46 |
-
-
|
| 47 |
-
- **
|
| 48 |
-
-
|
| 49 |
-
-
|
| 50 |
-
-
|
| 51 |
-
-
|
| 52 |
-
-
|
|
|
|
| 53 |
|
| 54 |
-
###
|
| 55 |
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
```text
|
| 61 |
ablate: h' = h - z_i d_i
|
|
@@ -63,7 +82,52 @@ scale α: h' = h + (α - 1) z_i d_i
|
|
| 63 |
inject δ: h' = h + δ d_i
|
| 64 |
```
|
| 65 |
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
## Offline experiment
|
| 69 |
|
|
@@ -71,29 +135,29 @@ The repository ships a controlled benchmark with:
|
|
| 71 |
|
| 72 |
- **224 discovery prompts** across 7 concepts;
|
| 73 |
- **112 paraphrase pairs** kept together during splitting;
|
| 74 |
-
-
|
| 75 |
- **28 separate causal completion tasks**;
|
| 76 |
-
-
|
| 77 |
-
- TopK sparse feature activations and dense residuals are saved separately.
|
| 78 |
|
| 79 |
The evaluation computes:
|
| 80 |
|
| 81 |
- SAE reconstruction cosine / NMSE;
|
| 82 |
-
- actual active-feature count / sparsity;
|
| 83 |
- held-out feature/concept AUROC and F1;
|
| 84 |
- paraphrase TopK Jaccard and sparse-activation cosine;
|
| 85 |
- layer-wise multinomial linear probes on dense residual states;
|
| 86 |
- selected-feature ablation and 2× amplification;
|
| 87 |
-
-
|
| 88 |
-
-
|
| 89 |
-
-
|
| 90 |
-
-
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
Feature selection uses the **training split**. Held-out AUROC/F1 are reported afterward. Paraphrases from the same pair never cross the train/test boundary.
|
| 93 |
|
| 94 |
-
## Run the
|
| 95 |
|
| 96 |
-
A CUDA machine is strongly recommended.
|
| 97 |
|
| 98 |
```bash
|
| 99 |
python -m venv .venv
|
|
@@ -102,6 +166,17 @@ pip install -r requirements.txt
|
|
| 102 |
python experiments/run_all.py
|
| 103 |
```
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
Outputs are materialized under `artifacts/`:
|
| 106 |
|
| 107 |
```text
|
|
@@ -115,24 +190,21 @@ artifacts/
|
|
| 115 |
├── layer_metrics.csv
|
| 116 |
├── stability.csv
|
| 117 |
├── causal_results.csv
|
|
|
|
| 118 |
├── summary.json
|
| 119 |
├── report.md
|
| 120 |
└── figures/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
```
|
| 122 |
|
| 123 |
-
`report.md` and `summary.json` are generated from
|
| 124 |
|
| 125 |
## Hugging Face ZeroGPU deployment
|
| 126 |
|
| 127 |
-
FeatureLens is
|
| 128 |
-
|
| 129 |
-
1. Create a new **Gradio** Space.
|
| 130 |
-
2. In the Space hardware settings, select **ZeroGPU**.
|
| 131 |
-
3. Push this repository as-is.
|
| 132 |
-
4. No API key is required; both Qwen repositories are public.
|
| 133 |
-
5. On first cold start, the Space downloads Qwen3-1.7B-Base and only SAE layers **4, 14 and 26**, not all 28 SAE files.
|
| 134 |
-
|
| 135 |
-
The app uses the `spaces.GPU` decorator when available and falls back to ordinary execution locally. On a Hugging Face Space it eagerly places the model and selected SAEs on the ZeroGPU CUDA-emulation device at startup, matching Hugging Face's recommended loading pattern.
|
| 136 |
|
| 137 |
Useful environment overrides:
|
| 138 |
|
|
@@ -145,54 +217,38 @@ FEATURELENS_SAE_DTYPE=float16
|
|
| 145 |
FEATURELENS_MAX_NEW_TOKENS=32
|
| 146 |
```
|
| 147 |
|
| 148 |
-
|
| 149 |
|
| 150 |
## Repository layout
|
| 151 |
|
| 152 |
```text
|
| 153 |
FeatureLens/
|
| 154 |
-
├── app.py
|
| 155 |
├── featurelens/
|
| 156 |
-
│ ├── config.py
|
| 157 |
-
│ ├── sae.py
|
| 158 |
-
│ ├── interventions.py
|
| 159 |
-
│ ├── runtime.py
|
| 160 |
-
│ ├── metrics.py
|
| 161 |
-
│ ├── stats.py
|
| 162 |
-
│
|
|
|
|
| 163 |
├── experiments/
|
| 164 |
│ ├── build_dataset.py
|
| 165 |
│ ├── collect_activations.py
|
| 166 |
│ ├── evaluate_features.py
|
| 167 |
│ ├── run_causal.py
|
|
|
|
| 168 |
│ ├── make_report.py
|
| 169 |
│ └── run_all.py
|
| 170 |
├── data/
|
| 171 |
-
│ ├── prompts.jsonl
|
| 172 |
-
│ └── causal_tasks.jsonl
|
| 173 |
├── tests/
|
| 174 |
├── scripts/release_check.py
|
| 175 |
├── docs/
|
| 176 |
└── research_config.json
|
| 177 |
```
|
| 178 |
|
| 179 |
-
##
|
| 180 |
-
|
| 181 |
-
- Qwen-Scope features are TopK sparse directions, not guaranteed monosemantic concepts.
|
| 182 |
-
- The controlled benchmark is intentionally small enough to reproduce on modest research compute; it is not a universal feature ontology.
|
| 183 |
-
- The live demo applies one residual edit at one selected prompt token. This is cleaner for causal attribution than repeatedly steering every generated token, but it may produce smaller behavioral effects.
|
| 184 |
-
- A target string may tokenize into multiple tokens. The live workbench explicitly labels its target metric as the **first-token** probability in that case.
|
| 185 |
-
- A causal effect can depend strongly on prompt, layer, feature scale, and downstream task. The report therefore includes raw per-task rows rather than only aggregate means.
|
| 186 |
-
|
| 187 |
-
## v0.2 deployment hardening
|
| 188 |
-
|
| 189 |
-
The Space pins **Gradio 6.24.0** in both the Space metadata and Python dependencies and launches with `ssr_mode=False`. This deliberately avoids the SSR execution path that produced an un-awaited `get_current_user` coroutine warning in the initial deployment while keeping the app fully functional in client-side rendering mode.
|
| 190 |
-
|
| 191 |
-
The ZeroGPU callbacks also use shorter declared GPU durations than v0.1 and generation defaults to 16 new tokens, reducing queue cost for ordinary demo usage.
|
| 192 |
-
|
| 193 |
-
See [`docs/VALIDATION.md`](docs/VALIDATION.md) for the pre-push and hosted test matrix.
|
| 194 |
-
|
| 195 |
-
## Reproducibility and checks
|
| 196 |
|
| 197 |
```bash
|
| 198 |
python -m ruff check app.py featurelens experiments tests scripts
|
|
@@ -201,11 +257,22 @@ python -m compileall -q app.py featurelens experiments scripts
|
|
| 201 |
python scripts/release_check.py
|
| 202 |
```
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
## Resume-ready description
|
| 205 |
|
| 206 |
> **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
|
| 207 |
-
> Built an SAE-based interpretability system for Qwen3-1.7B that discovers concept-associated residual features,
|
| 208 |
|
| 209 |
## Acknowledgements
|
| 210 |
|
| 211 |
-
FeatureLens builds on the open Qwen3 model and Qwen-Scope SAE checkpoints from the Qwen team.
|
|
|
|
| 1 |
---
|
| 2 |
title: FeatureLens
|
| 3 |
emoji: 🔬
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
python_version: "3.12.12"
|
| 8 |
sdk_version: "6.24.0"
|
|
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
+
> **v0.3:** full-continuation causal scoring, joint sparse feature-set interventions, batched 1/3/5-feature ablation sweeps, paraphrase-robustness inspection, and a calmer low-saturation UI.
|
| 17 |
|
| 18 |
**FeatureLens asks one concrete question:**
|
| 19 |
|
| 20 |
> Do sparse features that predict a concept also causally influence model behaviour?
|
| 21 |
|
| 22 |
+
It uses **Qwen3-1.7B-Base** with the official **Qwen-Scope residual-stream sparse autoencoders (SAEs)**. The live Hugging Face Space inspects sparse residual features and performs controlled interventions; the offline pipeline measures held-out predictiveness, paraphrase stability, single-feature causality, and whether causal influence is distributed across small sparse feature sets.
|
| 23 |
|
| 24 |
+
FeatureLens is deliberately **not** an SAE viewer clone and is independent of thesis code and thesis datasets.
|
| 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 a sparse representation survive a paraphrase?
|
| 33 |
+
4. **Single-feature causality** — does changing one feature alter downstream behaviour?
|
| 34 |
+
5. **Feature-set causality** — does jointly editing a sparse subspace reveal distributed influence?
|
| 35 |
+
6. **Specificity** — is the effect larger than a norm-matched random residual perturbation?
|
| 36 |
+
7. **Dose-response** — does the behavioural effect change coherently as a feature coefficient is varied?
|
| 37 |
|
| 38 |
+
A feature can be predictive but weakly causal. A feature set can also fail to outperform a matched random perturbation. Both are valid experimental outcomes.
|
| 39 |
|
| 40 |
## Live workbench
|
| 41 |
|
| 42 |
+
The Gradio Space supports:
|
| 43 |
|
| 44 |
+
- prompt input with an explicit **Prompt tokens** view and selected-token highlight;
|
| 45 |
+
- residual layers **4, 14, 26**;
|
| 46 |
+
- strongest active TopK SAE features and reconstruction diagnostics;
|
| 47 |
+
- feature **ablation**, **scaling**, and **injection**;
|
| 48 |
+
- baseline vs SAE-edited greedy generation;
|
| 49 |
+
- next-token probability shifts and Jensen-Shannon divergence;
|
| 50 |
+
- **full-continuation teacher-forced scoring**, including per-token log-probability rows;
|
| 51 |
+
- deterministic **norm-matched random controls**;
|
| 52 |
+
- a batched single-feature scale dose-response sweep;
|
| 53 |
+
- joint multi-feature ablation/scaling;
|
| 54 |
+
- a batched **1 / 3 / 5 strongest-feature joint-ablation sweep**;
|
| 55 |
+
- an interactive **paraphrase robustness** comparison;
|
| 56 |
+
- early/middle/late representation trajectories;
|
| 57 |
+
- optional empirical feature hints loaded only from real offline artifacts.
|
| 58 |
|
| 59 |
+
### Why full-continuation scoring matters
|
| 60 |
|
| 61 |
+
v0.2 measured the first token of a user-supplied target. That is insufficient for a continuation such as `2x` when it tokenizes into multiple tokens.
|
| 62 |
|
| 63 |
+
v0.3 concatenates the exact target token IDs to the prompt and scores every target token teacher-forced. It reports:
|
| 64 |
+
|
| 65 |
+
- total target sequence log probability;
|
| 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 |
+
Mean log probability per target token is the primary length-comparable causal metric used by the v0.3 offline report.
|
| 72 |
+
|
| 73 |
+
Greedy text may remain unchanged even when these probability-level metrics move. That is expected: deterministic generation changes only after an edit moves a different token across the argmax boundary.
|
| 74 |
+
|
| 75 |
+
## Reconstruction-preserving causal edits
|
| 76 |
+
|
| 77 |
+
Let the original residual be `h`, sparse activation `z_i`, and decoder direction `d_i`.
|
| 78 |
|
| 79 |
```text
|
| 80 |
ablate: h' = h - z_i d_i
|
|
|
|
| 82 |
inject δ: h' = h + δ d_i
|
| 83 |
```
|
| 84 |
|
| 85 |
+
FeatureLens patches the **original** residual. It does not replace `h` with the complete SAE reconstruction, so SAE reconstruction error is not introduced as a causal confound.
|
| 86 |
+
|
| 87 |
+
For a feature set `S`, ablation/scaling deltas are summed before patching:
|
| 88 |
+
|
| 89 |
+
```text
|
| 90 |
+
h' = h + Σ_i∈S Δz_i d_i
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
`inject` is intentionally excluded from multi-feature sets because one shared additive coefficient is not naturally comparable across unrelated decoder directions.
|
| 94 |
+
|
| 95 |
+
## Batched causal sweeps
|
| 96 |
+
|
| 97 |
+
The stronger v0.3 experiments are designed not to multiply ZeroGPU round-trips unnecessarily.
|
| 98 |
+
|
| 99 |
+
### Single-feature scale dose-response
|
| 100 |
+
|
| 101 |
+
The UI evaluates:
|
| 102 |
+
|
| 103 |
+
```text
|
| 104 |
+
0×, 0.5×, 1×, 1.5×, 2×, 3×
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
where **0× = ablation** and **1× = no intervention**. All six residual edits are stacked and evaluated in one model forward after the baseline.
|
| 108 |
+
|
| 109 |
+
### Feature-set size sweep
|
| 110 |
+
|
| 111 |
+
The live and offline experiments jointly ablate the strongest active / concept-selected features at:
|
| 112 |
+
|
| 113 |
+
```text
|
| 114 |
+
k = 1, 3, 5
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
Each targeted edit is paired with a random residual perturbation of identical L2 norm. The edited and control conditions are batched together after one baseline forward.
|
| 118 |
+
|
| 119 |
+
This directly tests a plausible failure mode of single-feature interpretability: a concept may be represented across a small sparse subspace rather than one SAE unit.
|
| 120 |
+
|
| 121 |
+
## Paraphrase robustness explorer
|
| 122 |
+
|
| 123 |
+
The **Paraphrase robustness** tab compares the same residual layer for an original prompt and a manually supplied rewording. It reports:
|
| 124 |
+
|
| 125 |
+
- full TopK feature-set Jaccard;
|
| 126 |
+
- sparse activation cosine without densifying the 32,768-wide SAE vector;
|
| 127 |
+
- shared / original-only / paraphrase-only displayed features;
|
| 128 |
+
- side-by-side activation bars.
|
| 129 |
+
|
| 130 |
+
High overlap is robustness evidence, not a semantic proof about any individual feature.
|
| 131 |
|
| 132 |
## Offline experiment
|
| 133 |
|
|
|
|
| 135 |
|
| 136 |
- **224 discovery prompts** across 7 concepts;
|
| 137 |
- **112 paraphrase pairs** kept together during splitting;
|
| 138 |
+
- code, mathematics, positive sentiment, negative sentiment, French, factual entities, uncertainty;
|
| 139 |
- **28 separate causal completion tasks**;
|
| 140 |
+
- residual and SAE activation collection at layers 4, 14 and 26.
|
|
|
|
| 141 |
|
| 142 |
The evaluation computes:
|
| 143 |
|
| 144 |
- SAE reconstruction cosine / NMSE;
|
|
|
|
| 145 |
- held-out feature/concept AUROC and F1;
|
| 146 |
- paraphrase TopK Jaccard and sparse-activation cosine;
|
| 147 |
- layer-wise multinomial linear probes on dense residual states;
|
| 148 |
- selected-feature ablation and 2× amplification;
|
| 149 |
+
- exact full-target sequence and mean-per-token log probabilities;
|
| 150 |
+
- next-token probability/rank, JS divergence, and top-1 changes;
|
| 151 |
+
- norm-matched random residual controls;
|
| 152 |
+
- **top-1 / top-3 / top-5 same-layer concept-feature joint ablations**;
|
| 153 |
+
- bootstrap 95% confidence intervals;
|
| 154 |
+
- paired sign-flip tests for SAE-vs-control effect differences.
|
| 155 |
|
| 156 |
+
Feature selection uses only the **training split**. Held-out AUROC/F1 are reported afterward. Paraphrases from the same pair never cross the train/test boundary.
|
| 157 |
|
| 158 |
+
## Run the benchmark
|
| 159 |
|
| 160 |
+
A CUDA machine is strongly recommended.
|
| 161 |
|
| 162 |
```bash
|
| 163 |
python -m venv .venv
|
|
|
|
| 166 |
python experiments/run_all.py
|
| 167 |
```
|
| 168 |
|
| 169 |
+
The pipeline runs:
|
| 170 |
+
|
| 171 |
+
```text
|
| 172 |
+
build_dataset
|
| 173 |
+
→ collect_activations
|
| 174 |
+
→ evaluate_features
|
| 175 |
+
→ run_causal
|
| 176 |
+
→ run_feature_sets
|
| 177 |
+
→ make_report
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
Outputs are materialized under `artifacts/`:
|
| 181 |
|
| 182 |
```text
|
|
|
|
| 190 |
├── layer_metrics.csv
|
| 191 |
├── stability.csv
|
| 192 |
├── causal_results.csv
|
| 193 |
+
├── feature_set_results.csv
|
| 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 |
+
`report.md` and `summary.json` are generated from measured results. The repository contains no fabricated benchmark numbers.
|
| 204 |
|
| 205 |
## Hugging Face ZeroGPU deployment
|
| 206 |
|
| 207 |
+
FeatureLens is a **Gradio SDK Space**. The live demo only needs Qwen3-1.7B-Base plus SAE layers **4, 14, 26** rather than all 28 layer checkpoints.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
Useful environment overrides:
|
| 210 |
|
|
|
|
| 217 |
FEATURELENS_MAX_NEW_TOKENS=32
|
| 218 |
```
|
| 219 |
|
| 220 |
+
The app uses the `spaces.GPU` decorator when available and falls back to ordinary local execution. SSR is explicitly disabled in `app.py` to avoid the earlier Gradio auth-coroutine warning observed during v0.2 deployment testing.
|
| 221 |
|
| 222 |
## Repository layout
|
| 223 |
|
| 224 |
```text
|
| 225 |
FeatureLens/
|
| 226 |
+
├── app.py
|
| 227 |
├── featurelens/
|
| 228 |
+
│ ├── config.py
|
| 229 |
+
│ ├── sae.py
|
| 230 |
+
│ ├── interventions.py
|
| 231 |
+
│ ├── runtime.py
|
| 232 |
+
│ ├── metrics.py
|
| 233 |
+
│ ├── stats.py
|
| 234 |
+
│ ├── selection.py
|
| 235 |
+
│ └── catalog.py
|
| 236 |
├── experiments/
|
| 237 |
│ ├── build_dataset.py
|
| 238 |
│ ├── collect_activations.py
|
| 239 |
│ ├── evaluate_features.py
|
| 240 |
│ ├── run_causal.py
|
| 241 |
+
│ ├── run_feature_sets.py
|
| 242 |
│ ├── make_report.py
|
| 243 |
│ └── run_all.py
|
| 244 |
├── data/
|
|
|
|
|
|
|
| 245 |
├── tests/
|
| 246 |
├── scripts/release_check.py
|
| 247 |
├── docs/
|
| 248 |
└── research_config.json
|
| 249 |
```
|
| 250 |
|
| 251 |
+
## Validation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
|
| 253 |
```bash
|
| 254 |
python -m ruff check app.py featurelens experiments tests scripts
|
|
|
|
| 257 |
python scripts/release_check.py
|
| 258 |
```
|
| 259 |
|
| 260 |
+
See [`docs/VALIDATION.md`](docs/VALIDATION.md) for the exact human HF smoke tests. The names in that document match the actual labels in the v0.3 UI.
|
| 261 |
+
|
| 262 |
+
## Methodological limitations
|
| 263 |
+
|
| 264 |
+
- Qwen-Scope features are sparse directions, not guaranteed monosemantic concepts.
|
| 265 |
+
- A controlled seven-concept benchmark is not a universal feature ontology.
|
| 266 |
+
- Similar TopK sets under paraphrasing do not prove identical semantic meaning.
|
| 267 |
+
- Joint feature ablation can create a larger residual perturbation as `k` increases; norm-matched controls are therefore essential.
|
| 268 |
+
- Full target scoring is teacher-forced: it measures how the edit changes probability assigned to a specified continuation, not free-running sequence probability under sampled generation.
|
| 269 |
+
- Causal effects remain prompt-, layer-, token-, scale-, and task-dependent.
|
| 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 that discovers held-out concept-associated residual features, measures paraphrase stability against dense probes, and causally tests single features and sparse feature sets with full-continuation scoring, dose-response analysis, and norm-matched controls.
|
| 275 |
|
| 276 |
## Acknowledgements
|
| 277 |
|
| 278 |
+
FeatureLens builds on the open Qwen3 model and Qwen-Scope residual-stream SAE checkpoints from the Qwen team.
|
app.py
CHANGED
|
@@ -7,24 +7,63 @@ from featurelens.config import SETTINGS
|
|
| 7 |
from featurelens.hf_runtime import gpu
|
| 8 |
from featurelens.runtime import RUNTIME
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
CSS = """
|
| 11 |
.gradio-container { max-width: 1320px !important; }
|
| 12 |
.hero { padding: 8px 2px 2px; }
|
| 13 |
.hero h1 { margin: 0; font-size: 2.35rem; letter-spacing: -0.045em; }
|
| 14 |
.hero p { margin: .35rem 0 0; opacity: .72; font-size: 1rem; }
|
| 15 |
-
.research-q {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
.badges { display:flex; flex-wrap:wrap; gap:7px; margin:8px 0 3px; }
|
| 17 |
-
.badge {
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
.token sup { opacity:.55; margin-right:4px; }
|
| 23 |
-
.small-note { opacity:.
|
| 24 |
-
.callout {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
"""
|
| 26 |
|
| 27 |
-
THEME = gr.themes.Soft(primary_hue="
|
| 28 |
|
| 29 |
|
| 30 |
def _raise_ui_error(exc: Exception) -> None:
|
|
@@ -33,7 +72,8 @@ def _raise_ui_error(exc: Exception) -> None:
|
|
| 33 |
|
| 34 |
def _analysis_metrics_markdown(result) -> str:
|
| 35 |
return (
|
| 36 |
-
|
|
|
|
| 37 |
f"Active SAE features: **{int(result.metrics['active_features'])}/{SETTINGS.sae_top_k}** \n"
|
| 38 |
f"Reconstruction cosine: **{result.metrics['cosine']:.4f}** · "
|
| 39 |
f"NMSE: **{result.metrics['nmse']:.4f}** \n"
|
|
@@ -42,27 +82,27 @@ def _analysis_metrics_markdown(result) -> str:
|
|
| 42 |
|
| 43 |
|
| 44 |
def _intervention_metrics_markdown(result) -> str:
|
| 45 |
-
target =
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
"the displayed causal metric is for its first token._"
|
| 52 |
-
)
|
| 53 |
target = (
|
| 54 |
-
f"Target
|
| 55 |
-
f"
|
| 56 |
-
f"SAE edit
|
| 57 |
-
f"
|
| 58 |
-
f"SAE Δlog p: **{result.
|
| 59 |
-
f"random Δ
|
| 60 |
-
f"
|
|
|
|
|
|
|
| 61 |
)
|
| 62 |
inactive = ""
|
| 63 |
if abs(result.feature_activation) < 1e-12:
|
| 64 |
inactive = (
|
| 65 |
-
" \n⚠️ **Selected feature is inactive at this token.** Ablate/scale
|
| 66 |
"a zero feature delta; use `inject` to test the decoder direction directly."
|
| 67 |
)
|
| 68 |
return (
|
|
@@ -71,23 +111,59 @@ def _intervention_metrics_markdown(result) -> str:
|
|
| 71 |
f"Perturbation L2: **{result.perturbation_norm:.4f}** \n"
|
| 72 |
f"Next-token JS: **{result.js_divergence:.6f}** · "
|
| 73 |
f"random-control JS: **{result.random_js_divergence:.6f}** · "
|
| 74 |
-
f"specificity: **{result.js_specificity_ratio:.2f}×** \n\n"
|
| 75 |
-
f"{target}{inactive}"
|
|
|
|
|
|
|
| 76 |
)
|
| 77 |
|
| 78 |
|
| 79 |
def _dose_metrics_markdown(result) -> str:
|
| 80 |
-
|
| 81 |
-
if result.target_token_count > 1:
|
| 82 |
-
note = (
|
| 83 |
-
f" Target text spans {result.target_token_count} tokens; the curve measures its first token."
|
| 84 |
-
)
|
| 85 |
inactive = ""
|
| 86 |
if abs(result.feature_activation) < 1e-12:
|
| 87 |
-
inactive = " **The feature is inactive here, so
|
| 88 |
return (
|
| 89 |
f"Feature activation at baseline: **{result.feature_activation:.4f}** · "
|
| 90 |
-
f"target
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
)
|
| 92 |
|
| 93 |
|
|
@@ -99,10 +175,15 @@ def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
|
|
| 99 |
result = RUNTIME.analyze(prompt, int(layer), int(token_index), int(top_n))
|
| 100 |
choices = [str(int(row[1])) for row in result.rows]
|
| 101 |
feature_update = gr.update(choices=choices, value=choices[0] if choices else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
chart_df = pd.DataFrame(
|
| 103 |
{
|
| 104 |
"Feature": [str(int(row[1])) for row in result.rows],
|
| 105 |
"Activation": [float(row[2]) for row in result.rows],
|
|
|
|
| 106 |
}
|
| 107 |
)
|
| 108 |
return (
|
|
@@ -110,6 +191,7 @@ def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
|
|
| 110 |
result.rows,
|
| 111 |
chart_df,
|
| 112 |
feature_update,
|
|
|
|
| 113 |
_analysis_metrics_markdown(result),
|
| 114 |
)
|
| 115 |
except Exception as exc:
|
|
@@ -148,12 +230,13 @@ def run_intervention(
|
|
| 148 |
result.modified_text,
|
| 149 |
_intervention_metrics_markdown(result),
|
| 150 |
result.top_token_rows,
|
|
|
|
| 151 |
)
|
| 152 |
except Exception as exc:
|
| 153 |
_raise_ui_error(exc)
|
| 154 |
|
| 155 |
|
| 156 |
-
@gpu(duration=
|
| 157 |
def run_dose_response(
|
| 158 |
prompt: str,
|
| 159 |
layer: int,
|
|
@@ -167,7 +250,7 @@ def run_dose_response(
|
|
| 167 |
if feature_id is None or str(feature_id).strip() == "":
|
| 168 |
raise ValueError("Choose or enter a feature id.")
|
| 169 |
if not target_text.strip():
|
| 170 |
-
raise ValueError("Enter a target continuation before running
|
| 171 |
result = RUNTIME.dose_response(
|
| 172 |
text=prompt,
|
| 173 |
layer=int(layer),
|
|
@@ -179,13 +262,15 @@ def run_dose_response(
|
|
| 179 |
"Multiplier",
|
| 180 |
"Δ feature coefficient",
|
| 181 |
"Perturbation L2",
|
| 182 |
-
"Baseline p
|
| 183 |
-
"Modified p
|
| 184 |
-
"Δ log p
|
| 185 |
-
"
|
|
|
|
| 186 |
]
|
| 187 |
table = pd.DataFrame(result.rows, columns=columns)
|
| 188 |
-
plot = table[["Multiplier", "Δ log p
|
|
|
|
| 189 |
return table, plot, _dose_metrics_markdown(result)
|
| 190 |
except Exception as exc:
|
| 191 |
_raise_ui_error(exc)
|
|
@@ -218,6 +303,116 @@ def run_layer_sweep(prompt: str, token_index: int):
|
|
| 218 |
_raise_ui_error(exc)
|
| 219 |
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
def mode_help(mode: str):
|
| 222 |
if mode == "ablate":
|
| 223 |
return gr.update(
|
|
@@ -241,20 +436,37 @@ def mode_help(mode: str):
|
|
| 241 |
)
|
| 242 |
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as demo:
|
| 245 |
gr.HTML(
|
| 246 |
-
'<div class="hero"><h1>FeatureLens <span style="font-size:.42em;opacity:.55">v0.
|
| 247 |
'<p>Causal sparse-feature interpretability for Qwen3-1.7B — inspect → intervene → control → measure.</p></div>'
|
| 248 |
'<div class="research-q"><b>Research question:</b> Do sparse features that predict a concept also '
|
| 249 |
'causally influence the model’s behaviour?</div>'
|
| 250 |
'<div class="badges">'
|
| 251 |
'<span class="badge">Qwen3-1.7B-Base</span><span class="badge">Qwen-Scope SAE</span>'
|
| 252 |
'<span class="badge">32,768 features</span><span class="badge">TopK=50</span>'
|
|
|
|
| 253 |
'<span class="badge">norm-matched controls</span><span class="badge">ZeroGPU</span></div>'
|
| 254 |
)
|
| 255 |
|
| 256 |
with gr.Tab("Workbench"):
|
| 257 |
-
gr.HTML('<div class="step">Step 1 ·
|
| 258 |
with gr.Row(equal_height=False):
|
| 259 |
with gr.Column(scale=5):
|
| 260 |
prompt = gr.Textbox(
|
|
@@ -284,49 +496,54 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 284 |
value=-1,
|
| 285 |
precision=0,
|
| 286 |
label="Prompt token index",
|
| 287 |
-
info="-1 = final prompt token. Inspect once to see all token positions.",
|
| 288 |
)
|
| 289 |
-
top_n = gr.Slider(5, 20, value=12, step=1, label="
|
| 290 |
analyze_btn = gr.Button("Inspect sparse features", variant="primary")
|
| 291 |
|
| 292 |
-
|
|
|
|
| 293 |
analysis_metrics = gr.Markdown()
|
| 294 |
with gr.Row(equal_height=False):
|
| 295 |
feature_table = gr.Dataframe(
|
| 296 |
headers=["Rank", "Feature id", "Activation", "Offline concept hint"],
|
| 297 |
datatype=["number", "number", "number", "str"],
|
| 298 |
interactive=False,
|
| 299 |
-
label="Strongest
|
| 300 |
wrap=True,
|
| 301 |
scale=3,
|
| 302 |
)
|
| 303 |
feature_plot = gr.BarPlot(
|
| 304 |
x="Feature",
|
| 305 |
y="Activation",
|
|
|
|
|
|
|
| 306 |
title="Activation profile",
|
| 307 |
x_title="Feature id",
|
| 308 |
y_title="Activation",
|
|
|
|
|
|
|
| 309 |
scale=2,
|
| 310 |
)
|
| 311 |
|
| 312 |
-
gr.HTML('<div class="step">Step 2 ·
|
| 313 |
gr.Markdown(
|
| 314 |
-
"FeatureLens
|
| 315 |
-
"
|
| 316 |
-
"
|
| 317 |
)
|
| 318 |
with gr.Row(equal_height=False):
|
| 319 |
with gr.Column(scale=2):
|
| 320 |
feature_id = gr.Dropdown(
|
| 321 |
choices=[],
|
| 322 |
allow_custom_value=True,
|
| 323 |
-
label="
|
| 324 |
info="Inspection populates the strongest active features; custom IDs are also allowed.",
|
| 325 |
)
|
| 326 |
mode = gr.Radio(
|
| 327 |
choices=["ablate", "scale", "inject"],
|
| 328 |
value="ablate",
|
| 329 |
-
label="
|
| 330 |
)
|
| 331 |
coefficient = gr.Number(
|
| 332 |
value=0.0,
|
|
@@ -334,73 +551,220 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 334 |
label="Coefficient (unused for ablation)",
|
| 335 |
)
|
| 336 |
target_text = gr.Textbox(
|
| 337 |
-
label="
|
| 338 |
placeholder="e.g. 2x",
|
| 339 |
-
info=
|
|
|
|
|
|
|
|
|
|
| 340 |
)
|
| 341 |
max_new = gr.Slider(
|
| 342 |
4,
|
| 343 |
SETTINGS.max_new_tokens,
|
| 344 |
-
value=min(
|
| 345 |
step=1,
|
| 346 |
-
label="
|
| 347 |
)
|
| 348 |
-
intervene_btn = gr.Button("Run causal test", variant="primary")
|
| 349 |
intervention_metrics = gr.Markdown()
|
| 350 |
with gr.Column(scale=3):
|
| 351 |
with gr.Row():
|
| 352 |
-
baseline_out = gr.Textbox(label="Baseline generation", lines=
|
| 353 |
-
modified_out = gr.Textbox(label="SAE-
|
| 354 |
token_prob_table = gr.Dataframe(
|
| 355 |
headers=["Token", "Baseline p", "SAE-edit p", "Δ probability"],
|
| 356 |
datatype=["str", "number", "number", "number"],
|
| 357 |
interactive=False,
|
| 358 |
label="Next-token distribution shift",
|
| 359 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
|
| 361 |
-
gr.HTML('<div class="step">Step 3 ·
|
| 362 |
-
with gr.Accordion("
|
| 363 |
gr.Markdown(
|
| 364 |
-
"
|
| 365 |
-
"
|
| 366 |
-
"
|
| 367 |
)
|
| 368 |
-
dose_btn = gr.Button("Run dose
|
| 369 |
dose_metrics = gr.Markdown()
|
| 370 |
with gr.Row():
|
| 371 |
-
dose_table = gr.Dataframe(interactive=False, label="
|
| 372 |
dose_plot = gr.LinePlot(
|
| 373 |
x="Multiplier",
|
| 374 |
-
y="Δ log p
|
| 375 |
-
|
|
|
|
|
|
|
| 376 |
x_title="Feature multiplier",
|
| 377 |
-
y_title="Δ log p
|
|
|
|
| 378 |
scale=2,
|
| 379 |
)
|
| 380 |
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
)
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
)
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
outputs=[dose_table, dose_plot, dose_metrics],
|
| 396 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
|
| 398 |
with gr.Tab("Layer trajectory"):
|
| 399 |
gr.Markdown(
|
| 400 |
-
"### Follow
|
| 401 |
"This is **not** a cross-layer feature-ID comparison — SAE dictionaries are layer-specific. "
|
| 402 |
-
"
|
| 403 |
-
"
|
| 404 |
)
|
| 405 |
with gr.Row():
|
| 406 |
trajectory_prompt = gr.Textbox(
|
|
@@ -417,6 +781,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 417 |
scale=1,
|
| 418 |
)
|
| 419 |
trajectory_btn = gr.Button("Compare layers", variant="primary")
|
|
|
|
| 420 |
trajectory_tokens = gr.HTML()
|
| 421 |
with gr.Row():
|
| 422 |
trajectory_table = gr.Dataframe(interactive=False, label="Layer diagnostics", scale=3)
|
|
@@ -424,24 +789,26 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 424 |
x="Layer",
|
| 425 |
y="Value",
|
| 426 |
color="Metric",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
title="Representation trajectory",
|
| 428 |
x_title="Layer",
|
| 429 |
y_title="Normalized value",
|
|
|
|
| 430 |
scale=2,
|
| 431 |
)
|
| 432 |
-
trajectory_btn.click(
|
| 433 |
-
run_layer_sweep,
|
| 434 |
-
inputs=[trajectory_prompt, trajectory_token],
|
| 435 |
-
outputs=[trajectory_tokens, trajectory_table, trajectory_plot],
|
| 436 |
-
)
|
| 437 |
|
| 438 |
with gr.Tab("Offline benchmark"):
|
| 439 |
gr.Markdown(RUNTIME.catalog.benchmark_markdown())
|
| 440 |
gr.Markdown(
|
| 441 |
"The offline pipeline evaluates held-out feature/concept AUROC + F1, reconstruction quality, "
|
| 442 |
-
"paraphrase stability, dense residual linear probes, causal
|
| 443 |
-
"
|
| 444 |
-
"
|
|
|
|
| 445 |
)
|
| 446 |
|
| 447 |
with gr.Tab("Method"):
|
|
@@ -449,24 +816,36 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 449 |
r"""
|
| 450 |
### Reconstruction-preserving intervention
|
| 451 |
|
| 452 |
-
For residual vector $h$,
|
| 453 |
|
| 454 |
- **Ablate:** $h' = h - z_i d_i$
|
| 455 |
- **Scale:** $h' = h + (\alpha - 1) z_i d_i$
|
| 456 |
- **Inject:** $h' = h + \delta d_i$
|
| 457 |
|
| 458 |
-
|
| 459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
|
| 461 |
### Evidence ladder
|
| 462 |
|
| 463 |
1. **Reconstruction:** does the SAE represent the residual reasonably well?
|
| 464 |
2. **Prediction:** does a feature predict a controlled concept on held-out paraphrase groups?
|
| 465 |
-
3. **
|
| 466 |
-
4. **
|
| 467 |
-
5. **
|
|
|
|
|
|
|
| 468 |
|
| 469 |
-
A high AUROC alone remains correlational evidence.
|
| 470 |
"""
|
| 471 |
)
|
| 472 |
|
|
@@ -475,8 +854,60 @@ A high AUROC alone remains correlational evidence.
|
|
| 475 |
"FeatureLens is independent of thesis code and thesis datasets.</small>"
|
| 476 |
)
|
| 477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
if __name__ == "__main__":
|
| 479 |
-
# Explicit CSR avoids
|
| 480 |
demo.queue(default_concurrency_limit=1, max_size=8).launch(
|
| 481 |
css=CSS,
|
| 482 |
theme=THEME,
|
|
|
|
| 7 |
from featurelens.hf_runtime import gpu
|
| 8 |
from featurelens.runtime import RUNTIME
|
| 9 |
|
| 10 |
+
# Muted, low-saturation palette chosen to stay readable in light and dark UI modes.
|
| 11 |
+
MUTED_TEAL = "#6F8F8B"
|
| 12 |
+
MUTED_OCHRE = "#B08D57"
|
| 13 |
+
MUTED_RED = "#A66B6B"
|
| 14 |
+
MUTED_PURPLE = "#8B7EA8"
|
| 15 |
+
MUTED_STONE = "#A59E93"
|
| 16 |
+
|
| 17 |
CSS = """
|
| 18 |
.gradio-container { max-width: 1320px !important; }
|
| 19 |
.hero { padding: 8px 2px 2px; }
|
| 20 |
.hero h1 { margin: 0; font-size: 2.35rem; letter-spacing: -0.045em; }
|
| 21 |
.hero p { margin: .35rem 0 0; opacity: .72; font-size: 1rem; }
|
| 22 |
+
.research-q {
|
| 23 |
+
border-left: 4px solid #6F8F8B;
|
| 24 |
+
padding: 10px 14px;
|
| 25 |
+
margin: 10px 0 14px;
|
| 26 |
+
border-radius: 0 10px 10px 0;
|
| 27 |
+
background: var(--background-fill-secondary);
|
| 28 |
+
}
|
| 29 |
.badges { display:flex; flex-wrap:wrap; gap:7px; margin:8px 0 3px; }
|
| 30 |
+
.badge {
|
| 31 |
+
border:1px solid var(--border-color-primary);
|
| 32 |
+
background:var(--background-fill-secondary);
|
| 33 |
+
border-radius:999px;
|
| 34 |
+
padding:4px 9px;
|
| 35 |
+
font-size:12px;
|
| 36 |
+
}
|
| 37 |
+
.step {
|
| 38 |
+
font-size: .78rem;
|
| 39 |
+
text-transform: uppercase;
|
| 40 |
+
letter-spacing:.09em;
|
| 41 |
+
opacity:.66;
|
| 42 |
+
font-weight:700;
|
| 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:7px;
|
| 50 |
+
padding:2px 7px;
|
| 51 |
+
font-family:ui-monospace,SFMono-Regular,monospace;
|
| 52 |
+
font-size:12px;
|
| 53 |
+
}
|
| 54 |
+
.token.selected { border:2px solid #7FA39F; font-weight:650; }
|
| 55 |
.token sup { opacity:.55; margin-right:4px; }
|
| 56 |
+
.small-note { opacity:.68; font-size:12px; }
|
| 57 |
+
.callout {
|
| 58 |
+
border:1px solid var(--border-color-primary);
|
| 59 |
+
background:var(--background-fill-secondary);
|
| 60 |
+
border-radius:12px;
|
| 61 |
+
padding:10px 12px;
|
| 62 |
+
}
|
| 63 |
+
.metric-note { opacity:.78; }
|
| 64 |
"""
|
| 65 |
|
| 66 |
+
THEME = gr.themes.Soft(primary_hue="teal", secondary_hue="stone", neutral_hue="stone")
|
| 67 |
|
| 68 |
|
| 69 |
def _raise_ui_error(exc: Exception) -> None:
|
|
|
|
| 72 |
|
| 73 |
def _analysis_metrics_markdown(result) -> str:
|
| 74 |
return (
|
| 75 |
+
"#### Analysis metrics\n"
|
| 76 |
+
f"**Layer {result.layer} · prompt token {result.token_index}** \n"
|
| 77 |
f"Active SAE features: **{int(result.metrics['active_features'])}/{SETTINGS.sae_top_k}** \n"
|
| 78 |
f"Reconstruction cosine: **{result.metrics['cosine']:.4f}** · "
|
| 79 |
f"NMSE: **{result.metrics['nmse']:.4f}** \n"
|
|
|
|
| 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."
|
| 88 |
+
)
|
| 89 |
+
if result.baseline_sequence_logprob is not None:
|
| 90 |
+
tokens = " ".join(repr(token) for token in result.target_tokens)
|
|
|
|
|
|
|
| 91 |
target = (
|
| 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 control: **{result.random_sequence_logprob:.4f}** \n"
|
| 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.random_mean_logprob_delta:+.4f}** · "
|
| 100 |
+
f"specificity ratio: **{result.target_specificity_ratio:.2f}×**"
|
| 101 |
)
|
| 102 |
inactive = ""
|
| 103 |
if abs(result.feature_activation) < 1e-12:
|
| 104 |
inactive = (
|
| 105 |
+
" \n⚠️ **Selected feature is inactive at this prompt token.** Ablate/scale produces "
|
| 106 |
"a zero feature delta; use `inject` to test the decoder direction directly."
|
| 107 |
)
|
| 108 |
return (
|
|
|
|
| 111 |
f"Perturbation L2: **{result.perturbation_norm:.4f}** \n"
|
| 112 |
f"Next-token JS: **{result.js_divergence:.6f}** · "
|
| 113 |
f"random-control JS: **{result.random_js_divergence:.6f}** · "
|
| 114 |
+
f"JS specificity: **{result.js_specificity_ratio:.2f}×** \n\n"
|
| 115 |
+
f"{target}{inactive} \n\n"
|
| 116 |
+
"_Greedy generations can remain identical even when probability-level causal metrics move; "
|
| 117 |
+
"an edit must cross an argmax boundary before deterministic text changes._"
|
| 118 |
)
|
| 119 |
|
| 120 |
|
| 121 |
def _dose_metrics_markdown(result) -> str:
|
| 122 |
+
tokens = " ".join(repr(token) for token in result.target_tokens)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
inactive = ""
|
| 124 |
if abs(result.feature_activation) < 1e-12:
|
| 125 |
+
inactive = " **The feature is inactive here, so multiplicative scaling is flat by construction.**"
|
| 126 |
return (
|
| 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 original coefficient. It does not use the intervention radio above."
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
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 therefore contributed zero "
|
| 139 |
+
"delta under ablation/scale."
|
| 140 |
+
if inactive_count
|
| 141 |
+
else ""
|
| 142 |
+
)
|
| 143 |
+
return (
|
| 144 |
+
f"Selected feature set: **{len(result.feature_ids)} features** · "
|
| 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 Δ: **{result.random_mean_logprob_delta:+.4f}** · "
|
| 149 |
+
f"specificity: **{result.target_specificity_ratio:.2f}×** \n"
|
| 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"random-control JS: **{result.random_js_divergence:.6f}** · "
|
| 154 |
+
f"JS specificity: **{result.js_specificity_ratio:.2f}×**{inactive_note}"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _paraphrase_metrics_markdown(result) -> str:
|
| 159 |
+
return (
|
| 160 |
+
"#### Robustness metrics\n"
|
| 161 |
+
f"Full TopK feature-set Jaccard: **{result.topk_jaccard:.3f}** \n"
|
| 162 |
+
f"Sparse activation cosine: **{result.sparse_cosine:.3f}** \n"
|
| 163 |
+
f"Shared features among displayed top-{result.top_n}: "
|
| 164 |
+
f"**{result.shared_top_n}/{result.top_n}** \n\n"
|
| 165 |
+
"Higher values mean the sparse representation is more stable under the supplied paraphrase; "
|
| 166 |
+
"they do **not** prove the shared features have identical semantics."
|
| 167 |
)
|
| 168 |
|
| 169 |
|
|
|
|
| 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 |
+
choices=choices,
|
| 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],
|
| 185 |
"Activation": [float(row[2]) for row in result.rows],
|
| 186 |
+
"Series": ["Activation"] * len(result.rows),
|
| 187 |
}
|
| 188 |
)
|
| 189 |
return (
|
|
|
|
| 191 |
result.rows,
|
| 192 |
chart_df,
|
| 193 |
feature_update,
|
| 194 |
+
feature_set_update,
|
| 195 |
_analysis_metrics_markdown(result),
|
| 196 |
)
|
| 197 |
except Exception as exc:
|
|
|
|
| 230 |
result.modified_text,
|
| 231 |
_intervention_metrics_markdown(result),
|
| 232 |
result.top_token_rows,
|
| 233 |
+
result.target_token_rows,
|
| 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,
|
|
|
|
| 250 |
if feature_id is None or str(feature_id).strip() == "":
|
| 251 |
raise ValueError("Choose or enter a feature id.")
|
| 252 |
if not target_text.strip():
|
| 253 |
+
raise ValueError("Enter a target continuation before running the scale dose-response.")
|
| 254 |
result = RUNTIME.dose_response(
|
| 255 |
text=prompt,
|
| 256 |
layer=int(layer),
|
|
|
|
| 262 |
"Multiplier",
|
| 263 |
"Δ feature coefficient",
|
| 264 |
"Perturbation L2",
|
| 265 |
+
"Baseline mean log p/token",
|
| 266 |
+
"Modified mean log p/token",
|
| 267 |
+
"Δ mean log p/token",
|
| 268 |
+
"Δ sequence log p",
|
| 269 |
+
"Next-token JS",
|
| 270 |
]
|
| 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)
|
|
|
|
| 303 |
_raise_ui_error(exc)
|
| 304 |
|
| 305 |
|
| 306 |
+
@gpu(duration=35)
|
| 307 |
+
def run_feature_set(
|
| 308 |
+
prompt: str,
|
| 309 |
+
layer: int,
|
| 310 |
+
token_index: int,
|
| 311 |
+
feature_ids: list[str] | None,
|
| 312 |
+
mode: str,
|
| 313 |
+
coefficient: float,
|
| 314 |
+
target_text: str,
|
| 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'.")
|
| 322 |
+
if not target_text.strip():
|
| 323 |
+
raise ValueError("Enter a target continuation for the feature-set causal test.")
|
| 324 |
+
result = RUNTIME.intervene_feature_set(
|
| 325 |
+
text=prompt,
|
| 326 |
+
layer=int(layer),
|
| 327 |
+
token_index=int(token_index),
|
| 328 |
+
feature_ids=selected,
|
| 329 |
+
mode=mode,
|
| 330 |
+
coefficient=float(coefficient),
|
| 331 |
+
target_text=target_text,
|
| 332 |
+
)
|
| 333 |
+
return result.feature_rows, _feature_set_metrics_markdown(result), result.target_token_rows
|
| 334 |
+
except Exception as exc:
|
| 335 |
+
_raise_ui_error(exc)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
@gpu(duration=35)
|
| 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(
|
| 351 |
+
text=prompt,
|
| 352 |
+
layer=int(layer),
|
| 353 |
+
token_index=int(token_index),
|
| 354 |
+
target_text=target_text,
|
| 355 |
+
)
|
| 356 |
+
columns = [
|
| 357 |
+
"Set size k",
|
| 358 |
+
"Feature ids",
|
| 359 |
+
"Perturbation L2",
|
| 360 |
+
"Baseline mean log p/token",
|
| 361 |
+
"SAE mean log p/token",
|
| 362 |
+
"SAE Δ mean log p/token",
|
| 363 |
+
"Random Δ mean log p/token",
|
| 364 |
+
"Specificity ratio",
|
| 365 |
+
"SAE Δ sequence log p",
|
| 366 |
+
"SAE next-token JS",
|
| 367 |
+
"Random next-token JS",
|
| 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"], "Norm-matched random", row["Random Δ mean log p/token"]])
|
| 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 |
+
"For each k, FeatureLens jointly **ablates the k strongest active SAE features** at the "
|
| 379 |
+
"selected prompt token and compares that joint edit with a norm-matched random residual control."
|
| 380 |
+
)
|
| 381 |
+
return table, plot, note
|
| 382 |
+
except Exception as exc:
|
| 383 |
+
_raise_ui_error(exc)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
@gpu(duration=30)
|
| 387 |
+
def run_paraphrase_compare(
|
| 388 |
+
original_prompt: str,
|
| 389 |
+
paraphrase_prompt: str,
|
| 390 |
+
layer: int,
|
| 391 |
+
token_index_a: int,
|
| 392 |
+
token_index_b: int,
|
| 393 |
+
top_n: int,
|
| 394 |
+
):
|
| 395 |
+
try:
|
| 396 |
+
result = RUNTIME.compare_paraphrases(
|
| 397 |
+
text_a=original_prompt,
|
| 398 |
+
text_b=paraphrase_prompt,
|
| 399 |
+
layer=int(layer),
|
| 400 |
+
token_index_a=int(token_index_a),
|
| 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 |
+
result.rows,
|
| 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(
|
|
|
|
| 436 |
)
|
| 437 |
|
| 438 |
|
| 439 |
+
def set_mode_help(mode: str):
|
| 440 |
+
if mode == "ablate":
|
| 441 |
+
return gr.update(
|
| 442 |
+
value=0.0,
|
| 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 |
+
'<div class="hero"><h1>FeatureLens <span style="font-size:.42em;opacity:.55">v0.3</span></h1>'
|
| 458 |
'<p>Causal sparse-feature interpretability for Qwen3-1.7B — inspect → intervene → control → measure.</p></div>'
|
| 459 |
'<div class="research-q"><b>Research question:</b> Do sparse features that predict a concept also '
|
| 460 |
'causally influence the model’s behaviour?</div>'
|
| 461 |
'<div class="badges">'
|
| 462 |
'<span class="badge">Qwen3-1.7B-Base</span><span class="badge">Qwen-Scope SAE</span>'
|
| 463 |
'<span class="badge">32,768 features</span><span class="badge">TopK=50</span>'
|
| 464 |
+
'<span class="badge">full-sequence scoring</span><span class="badge">feature sets</span>'
|
| 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="step">Step 1 · Inspect one prompt location</div>')
|
| 470 |
with gr.Row(equal_height=False):
|
| 471 |
with gr.Column(scale=5):
|
| 472 |
prompt = gr.Textbox(
|
|
|
|
| 496 |
value=-1,
|
| 497 |
precision=0,
|
| 498 |
label="Prompt token index",
|
| 499 |
+
info="-1 = final prompt token. Inspect once to see all token positions below.",
|
| 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('<div class="small-note">Prompt tokens appear here after clicking <b>Inspect sparse features</b>.</div>')
|
| 506 |
analysis_metrics = gr.Markdown()
|
| 507 |
with gr.Row(equal_height=False):
|
| 508 |
feature_table = gr.Dataframe(
|
| 509 |
headers=["Rank", "Feature id", "Activation", "Offline concept hint"],
|
| 510 |
datatype=["number", "number", "number", "str"],
|
| 511 |
interactive=False,
|
| 512 |
+
label="Strongest active SAE features",
|
| 513 |
wrap=True,
|
| 514 |
scale=3,
|
| 515 |
)
|
| 516 |
feature_plot = gr.BarPlot(
|
| 517 |
x="Feature",
|
| 518 |
y="Activation",
|
| 519 |
+
color="Series",
|
| 520 |
+
color_map={"Activation": MUTED_TEAL},
|
| 521 |
title="Activation profile",
|
| 522 |
x_title="Feature id",
|
| 523 |
y_title="Activation",
|
| 524 |
+
x_label_angle=-35,
|
| 525 |
+
height=330,
|
| 526 |
scale=2,
|
| 527 |
)
|
| 528 |
|
| 529 |
+
gr.HTML('<div class="step">Step 2 · Run one single-feature causal test</div>')
|
| 530 |
gr.Markdown(
|
| 531 |
+
"FeatureLens edits the **original residual**, then compares the SAE edit with a deterministic "
|
| 532 |
+
"random residual perturbation of the **same L2 norm**. If a target continuation is supplied, "
|
| 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):
|
| 537 |
feature_id = gr.Dropdown(
|
| 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.Radio(
|
| 544 |
choices=["ablate", "scale", "inject"],
|
| 545 |
value="ablate",
|
| 546 |
+
label="Single-feature intervention",
|
| 547 |
)
|
| 548 |
coefficient = gr.Number(
|
| 549 |
value=0.0,
|
|
|
|
| 551 |
label="Coefficient (unused for ablation)",
|
| 552 |
)
|
| 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,
|
| 563 |
SETTINGS.max_new_tokens,
|
| 564 |
+
value=min(12, SETTINGS.max_new_tokens),
|
| 565 |
step=1,
|
| 566 |
+
label="Greedy generation length",
|
| 567 |
)
|
| 568 |
+
intervene_btn = gr.Button("Run single-feature causal test", variant="primary")
|
| 569 |
intervention_metrics = gr.Markdown()
|
| 570 |
with gr.Column(scale=3):
|
| 571 |
with gr.Row():
|
| 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="step">Step 3 · Check single-feature dose-response</div>')
|
| 596 |
+
with gr.Accordion("Single-feature scale dose-response", open=False):
|
| 597 |
gr.Markdown(
|
| 598 |
+
"This panel is **always a scale intervention**, regardless of the radio choice above. "
|
| 599 |
+
"It evaluates **0×, 0.5×, 1×, 1.5×, 2× and 3×** in one batched forward pass after the "
|
| 600 |
+
"baseline. Here **0× = ablation** and **1× = no intervention**."
|
| 601 |
)
|
| 602 |
+
dose_btn = gr.Button("Run scale dose-response")
|
| 603 |
dose_metrics = gr.Markdown()
|
| 604 |
with gr.Row():
|
| 605 |
+
dose_table = gr.Dataframe(interactive=False, label="Scale dose-response measurements", scale=3)
|
| 606 |
dose_plot = gr.LinePlot(
|
| 607 |
x="Multiplier",
|
| 608 |
+
y="Δ mean log p/token",
|
| 609 |
+
color="Series",
|
| 610 |
+
color_map={"SAE feature": MUTED_TEAL},
|
| 611 |
+
title="Scale dose-response",
|
| 612 |
x_title="Feature multiplier",
|
| 613 |
+
y_title="Δ mean log p/token",
|
| 614 |
+
height=330,
|
| 615 |
scale=2,
|
| 616 |
)
|
| 617 |
|
| 618 |
+
with gr.Tab("Feature sets"):
|
| 619 |
+
gr.Markdown(
|
| 620 |
+
"### Test distributed sparse representations\n"
|
| 621 |
+
"This tab reuses the **Prompt**, **Residual layer**, and **Prompt token index** from the Workbench. "
|
| 622 |
+
"Click **Inspect sparse features** there first; its displayed feature IDs populate the selector below."
|
| 623 |
+
)
|
| 624 |
+
feature_set_ids = gr.Dropdown(
|
| 625 |
+
choices=[],
|
| 626 |
+
value=[],
|
| 627 |
+
multiselect=True,
|
| 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 |
+
with gr.Row():
|
| 634 |
+
set_mode = gr.Radio(
|
| 635 |
+
choices=["ablate", "scale"],
|
| 636 |
+
value="ablate",
|
| 637 |
+
label="Feature-set intervention",
|
| 638 |
+
info="Inject is omitted because one shared additive coefficient is not comparable across multiple directions.",
|
| 639 |
+
)
|
| 640 |
+
set_coefficient = gr.Number(
|
| 641 |
+
value=0.0,
|
| 642 |
+
interactive=False,
|
| 643 |
+
label="Set coefficient (unused for ablation)",
|
| 644 |
+
)
|
| 645 |
+
set_target = gr.Textbox(
|
| 646 |
+
label="Target continuation",
|
| 647 |
+
value="2x",
|
| 648 |
+
info="Required. The complete continuation is scored teacher-forced.",
|
| 649 |
+
)
|
| 650 |
+
set_btn = gr.Button("Run joint feature-set causal test", variant="primary")
|
| 651 |
+
set_metrics = gr.Markdown()
|
| 652 |
+
with gr.Row():
|
| 653 |
+
set_feature_table = gr.Dataframe(
|
| 654 |
+
headers=["Feature id", "Original activation", "Δ coefficient", "Offline concept hint"],
|
| 655 |
+
datatype=["number", "number", "number", "str"],
|
| 656 |
+
interactive=False,
|
| 657 |
+
label="Joint intervention features",
|
| 658 |
+
scale=2,
|
| 659 |
+
)
|
| 660 |
+
set_target_table = gr.Dataframe(
|
| 661 |
+
headers=[
|
| 662 |
+
"Target position",
|
| 663 |
+
"Target token",
|
| 664 |
+
"Baseline log p",
|
| 665 |
+
"SAE-edit log p",
|
| 666 |
+
"Random log p",
|
| 667 |
+
"SAE Δ log p",
|
| 668 |
+
"Random Δ log p",
|
| 669 |
+
],
|
| 670 |
+
datatype=["number", "str", "number", "number", "number", "number", "number"],
|
| 671 |
+
interactive=False,
|
| 672 |
+
label="Target continuation token-by-token score",
|
| 673 |
+
scale=3,
|
| 674 |
+
)
|
| 675 |
+
|
| 676 |
+
gr.Markdown("### Top-k joint-ablation sweep")
|
| 677 |
+
gr.Markdown(
|
| 678 |
+
"Instead of choosing a single feature, this experiment automatically ablates the **1, 3, and 5 "
|
| 679 |
+
"strongest active features** at the current Workbench location. All six edited/control conditions "
|
| 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 |
+
set_sweep_table = gr.Dataframe(interactive=False, label="Feature-set size measurements", scale=3)
|
| 690 |
+
set_sweep_plot = gr.LinePlot(
|
| 691 |
+
x="Set size k",
|
| 692 |
+
y="Δ mean log p/token",
|
| 693 |
+
color="Condition",
|
| 694 |
+
color_map={
|
| 695 |
+
"Top-k SAE ablation": MUTED_TEAL,
|
| 696 |
+
"Norm-matched random": MUTED_STONE,
|
| 697 |
+
},
|
| 698 |
+
title="Effect vs feature-set size",
|
| 699 |
+
x_title="Number of jointly ablated features",
|
| 700 |
+
y_title="Δ mean log p/token",
|
| 701 |
+
height=330,
|
| 702 |
+
scale=2,
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
+
with gr.Tab("Paraphrase robustness"):
|
| 706 |
+
gr.Markdown(
|
| 707 |
+
"### Does the sparse representation survive a rewording?\n"
|
| 708 |
+
"Compare the TopK SAE representation of an original prompt with a manually supplied paraphrase. "
|
| 709 |
+
"This mirrors the offline paraphrase-stability evaluation without pretending that feature IDs "
|
| 710 |
+
"have known semantics."
|
| 711 |
+
)
|
| 712 |
+
with gr.Row():
|
| 713 |
+
para_a = gr.Textbox(
|
| 714 |
+
label="Original prompt",
|
| 715 |
+
lines=4,
|
| 716 |
+
value="The derivative of x squared is",
|
| 717 |
+
)
|
| 718 |
+
para_b = gr.Textbox(
|
| 719 |
+
label="Paraphrase",
|
| 720 |
+
lines=4,
|
| 721 |
+
value="Differentiate x squared with respect to x:",
|
| 722 |
+
)
|
| 723 |
+
with gr.Row():
|
| 724 |
+
para_layer = gr.Dropdown(
|
| 725 |
+
choices=list(SETTINGS.layers),
|
| 726 |
+
value=SETTINGS.layers[1] if len(SETTINGS.layers) > 1 else SETTINGS.layers[0],
|
| 727 |
+
label="Residual layer",
|
| 728 |
+
)
|
| 729 |
+
para_idx_a = gr.Number(value=-1, precision=0, label="Original prompt token index")
|
| 730 |
+
para_idx_b = gr.Number(value=-1, precision=0, label="Paraphrase token index")
|
| 731 |
+
para_top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
|
| 732 |
+
para_btn = gr.Button("Compare paraphrase representations", variant="primary")
|
| 733 |
+
with gr.Row():
|
| 734 |
+
with gr.Column():
|
| 735 |
+
gr.Markdown("#### Original prompt tokens")
|
| 736 |
+
para_tokens_a = gr.HTML()
|
| 737 |
+
with gr.Column():
|
| 738 |
+
gr.Markdown("#### Paraphrase tokens")
|
| 739 |
+
para_tokens_b = gr.HTML()
|
| 740 |
+
para_metrics = gr.Markdown()
|
| 741 |
+
with gr.Row():
|
| 742 |
+
para_table = gr.Dataframe(
|
| 743 |
+
headers=["Feature id", "Original activation", "Paraphrase activation", "Status", "Offline concept hint"],
|
| 744 |
+
datatype=["number", "number", "number", "str", "str"],
|
| 745 |
+
interactive=False,
|
| 746 |
+
label="Top-feature overlap",
|
| 747 |
+
scale=3,
|
| 748 |
+
)
|
| 749 |
+
para_plot = gr.BarPlot(
|
| 750 |
+
x="Feature",
|
| 751 |
+
y="Activation",
|
| 752 |
+
color="Prompt",
|
| 753 |
+
color_map={"Original": MUTED_TEAL, "Paraphrase": MUTED_PURPLE},
|
| 754 |
+
title="Original vs paraphrase activation",
|
| 755 |
+
x_title="Feature id",
|
| 756 |
+
y_title="Activation",
|
| 757 |
+
x_label_angle=-35,
|
| 758 |
+
height=330,
|
| 759 |
+
scale=2,
|
| 760 |
+
)
|
| 761 |
|
| 762 |
with gr.Tab("Layer trajectory"):
|
| 763 |
gr.Markdown(
|
| 764 |
+
"### Follow representation structure across early, middle and late residual streams\n"
|
| 765 |
"This is **not** a cross-layer feature-ID comparison — SAE dictionaries are layer-specific. "
|
| 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(
|
|
|
|
| 781 |
scale=1,
|
| 782 |
)
|
| 783 |
trajectory_btn = gr.Button("Compare layers", variant="primary")
|
| 784 |
+
gr.Markdown("#### Prompt tokens")
|
| 785 |
trajectory_tokens = gr.HTML()
|
| 786 |
with gr.Row():
|
| 787 |
trajectory_table = gr.Dataframe(interactive=False, label="Layer diagnostics", scale=3)
|
|
|
|
| 789 |
x="Layer",
|
| 790 |
y="Value",
|
| 791 |
color="Metric",
|
| 792 |
+
color_map={
|
| 793 |
+
"Reconstruction cosine": MUTED_TEAL,
|
| 794 |
+
"Top-5 mass": MUTED_OCHRE,
|
| 795 |
+
"Activation entropy": MUTED_RED,
|
| 796 |
+
},
|
| 797 |
title="Representation trajectory",
|
| 798 |
x_title="Layer",
|
| 799 |
y_title="Normalized value",
|
| 800 |
+
height=330,
|
| 801 |
scale=2,
|
| 802 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
|
| 804 |
with gr.Tab("Offline benchmark"):
|
| 805 |
gr.Markdown(RUNTIME.catalog.benchmark_markdown())
|
| 806 |
gr.Markdown(
|
| 807 |
"The offline pipeline evaluates held-out feature/concept AUROC + F1, reconstruction quality, "
|
| 808 |
+
"paraphrase stability, dense residual linear probes, single-feature causal edits, and v0.3 "
|
| 809 |
+
"top-k feature-set ablations against norm-matched controls. Target outcomes use **full continuation "
|
| 810 |
+
"teacher-forced log probability**, with mean log p/token used for length-comparable aggregate tests. "
|
| 811 |
+
"Results are loaded from `artifacts/`; the app never ships invented benchmark numbers."
|
| 812 |
)
|
| 813 |
|
| 814 |
with gr.Tab("Method"):
|
|
|
|
| 816 |
r"""
|
| 817 |
### Reconstruction-preserving intervention
|
| 818 |
|
| 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) z_i d_i$
|
| 823 |
- **Inject:** $h' = h + \delta d_i$
|
| 824 |
|
| 825 |
+
For a feature set $S$, FeatureLens sums the individual ablation/scale deltas before patching the original residual:
|
| 826 |
+
|
| 827 |
+
$$h' = h + \sum_{i \in S} \Delta z_i d_i.$$
|
| 828 |
+
|
| 829 |
+
The app never replaces $h$ with the complete SAE reconstruction, so SAE reconstruction error is not introduced
|
| 830 |
+
as a causal confound.
|
| 831 |
+
|
| 832 |
+
### Full-continuation scoring
|
| 833 |
+
|
| 834 |
+
For a user-supplied target continuation, v0.3 concatenates its token IDs to the prompt and computes teacher-forced
|
| 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. **Reconstruction:** does the SAE represent the residual reasonably well?
|
| 841 |
2. **Prediction:** does a feature predict a controlled concept on held-out paraphrase groups?
|
| 842 |
+
3. **Robustness:** does the sparse representation remain stable across a paraphrase?
|
| 843 |
+
4. **Single-feature intervention:** does changing one feature alter downstream behaviour?
|
| 844 |
+
5. **Feature-set intervention:** does jointly editing a sparse subspace reveal distributed causal influence?
|
| 845 |
+
6. **Specificity:** are those effects larger than norm-matched random residual perturbations?
|
| 846 |
+
7. **Dose-response:** does effect size change coherently as a feature coefficient is varied?
|
| 847 |
|
| 848 |
+
A high AUROC or high paraphrase overlap alone remains correlational evidence.
|
| 849 |
"""
|
| 850 |
)
|
| 851 |
|
|
|
|
| 854 |
"FeatureLens is independent of thesis code and thesis datasets.</small>"
|
| 855 |
)
|
| 856 |
|
| 857 |
+
# Event wiring is kept together so cross-tab state is explicit.
|
| 858 |
+
analyze_btn.click(
|
| 859 |
+
analyze_prompt,
|
| 860 |
+
inputs=[prompt, layer, token_index, top_n],
|
| 861 |
+
outputs=[
|
| 862 |
+
token_view,
|
| 863 |
+
feature_table,
|
| 864 |
+
feature_plot,
|
| 865 |
+
feature_id,
|
| 866 |
+
feature_set_ids,
|
| 867 |
+
analysis_metrics,
|
| 868 |
+
],
|
| 869 |
+
)
|
| 870 |
+
mode.change(mode_help, inputs=[mode], outputs=[coefficient])
|
| 871 |
+
intervene_btn.click(
|
| 872 |
+
run_intervention,
|
| 873 |
+
inputs=[prompt, layer, token_index, feature_id, mode, coefficient, target_text, max_new],
|
| 874 |
+
outputs=[
|
| 875 |
+
baseline_out,
|
| 876 |
+
modified_out,
|
| 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,
|
artifacts/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Generated artifacts
|
| 2 |
|
| 3 |
-
This directory intentionally ships without invented results.
|
| 4 |
|
| 5 |
Run:
|
| 6 |
|
|
@@ -8,6 +8,16 @@ Run:
|
|
| 8 |
python experiments/run_all.py
|
| 9 |
```
|
| 10 |
|
| 11 |
-
to create
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Generated artifacts
|
| 2 |
|
| 3 |
+
This directory intentionally ships without invented empirical results.
|
| 4 |
|
| 5 |
Run:
|
| 6 |
|
|
|
|
| 8 |
python experiments/run_all.py
|
| 9 |
```
|
| 10 |
|
| 11 |
+
to create:
|
| 12 |
|
| 13 |
+
- activation caches;
|
| 14 |
+
- `feature_catalog.csv`;
|
| 15 |
+
- `layer_metrics.csv`;
|
| 16 |
+
- `stability.csv`;
|
| 17 |
+
- `causal_results.csv`;
|
| 18 |
+
- `feature_set_results.csv`;
|
| 19 |
+
- `summary.json`;
|
| 20 |
+
- `report.md`;
|
| 21 |
+
- report figures.
|
| 22 |
+
|
| 23 |
+
Large residual/activation arrays are ignored by Git. Commit only small CSV/JSON/report/figure outputs if you want the live Space to display benchmark-derived feature hints and measured conclusions.
|
docs/HF_DEPLOY.md
CHANGED
|
@@ -1,24 +1,54 @@
|
|
| 1 |
# Hugging Face deployment
|
| 2 |
|
| 3 |
-
FeatureLens targets a Gradio SDK Space with ZeroGPU hardware.
|
| 4 |
|
| 5 |
-
##
|
| 6 |
|
| 7 |
-
|
| 8 |
|
| 9 |
-
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
## GPU-decorated
|
| 16 |
|
| 17 |
-
|
| 18 |
-
- baseline-vs-modified generation: 60-second maximum allocation.
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Hugging Face deployment
|
| 2 |
|
| 3 |
+
FeatureLens targets a **Gradio SDK Space** with ZeroGPU hardware.
|
| 4 |
|
| 5 |
+
## Runtime shape
|
| 6 |
|
| 7 |
+
On Hugging Face, `FEATURELENS_EAGER_LOAD` defaults to `1`. The runtime loads:
|
| 8 |
|
| 9 |
+
- `Qwen/Qwen3-1.7B-Base`;
|
| 10 |
+
- Qwen-Scope SAE layers **4, 14, 26** only.
|
| 11 |
|
| 12 |
+
The full 28-layer SAE repository is not required by the live app.
|
| 13 |
|
| 14 |
+
`app.py` explicitly launches with `ssr_mode=False`. This avoids the SSR/auth path that produced the earlier `get_current_user was never awaited` warning during v0.2 deployment testing.
|
| 15 |
|
| 16 |
+
## GPU-decorated actions
|
| 17 |
|
| 18 |
+
Current v0.3 callback allocations:
|
|
|
|
| 19 |
|
| 20 |
+
- **Inspect sparse features** — 30 s;
|
| 21 |
+
- **Run single-feature causal test** — 45 s;
|
| 22 |
+
- **Run scale dose-response** — 35 s;
|
| 23 |
+
- **Run joint feature-set causal test** — 35 s;
|
| 24 |
+
- **Run 1/3/5-feature ablation sweep** — 35 s;
|
| 25 |
+
- **Compare paraphrase representations** — 30 s;
|
| 26 |
+
- **Compare layers** — 35 s.
|
| 27 |
|
| 28 |
+
These durations are allocation ceilings, not expected runtimes.
|
| 29 |
|
| 30 |
+
## Batched interventions
|
| 31 |
+
|
| 32 |
+
v0.3 deliberately batches experiment conditions inside one GPU callback:
|
| 33 |
+
|
| 34 |
+
- all six dose-response multipliers share one edited forward after the baseline;
|
| 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 |
+
This keeps the stronger diagnostics compatible with a quota-limited interactive Space.
|
| 39 |
+
|
| 40 |
+
## Greedy generation vs target scoring
|
| 41 |
+
|
| 42 |
+
The single-feature causal test still runs baseline and edited greedy generation because the visible text comparison is useful for a demo. Probability-level evidence uses teacher-forced full-continuation scoring, because deterministic text can remain unchanged despite meaningful logit shifts.
|
| 43 |
+
|
| 44 |
+
The feature-set and set-size panels intentionally avoid extra free-running generations and focus on causal probability metrics.
|
| 45 |
+
|
| 46 |
+
## Offline benchmark
|
| 47 |
+
|
| 48 |
+
Do **not** run the full research benchmark in the public Space. Run:
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
python experiments/run_all.py
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
on separate CUDA compute, then commit only the small report/catalog/CSV/figure artifacts you want the Space to display. Large activation arrays remain gitignored.
|
docs/METHODOLOGY.md
CHANGED
|
@@ -2,38 +2,100 @@
|
|
| 2 |
|
| 3 |
## Primary hypothesis
|
| 4 |
|
| 5 |
-
A sparse feature that
|
| 6 |
|
| 7 |
## Discovery data
|
| 8 |
|
| 9 |
-
Seven controlled concepts are represented by 16
|
| 10 |
|
| 11 |
-
The
|
| 12 |
|
| 13 |
-
## Sparse feature
|
| 14 |
|
| 15 |
-
For each configured layer, FeatureLens stores the TopK SAE code
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
## Dense baseline
|
| 20 |
|
| 21 |
-
A multinomial logistic-regression probe is fit to
|
| 22 |
|
| 23 |
-
##
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
## Negative control
|
| 30 |
|
| 31 |
-
For every
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
##
|
| 34 |
|
| 35 |
-
The
|
| 36 |
|
| 37 |
-
|
|
|
|
| 38 |
|
| 39 |
-
|
|
|
|
| 2 |
|
| 3 |
## Primary hypothesis
|
| 4 |
|
| 5 |
+
A sparse feature that predicts a semantic category is not automatically a causal mechanism for behavior. FeatureLens therefore evaluates reconstruction, association, robustness, and causal intervention separately.
|
| 6 |
|
| 7 |
## Discovery data
|
| 8 |
|
| 9 |
+
Seven controlled concepts are represented by 16 prompt pairs per concept. Each pair contains two paraphrases. The split is grouped by `pair_id`, so lexical near-duplicates never cross train and held-out test.
|
| 10 |
|
| 11 |
+
The offline discovery benchmark uses the final prompt-token residual because it summarizes the complete prompt prefix and keeps cached tensors small. The live workbench remains token-selectable.
|
| 12 |
|
| 13 |
+
## Sparse feature selection
|
| 14 |
|
| 15 |
+
For each configured residual layer, FeatureLens stores the TopK SAE code. Candidate features must fire often enough on the training split. They are ranked using training AUROC with activation-rate contrast as a tie-break.
|
| 16 |
|
| 17 |
+
Held-out AUROC and F1 are computed **after** selection. The test split is not used to pick the winning feature.
|
| 18 |
|
| 19 |
## Dense baseline
|
| 20 |
|
| 21 |
+
A multinomial logistic-regression probe is fit to dense residual vectors at the same layers. This asks whether concept information exists in the representation even when no individual sparse feature isolates it cleanly.
|
| 22 |
|
| 23 |
+
## Paraphrase robustness
|
| 24 |
|
| 25 |
+
Sparse representation stability is measured with:
|
| 26 |
|
| 27 |
+
- TopK support Jaccard;
|
| 28 |
+
- sparse activation cosine.
|
| 29 |
+
|
| 30 |
+
The live **Paraphrase robustness** explorer computes the same style of diagnostics for a manually supplied prompt pair. High overlap is robustness evidence only; it does not establish semantic identity of individual feature IDs.
|
| 31 |
+
|
| 32 |
+
## Reconstruction-preserving causal edit
|
| 33 |
+
|
| 34 |
+
For residual `h`, selected SAE activation `z_i`, decoder direction `d_i`, and multiplier `α`:
|
| 35 |
+
|
| 36 |
+
```text
|
| 37 |
+
ablate: h' = h - z_i d_i
|
| 38 |
+
scale: h' = h + (α - 1) z_i d_i
|
| 39 |
+
inject: h' = h + δ d_i
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
The delta is added to the **original residual**. FeatureLens never replaces the residual with the full SAE reconstruction, avoiding reconstruction error as a causal confound.
|
| 43 |
+
|
| 44 |
+
## Full-continuation target scoring
|
| 45 |
+
|
| 46 |
+
v0.3 scores the complete user/task target rather than only its first token.
|
| 47 |
+
|
| 48 |
+
Prompt token IDs are concatenated with the exact target token IDs. A teacher-forced forward pass supplies a log probability for every target token. FeatureLens stores:
|
| 49 |
+
|
| 50 |
+
- total target sequence log probability;
|
| 51 |
+
- mean log probability per target token;
|
| 52 |
+
- per-token log probabilities;
|
| 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 comparable across targets with different token lengths.
|
| 57 |
+
|
| 58 |
+
Next-token probability/rank and JS divergence remain secondary diagnostics.
|
| 59 |
|
| 60 |
## Negative control
|
| 61 |
|
| 62 |
+
For every targeted residual perturbation, FeatureLens constructs a deterministic random residual direction with the same L2 norm and patches it at the same layer and prompt token.
|
| 63 |
+
|
| 64 |
+
The paired targeted-vs-random comparison asks whether the chosen SAE direction matters more than an arbitrary perturbation of equal magnitude.
|
| 65 |
+
|
| 66 |
+
## Single-feature dose-response
|
| 67 |
+
|
| 68 |
+
The live dose-response experiment is explicitly a **scale sweep**:
|
| 69 |
+
|
| 70 |
+
```text
|
| 71 |
+
0×, 0.5×, 1×, 1.5×, 2×, 3×
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
`0×` is ablation and `1×` is the exact no-edit control. All six residual deltas are evaluated as one batch after the baseline.
|
| 75 |
+
|
| 76 |
+
A monotonic curve would strengthen causal evidence, but monotonicity is not assumed. Non-monotonic responses are retained as results.
|
| 77 |
+
|
| 78 |
+
## Feature-set intervention
|
| 79 |
+
|
| 80 |
+
A concept may be distributed across multiple sparse features. For a same-layer feature set `S`, FeatureLens sums individual ablation/scale deltas:
|
| 81 |
+
|
| 82 |
+
```text
|
| 83 |
+
h' = h + Σ_i∈S Δz_i d_i
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
Features from different SAE layers are never summed into one residual intervention.
|
| 87 |
+
|
| 88 |
+
The live app supports custom same-layer feature sets. The offline pipeline additionally selects a concept's best layer using training-only feature scores and tests its top **1 / 3 / 5** distinct features.
|
| 89 |
+
|
| 90 |
+
## Feature-set size control
|
| 91 |
+
|
| 92 |
+
For each k in `1, 3, 5`, targeted joint ablation is compared with a norm-matched random residual perturbation. Increasing k is **not** assumed to increase causal specificity: a larger feature set can simply create a larger perturbation, which is why matched controls and paired statistics remain necessary.
|
| 93 |
|
| 94 |
+
## Statistical interpretation
|
| 95 |
|
| 96 |
+
The generated report calculates:
|
| 97 |
|
| 98 |
+
- bootstrap 95% confidence intervals;
|
| 99 |
+
- paired sign-flip randomization tests for targeted-vs-random absolute effects.
|
| 100 |
|
| 101 |
+
A large point-estimate ratio alone is not treated as strong causal-specificity evidence if paired uncertainty does not support it. The report preserves raw rows regardless of the narrative classification.
|
docs/VALIDATION.md
CHANGED
|
@@ -1,76 +1,416 @@
|
|
| 1 |
-
# FeatureLens validation
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
```bash
|
| 8 |
-
python -m ruff check app.py featurelens experiments tests scripts
|
| 9 |
python -m pytest -q
|
| 10 |
python -m compileall -q app.py featurelens experiments scripts
|
| 11 |
python scripts/release_check.py
|
| 12 |
```
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
```bash
|
| 19 |
-
python app.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
```
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
1
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 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 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FeatureLens v0.3 validation guide
|
| 2 |
|
| 3 |
+
This document intentionally uses the **exact labels visible in the v0.3 Gradio UI**.
|
| 4 |
|
| 5 |
+
The goal is to distinguish three things:
|
| 6 |
+
|
| 7 |
+
1. software correctness;
|
| 8 |
+
2. Hugging Face / ZeroGPU deployment correctness;
|
| 9 |
+
3. scientific sanity checks.
|
| 10 |
+
|
| 11 |
+
Do not interpret one successful prompt as a scientific result. The live tests below only verify that the instrumentation behaves coherently.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## A. Local software gate
|
| 16 |
+
|
| 17 |
+
From the repository root:
|
| 18 |
|
| 19 |
```bash
|
|
|
|
| 20 |
python -m pytest -q
|
| 21 |
python -m compileall -q app.py featurelens experiments scripts
|
| 22 |
python scripts/release_check.py
|
| 23 |
```
|
| 24 |
|
| 25 |
+
Expected:
|
| 26 |
+
|
| 27 |
+
- all tests pass;
|
| 28 |
+
- `compileall` exits without output/error;
|
| 29 |
+
- release check ends with `FeatureLens release check: PASS` and `release: v0.3.0`.
|
| 30 |
|
| 31 |
+
Optional lint gate:
|
| 32 |
|
| 33 |
```bash
|
| 34 |
+
python -m ruff check app.py featurelens experiments tests scripts
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## B. Hugging Face startup test
|
| 40 |
+
|
| 41 |
+
### Test B1 — container startup
|
| 42 |
+
|
| 43 |
+
Open the Space container logs after a fresh rebuild.
|
| 44 |
+
|
| 45 |
+
Expected launch lines:
|
| 46 |
+
|
| 47 |
+
```text
|
| 48 |
+
* Running on local URL: http://0.0.0.0:7860
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
The earlier warning below should **not** reappear:
|
| 52 |
+
|
| 53 |
+
```text
|
| 54 |
+
coroutine 'App.create_app.<locals>.get_current_user' was never awaited
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
The launch output should also not contain `with SSR ⚡` because `app.py` uses `ssr_mode=False`.
|
| 58 |
+
|
| 59 |
+
Pass condition: Space reaches `Running` and the UI loads.
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## C. Workbench inspection
|
| 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`
|
| 73 |
+
- **Prompt token index**: `-1`
|
| 74 |
+
- **Displayed active features**: `12`
|
| 75 |
+
|
| 76 |
+
Click **Inspect sparse features**.
|
| 77 |
+
|
| 78 |
+
Verify all of the following:
|
| 79 |
+
|
| 80 |
+
1. **Prompt tokens** appears directly above token-like boxes. One token has a stronger outline; this is the selected prompt token.
|
| 81 |
+
2. **Analysis metrics** appears and includes:
|
| 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 than 12 are available.
|
| 87 |
+
4. **Activation profile** contains the same displayed feature IDs as the table.
|
| 88 |
+
5. **Single feature id** is populated with those feature IDs.
|
| 89 |
+
6. Open **Feature sets** and confirm **Feature set** is populated, with up to the first 3 displayed features selected by default.
|
| 90 |
+
|
| 91 |
+
Sanity checks:
|
| 92 |
+
|
| 93 |
+
- reconstruction cosine must be finite and normally lie in `[-1, 1]`;
|
| 94 |
+
- NMSE must be finite and non-negative;
|
| 95 |
+
- feature IDs must lie in `[0, 32767]`;
|
| 96 |
+
- activations should be non-negative for the TopK ReLU SAE.
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
## D. Single-feature causal test
|
| 101 |
+
|
| 102 |
+
Use the same Workbench prompt/location from Test C1.
|
| 103 |
+
|
| 104 |
+
Choose the strongest value in **Single feature id**.
|
| 105 |
+
|
| 106 |
+
### Test D1 — ablation with a multi-token target
|
| 107 |
+
|
| 108 |
+
Set:
|
| 109 |
+
|
| 110 |
+
- **Single-feature intervention**: `ablate`
|
| 111 |
+
- **Target continuation (optional)**: `2x`
|
| 112 |
+
- **Greedy generation length**: `8`
|
| 113 |
+
|
| 114 |
+
Click **Run single-feature causal test**.
|
| 115 |
+
|
| 116 |
+
Verify:
|
| 117 |
+
|
| 118 |
+
1. **Baseline greedy generation** is populated.
|
| 119 |
+
2. **SAE-edited greedy generation** is populated.
|
| 120 |
+
3. **Next-token distribution shift** is populated.
|
| 121 |
+
4. The metrics report:
|
| 122 |
+
- original feature activation;
|
| 123 |
+
- Δ coefficient;
|
| 124 |
+
- perturbation L2;
|
| 125 |
+
- next-token JS;
|
| 126 |
+
- random-control JS;
|
| 127 |
+
- full target continuation token count;
|
| 128 |
+
- baseline / SAE / random sequence log p;
|
| 129 |
+
- SAE and random Δ sequence log p;
|
| 130 |
+
- SAE and random Δ mean log p/token;
|
| 131 |
+
- specificity ratio.
|
| 132 |
+
5. **Target continuation token-by-token score** has one row for every token in `2x`.
|
| 133 |
+
|
| 134 |
+
Important expected behaviour:
|
| 135 |
+
|
| 136 |
+
- ablation should report `Δ coefficient = -original activation` for an active feature;
|
| 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 |
+
### Test D2 — scale
|
| 141 |
+
|
| 142 |
+
Set:
|
| 143 |
+
|
| 144 |
+
- **Single-feature intervention**: `scale`
|
| 145 |
+
- **Feature multiplier**: `2`
|
| 146 |
+
- keep **Target continuation (optional)**: `2x`
|
| 147 |
+
|
| 148 |
+
Click **Run single-feature causal test**.
|
| 149 |
+
|
| 150 |
+
Expected:
|
| 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 D3 — inject
|
| 157 |
+
|
| 158 |
+
Set:
|
| 159 |
+
|
| 160 |
+
- **Single-feature intervention**: `inject`
|
| 161 |
+
- **Additive feature coefficient**: `5`
|
| 162 |
+
- keep target `2x`.
|
| 163 |
+
|
| 164 |
+
Expected:
|
| 165 |
+
|
| 166 |
+
- `Δ coefficient = +5` regardless of the original feature activation;
|
| 167 |
+
- perturbation L2 is finite;
|
| 168 |
+
- full-continuation metrics are present.
|
| 169 |
+
|
| 170 |
+
---
|
| 171 |
+
|
| 172 |
+
## E. Single-feature scale dose-response
|
| 173 |
+
|
| 174 |
+
Keep the Test C1 prompt/location, strongest **Single feature id**, and target `2x`.
|
| 175 |
+
|
| 176 |
+
Open **Single-feature scale dose-response**.
|
| 177 |
+
|
| 178 |
+
Click **Run scale dose-response**.
|
| 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.0
|
| 186 |
+
0.5
|
| 187 |
+
1.0
|
| 188 |
+
1.5
|
| 189 |
+
2.0
|
| 190 |
+
3.0
|
| 191 |
```
|
| 192 |
|
| 193 |
+
Interpretation of the multiplier:
|
| 194 |
+
|
| 195 |
+
- `0×` = ablation;
|
| 196 |
+
- `0.5×` = halve the original activation;
|
| 197 |
+
- `1×` = no intervention;
|
| 198 |
+
- `1.5×` = increase by 50%;
|
| 199 |
+
- `2×` = double;
|
| 200 |
+
- `3×` = triple.
|
| 201 |
+
|
| 202 |
+
Critical no-op sanity check for the `1×` row:
|
| 203 |
+
|
| 204 |
+
- `Δ feature coefficient ≈ 0`;
|
| 205 |
+
- `Perturbation L2 ≈ 0`;
|
| 206 |
+
- `Δ mean log p/token ≈ 0`;
|
| 207 |
+
- `Δ sequence log p ≈ 0`;
|
| 208 |
+
- `Next-token JS ≈ 0`.
|
| 209 |
+
|
| 210 |
+
The curve need not be monotonic. A non-monotonic curve is a scientific observation, not a software error, provided the `1×` no-op row is correct.
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
+
## F. Joint feature-set causal test
|
| 215 |
+
|
| 216 |
+
First run Test C1 so **Feature set** is populated.
|
| 217 |
+
|
| 218 |
+
Open **Feature sets**.
|
| 219 |
+
|
| 220 |
+
### Test F1 — top three joint ablation
|
| 221 |
+
|
| 222 |
+
Use the default 3 selected values in **Feature set**.
|
| 223 |
+
|
| 224 |
+
Set:
|
| 225 |
+
|
| 226 |
+
- **Feature-set intervention**: `ablate`
|
| 227 |
+
- **Target continuation**: `2x`
|
| 228 |
+
|
| 229 |
+
Click **Run joint feature-set causal test**.
|
| 230 |
+
|
| 231 |
+
Verify:
|
| 232 |
+
|
| 233 |
+
1. **Joint intervention features** contains the selected feature IDs, original activations, and each Δ coefficient.
|
| 234 |
+
2. Under ablation, each active feature's Δ coefficient is the negative of its original activation.
|
| 235 |
+
3. The metrics report:
|
| 236 |
+
- selected set size;
|
| 237 |
+
- total joint perturbation L2;
|
| 238 |
+
- full target sequence / mean-per-token effects;
|
| 239 |
+
- matched random-control effects;
|
| 240 |
+
- specificity ratios.
|
| 241 |
+
4. **Target continuation token-by-token score** contains all target tokens.
|
| 242 |
+
|
| 243 |
+
### Test F2 — joint scale
|
| 244 |
+
|
| 245 |
+
Set:
|
| 246 |
+
|
| 247 |
+
- **Feature-set intervention**: `scale`
|
| 248 |
+
- **Shared feature multiplier**: `2`
|
| 249 |
+
|
| 250 |
+
Run again.
|
| 251 |
+
|
| 252 |
+
Expected: every selected active feature receives its own `+original activation` coefficient delta before the decoder deltas are summed.
|
| 253 |
+
|
| 254 |
+
---
|
| 255 |
+
|
| 256 |
+
## G. 1/3/5-feature ablation sweep
|
| 257 |
+
|
| 258 |
+
Still in **Feature sets**.
|
| 259 |
+
|
| 260 |
+
Set **Target continuation for set-size sweep** to `2x`.
|
| 261 |
+
|
| 262 |
+
Click **Run 1/3/5-feature ablation sweep**.
|
| 263 |
+
|
| 264 |
+
Expected **Feature-set size measurements** rows:
|
| 265 |
+
|
| 266 |
+
```text
|
| 267 |
+
1
|
| 268 |
+
3
|
| 269 |
+
5
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
For each row verify:
|
| 273 |
+
|
| 274 |
+
- the feature ID list contains exactly `k` IDs;
|
| 275 |
+
- perturbation L2 is finite;
|
| 276 |
+
- SAE and random Δ mean log p/token are present;
|
| 277 |
+
- specificity ratio is finite unless the random effect is numerically zero;
|
| 278 |
+
- SAE and random next-token JS are present.
|
| 279 |
+
|
| 280 |
+
This sweep always performs **joint ablation of the strongest active features** at the current Workbench prompt location. It does not use **Feature-set intervention** or **Shared feature multiplier**.
|
| 281 |
+
|
| 282 |
+
Scientific sanity check: effect magnitude does **not** have to increase with k. If k=5 is weaker or less specific than k=1, retain that result.
|
| 283 |
+
|
| 284 |
+
---
|
| 285 |
+
|
| 286 |
+
## H. Paraphrase robustness
|
| 287 |
+
|
| 288 |
+
Open **Paraphrase robustness**.
|
| 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`
|
| 295 |
+
- **Original prompt token index**: `-1`
|
| 296 |
+
- **Paraphrase token index**: `-1`
|
| 297 |
+
- **Displayed active features**: `12`
|
| 298 |
+
|
| 299 |
+
Click **Compare paraphrase representations**.
|
| 300 |
+
|
| 301 |
+
Verify:
|
| 302 |
+
|
| 303 |
+
1. **Original prompt tokens** and **Paraphrase tokens** both render.
|
| 304 |
+
2. **Robustness metrics** reports:
|
| 305 |
+
- full TopK feature-set Jaccard;
|
| 306 |
+
- sparse activation cosine;
|
| 307 |
+
- shared displayed top features.
|
| 308 |
+
3. **Top-feature overlap** labels features as `shared`, `original only`, or `paraphrase only`.
|
| 309 |
+
4. **Original vs paraphrase activation** renders both conditions with distinct muted colors.
|
| 310 |
+
|
| 311 |
+
Bounds:
|
| 312 |
+
|
| 313 |
+
- Jaccard must lie in `[0, 1]`;
|
| 314 |
+
- sparse cosine should lie approximately in `[0, 1]` because SAE TopK activations are non-negative.
|
| 315 |
+
|
| 316 |
+
Repeat with a deliberately weak/non-paraphrase second prompt. The metrics should be allowed to decrease; there is no hard-coded expected threshold.
|
| 317 |
+
|
| 318 |
+
---
|
| 319 |
+
|
| 320 |
+
## I. Layer trajectory
|
| 321 |
+
|
| 322 |
+
Open **Layer trajectory**.
|
| 323 |
+
|
| 324 |
+
Set:
|
| 325 |
+
|
| 326 |
+
- **Prompt**: `The derivative of x squared is`
|
| 327 |
+
- **Prompt token index**: `-1`
|
| 328 |
+
|
| 329 |
+
Click **Compare layers**.
|
| 330 |
+
|
| 331 |
+
Expected:
|
| 332 |
+
|
| 333 |
+
- **Prompt tokens** renders;
|
| 334 |
+
- **Layer diagnostics** contains rows `4`, `14`, `26`;
|
| 335 |
+
- **Representation trajectory** contains:
|
| 336 |
+
- Reconstruction cosine;
|
| 337 |
+
- Top-5 mass;
|
| 338 |
+
- Activation entropy.
|
| 339 |
+
|
| 340 |
+
Do not compare feature ID numbers across layers. Each layer uses a separate SAE dictionary.
|
| 341 |
+
|
| 342 |
+
---
|
| 343 |
+
|
| 344 |
+
## J. Adversarial / edge-case tests
|
| 345 |
+
|
| 346 |
+
These are intended to find bugs, not produce attractive screenshots.
|
| 347 |
+
|
| 348 |
+
### J1 — invalid token index
|
| 349 |
+
|
| 350 |
+
Set **Prompt token index** to an index larger than the prompt length and click **Inspect sparse features**.
|
| 351 |
+
|
| 352 |
+
Expected: a clear UI error explaining the token index is outside the prompt length.
|
| 353 |
+
|
| 354 |
+
### J2 — inactive custom feature
|
| 355 |
+
|
| 356 |
+
After inspection, enter a custom **Single feature id** that is not active at the selected token and choose `ablate`.
|
| 357 |
+
|
| 358 |
+
Expected: FeatureLens warns that ablation/scaling produces a zero feature delta for an inactive feature.
|
| 359 |
+
|
| 360 |
+
Then switch to `inject` with coefficient `5`.
|
| 361 |
+
|
| 362 |
+
Expected: a non-zero decoder-direction perturbation is still possible.
|
| 363 |
+
|
| 364 |
+
### J3 — negation prompt
|
| 365 |
+
|
| 366 |
+
Inspect:
|
| 367 |
+
|
| 368 |
+
```text
|
| 369 |
+
This is not a positive review.
|
| 370 |
+
```
|
| 371 |
+
|
| 372 |
+
Do not assume a positive-sentiment feature should dominate merely because the word `positive` appears.
|
| 373 |
+
|
| 374 |
+
### J4 — mixed language
|
| 375 |
+
|
| 376 |
+
Inspect:
|
| 377 |
+
|
| 378 |
+
```text
|
| 379 |
+
The answer est probablement correct, but I am not certain.
|
| 380 |
+
```
|
| 381 |
+
|
| 382 |
+
Use this only as a robustness/adversarial check. Do not retrofit semantic labels from one example.
|
| 383 |
+
|
| 384 |
+
### J5 — target whitespace
|
| 385 |
+
|
| 386 |
+
Compare target continuations `2x` and ` 2x`.
|
| 387 |
+
|
| 388 |
+
They may tokenize differently. v0.3 intentionally scores the exact text entered in **Target continuation (optional)**.
|
| 389 |
+
|
| 390 |
+
---
|
| 391 |
+
|
| 392 |
+
## K. Scientific benchmark acceptance
|
| 393 |
+
|
| 394 |
+
After the live Space is stable, run:
|
| 395 |
+
|
| 396 |
+
```bash
|
| 397 |
+
python experiments/run_all.py
|
| 398 |
+
```
|
| 399 |
+
|
| 400 |
+
Expected new v0.3 artifact:
|
| 401 |
+
|
| 402 |
+
```text
|
| 403 |
+
artifacts/feature_set_results.csv
|
| 404 |
+
```
|
| 405 |
+
|
| 406 |
+
The generated report should use `target_mean_logprob_delta` when the v0.3 causal results are present and should include `figures/feature_set_effects.png`.
|
| 407 |
+
|
| 408 |
+
Before using numbers on a resume or portfolio page, manually inspect:
|
| 409 |
+
|
| 410 |
+
- held-out feature selection discipline;
|
| 411 |
+
- paraphrase split integrity;
|
| 412 |
+
- single-feature SAE vs random paired rows;
|
| 413 |
+
- feature-set SAE vs random paired rows;
|
| 414 |
+
- inactive-feature rate;
|
| 415 |
+
- confidence intervals / sign-flip tests;
|
| 416 |
+
- whether any headline claim is actually supported by the saved raw rows.
|
experiments/make_report.py
CHANGED
|
@@ -24,9 +24,7 @@ def parse_args() -> argparse.Namespace:
|
|
| 24 |
|
| 25 |
def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
|
| 26 |
scored = catalog.copy()
|
| 27 |
-
scored['activation_contrast'] =
|
| 28 |
-
scored['activation_rate_pos'] - scored['activation_rate_neg']
|
| 29 |
-
)
|
| 30 |
ordered = scored.sort_values(
|
| 31 |
['concept', 'train_auroc', 'activation_contrast'],
|
| 32 |
ascending=[True, False, False],
|
|
@@ -34,7 +32,20 @@ def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
|
|
| 34 |
return ordered.groupby('concept', as_index=False).first()
|
| 35 |
|
| 36 |
|
| 37 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
fig_dir = artifact_dir / 'figures'
|
| 39 |
fig_dir.mkdir(parents=True, exist_ok=True)
|
| 40 |
|
|
@@ -51,8 +62,18 @@ def _save_plots(artifact_dir: Path, selected: pd.DataFrame, layers: pd.DataFrame
|
|
| 51 |
|
| 52 |
figure = plt.figure(figsize=(7.0, 4.2))
|
| 53 |
ax = figure.add_subplot(111)
|
| 54 |
-
ax.plot(
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
ax.set_xlabel('Layer')
|
| 57 |
ax.set_ylim(0, 1.05)
|
| 58 |
ax.set_title('Layer-wise representation diagnostics')
|
|
@@ -61,22 +82,81 @@ def _save_plots(artifact_dir: Path, selected: pd.DataFrame, layers: pd.DataFrame
|
|
| 61 |
figure.savefig(fig_dir / 'layer_diagnostics.png', dpi=160)
|
| 62 |
plt.close(figure)
|
| 63 |
|
|
|
|
| 64 |
grouped = (
|
| 65 |
-
causal.groupby(['intervention', 'condition'])[
|
| 66 |
.apply(lambda values: float(np.mean(np.abs(values))))
|
| 67 |
-
.reset_index(name='
|
| 68 |
)
|
| 69 |
-
pivot = grouped.pivot(index='intervention', columns='condition', values='
|
| 70 |
figure = plt.figure(figsize=(7.0, 4.2))
|
| 71 |
ax = figure.add_subplot(111)
|
| 72 |
pivot.plot(kind='bar', ax=ax)
|
| 73 |
-
ax.set_ylabel('Mean |Δ log p(target)|')
|
| 74 |
-
ax.set_title('SAE
|
| 75 |
ax.tick_params(axis='x', rotation=0)
|
| 76 |
figure.tight_layout()
|
| 77 |
figure.savefig(fig_dir / 'causal_effects.png', dpi=160)
|
| 78 |
plt.close(figure)
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
def main() -> None:
|
| 82 |
args = parse_args()
|
|
@@ -84,8 +164,10 @@ def main() -> None:
|
|
| 84 |
layers = pd.read_csv(args.artifact_dir / 'layer_metrics.csv')
|
| 85 |
stability = pd.read_csv(args.artifact_dir / 'stability.csv')
|
| 86 |
causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
|
|
|
|
|
|
|
| 87 |
selected = _selected_features(catalog)
|
| 88 |
-
_save_plots(args.artifact_dir, selected, layers, causal)
|
| 89 |
|
| 90 |
mean_auc = float(selected['auroc'].mean())
|
| 91 |
median_auc = float(selected['auroc'].median())
|
|
@@ -94,68 +176,120 @@ def main() -> None:
|
|
| 94 |
mean_jaccard = float(stability['topk_jaccard'].mean())
|
| 95 |
mean_sparse_cos = float(stability['sparse_cosine'].mean())
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
sae_abs = float(np.mean(np.abs(sae['target_logprob_delta'])))
|
| 100 |
-
random_abs = float(np.mean(np.abs(random['target_logprob_delta'])))
|
| 101 |
-
ratio = sae_abs / max(random_abs, 1e-12)
|
| 102 |
-
|
| 103 |
-
paired = causal.pivot_table(
|
| 104 |
index=['task_id', 'intervention'],
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
).dropna(subset=['sae_feature', 'random_norm_matched'])
|
| 109 |
-
paired_sae = np.abs(paired['sae_feature'].to_numpy(dtype=float))
|
| 110 |
-
paired_random = np.abs(paired['random_norm_matched'].to_numpy(dtype=float))
|
| 111 |
-
diff_mean = float(np.mean(paired_sae - paired_random))
|
| 112 |
-
diff_ci_low, diff_ci_high = paired_bootstrap_difference_ci(
|
| 113 |
-
paired_sae, paired_random, seed=43
|
| 114 |
)
|
| 115 |
-
|
| 116 |
-
|
| 117 |
active_rate = float(np.mean(sae['feature_activation'] > 0))
|
| 118 |
top1_change = float(sae['top1_changed'].mean())
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
if mean_auc >= 0.8 and sae_abs < 0.08:
|
| 121 |
interpretation = (
|
| 122 |
-
'The selected sparse features were strongly predictive on held-out prompts, but
|
| 123 |
-
'interventions produced only modest downstream
|
| 124 |
-
'
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
)
|
| 127 |
elif mean_auc >= 0.8 and sae_abs >= 0.08 and ratio >= 1.5:
|
| 128 |
interpretation = (
|
| 129 |
-
'The selected sparse features were strongly predictive and their
|
| 130 |
-
'
|
| 131 |
-
'
|
|
|
|
| 132 |
)
|
| 133 |
elif mean_auc < 0.65:
|
| 134 |
interpretation = (
|
| 135 |
-
'Feature/concept predictiveness was limited on held-out prompts, so strong causal claims '
|
| 136 |
-
'
|
| 137 |
-
'
|
| 138 |
)
|
| 139 |
else:
|
| 140 |
interpretation = (
|
| 141 |
-
'The results show mixed predictive and causal evidence. FeatureLens
|
| 142 |
-
'
|
| 143 |
-
'single interpretability score.'
|
| 144 |
)
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
headline = (
|
| 147 |
-
f'Selected SAE features averaged {mean_auc:.3f} held-out AUROC; SAE interventions
|
| 148 |
-
f'
|
| 149 |
-
'random residual controls.'
|
| 150 |
)
|
| 151 |
highlights = [
|
| 152 |
f'Median selected-feature held-out AUROC: {median_auc:.3f}; mean AUROC 95% bootstrap CI [{auc_ci_low:.3f}, {auc_ci_high:.3f}].',
|
| 153 |
f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
|
| 154 |
f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
|
| 155 |
-
f'Selected feature active on {active_rate:.1%} of causal prompts; modified top-1
|
| 156 |
-
f'
|
| 157 |
-
f'
|
| 158 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
summary = {
|
| 160 |
'headline': headline,
|
| 161 |
'highlights': highlights,
|
|
@@ -168,14 +302,16 @@ def main() -> None:
|
|
| 168 |
'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
|
| 169 |
'mean_paraphrase_topk_jaccard': mean_jaccard,
|
| 170 |
'mean_paraphrase_sparse_cosine': mean_sparse_cos,
|
| 171 |
-
'
|
| 172 |
-
'
|
|
|
|
| 173 |
'causal_to_random_effect_ratio': ratio,
|
| 174 |
-
'paired_mean_abs_effect_advantage':
|
| 175 |
-
'paired_mean_abs_effect_advantage_bootstrap_ci_95': [
|
| 176 |
-
'paired_sign_flip_pvalue':
|
| 177 |
'causal_prompt_feature_active_rate': active_rate,
|
| 178 |
'sae_top1_change_rate': top1_change,
|
|
|
|
| 179 |
},
|
| 180 |
}
|
| 181 |
(args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
|
|
@@ -200,15 +336,17 @@ def main() -> None:
|
|
| 200 |
'## Experimental design',
|
| 201 |
'',
|
| 202 |
'- Model: Qwen3-1.7B-Base.',
|
| 203 |
-
'- SAEs: Qwen-Scope residual-stream TopK SAEs at
|
| 204 |
'- Discovery set: controlled concept prompts with paired paraphrases.',
|
| 205 |
-
'- Split discipline: paraphrase groups
|
| 206 |
'- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
|
| 207 |
'- Linear baseline: multinomial logistic regression on the dense residual stream.',
|
| 208 |
-
'-
|
| 209 |
-
'-
|
| 210 |
-
'-
|
| 211 |
-
'-
|
|
|
|
|
|
|
| 212 |
'',
|
| 213 |
'## Figures',
|
| 214 |
'',
|
|
@@ -216,17 +354,23 @@ def main() -> None:
|
|
| 216 |
'',
|
| 217 |
'',
|
| 218 |
'',
|
| 219 |
-
'![
|
| 220 |
-
'',
|
| 221 |
-
'## Interpretation guardrails',
|
| 222 |
-
'',
|
| 223 |
-
'A high feature/concept AUROC is treated as correlational evidence only. Causal evidence requires a downstream change under intervention and is interpreted relative to the norm-matched random control. The narrative above is generated from saved metrics; no result values are hard-coded.',
|
| 224 |
-
'',
|
| 225 |
-
'## Reproducibility',
|
| 226 |
-
'',
|
| 227 |
-
'Run `python experiments/run_all.py` from the repository root. Raw activation matrices, splits, selected features, causal rows, figures, and this report are all materialized under `artifacts/`.',
|
| 228 |
-
'',
|
| 229 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
(args.artifact_dir / 'report.md').write_text('\n'.join(lines), encoding='utf-8')
|
| 231 |
print(headline)
|
| 232 |
print(f'Wrote {args.artifact_dir / "report.md"}')
|
|
|
|
| 24 |
|
| 25 |
def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
|
| 26 |
scored = catalog.copy()
|
| 27 |
+
scored['activation_contrast'] = scored['activation_rate_pos'] - scored['activation_rate_neg']
|
|
|
|
|
|
|
| 28 |
ordered = scored.sort_values(
|
| 29 |
['concept', 'train_auroc', 'activation_contrast'],
|
| 30 |
ascending=[True, False, False],
|
|
|
|
| 32 |
return ordered.groupby('concept', as_index=False).first()
|
| 33 |
|
| 34 |
|
| 35 |
+
def _effect_column(frame: pd.DataFrame) -> str:
|
| 36 |
+
"""Prefer the v0.3 length-normalized full-continuation metric, with v0.2 fallback."""
|
| 37 |
+
if 'target_mean_logprob_delta' in frame.columns:
|
| 38 |
+
return 'target_mean_logprob_delta'
|
| 39 |
+
return 'target_logprob_delta'
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _save_plots(
|
| 43 |
+
artifact_dir: Path,
|
| 44 |
+
selected: pd.DataFrame,
|
| 45 |
+
layers: pd.DataFrame,
|
| 46 |
+
causal: pd.DataFrame,
|
| 47 |
+
feature_sets: pd.DataFrame | None,
|
| 48 |
+
) -> None:
|
| 49 |
fig_dir = artifact_dir / 'figures'
|
| 50 |
fig_dir.mkdir(parents=True, exist_ok=True)
|
| 51 |
|
|
|
|
| 62 |
|
| 63 |
figure = plt.figure(figsize=(7.0, 4.2))
|
| 64 |
ax = figure.add_subplot(111)
|
| 65 |
+
ax.plot(
|
| 66 |
+
layers['layer'],
|
| 67 |
+
layers['linear_probe_macro_auroc'],
|
| 68 |
+
marker='o',
|
| 69 |
+
label='Linear probe AUROC',
|
| 70 |
+
)
|
| 71 |
+
ax.plot(
|
| 72 |
+
layers['layer'],
|
| 73 |
+
layers['reconstruction_cosine'],
|
| 74 |
+
marker='o',
|
| 75 |
+
label='SAE reconstruction cosine',
|
| 76 |
+
)
|
| 77 |
ax.set_xlabel('Layer')
|
| 78 |
ax.set_ylim(0, 1.05)
|
| 79 |
ax.set_title('Layer-wise representation diagnostics')
|
|
|
|
| 82 |
figure.savefig(fig_dir / 'layer_diagnostics.png', dpi=160)
|
| 83 |
plt.close(figure)
|
| 84 |
|
| 85 |
+
causal_metric = _effect_column(causal)
|
| 86 |
grouped = (
|
| 87 |
+
causal.groupby(['intervention', 'condition'])[causal_metric]
|
| 88 |
.apply(lambda values: float(np.mean(np.abs(values))))
|
| 89 |
+
.reset_index(name='mean_abs_effect')
|
| 90 |
)
|
| 91 |
+
pivot = grouped.pivot(index='intervention', columns='condition', values='mean_abs_effect')
|
| 92 |
figure = plt.figure(figsize=(7.0, 4.2))
|
| 93 |
ax = figure.add_subplot(111)
|
| 94 |
pivot.plot(kind='bar', ax=ax)
|
| 95 |
+
ax.set_ylabel('Mean |Δ mean log p/token|' if causal_metric == 'target_mean_logprob_delta' else 'Mean |Δ log p(target)|')
|
| 96 |
+
ax.set_title('Single-feature SAE edits vs norm-matched controls')
|
| 97 |
ax.tick_params(axis='x', rotation=0)
|
| 98 |
figure.tight_layout()
|
| 99 |
figure.savefig(fig_dir / 'causal_effects.png', dpi=160)
|
| 100 |
plt.close(figure)
|
| 101 |
|
| 102 |
+
if feature_sets is not None and not feature_sets.empty:
|
| 103 |
+
set_metric = _effect_column(feature_sets)
|
| 104 |
+
grouped_sets = (
|
| 105 |
+
feature_sets.groupby(['set_size', 'condition'])[set_metric]
|
| 106 |
+
.apply(lambda values: float(np.mean(np.abs(values))))
|
| 107 |
+
.reset_index(name='mean_abs_effect')
|
| 108 |
+
)
|
| 109 |
+
set_pivot = grouped_sets.pivot(index='set_size', columns='condition', values='mean_abs_effect')
|
| 110 |
+
figure = plt.figure(figsize=(7.0, 4.2))
|
| 111 |
+
ax = figure.add_subplot(111)
|
| 112 |
+
set_pivot.plot(kind='line', marker='o', ax=ax)
|
| 113 |
+
ax.set_xlabel('Jointly ablated feature count')
|
| 114 |
+
ax.set_ylabel('Mean |Δ mean log p/token|' if set_metric == 'target_mean_logprob_delta' else 'Mean |Δ log p(target)|')
|
| 115 |
+
ax.set_title('Distributed feature-set causal effect')
|
| 116 |
+
figure.tight_layout()
|
| 117 |
+
figure.savefig(fig_dir / 'feature_set_effects.png', dpi=160)
|
| 118 |
+
plt.close(figure)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _paired_stats(
|
| 122 |
+
frame: pd.DataFrame,
|
| 123 |
+
*,
|
| 124 |
+
index: list[str],
|
| 125 |
+
sae_condition: str,
|
| 126 |
+
random_condition: str,
|
| 127 |
+
seed: int,
|
| 128 |
+
) -> dict[str, float | list[float]]:
|
| 129 |
+
metric = _effect_column(frame)
|
| 130 |
+
paired = frame.pivot_table(
|
| 131 |
+
index=index,
|
| 132 |
+
columns='condition',
|
| 133 |
+
values=metric,
|
| 134 |
+
aggfunc='first',
|
| 135 |
+
).dropna(subset=[sae_condition, random_condition])
|
| 136 |
+
sae_abs = np.abs(paired[sae_condition].to_numpy(dtype=float))
|
| 137 |
+
random_abs = np.abs(paired[random_condition].to_numpy(dtype=float))
|
| 138 |
+
if sae_abs.size == 0:
|
| 139 |
+
return {
|
| 140 |
+
'sae_abs': float('nan'),
|
| 141 |
+
'random_abs': float('nan'),
|
| 142 |
+
'ratio': float('nan'),
|
| 143 |
+
'paired_advantage': float('nan'),
|
| 144 |
+
'ci': [float('nan'), float('nan')],
|
| 145 |
+
'pvalue': float('nan'),
|
| 146 |
+
'n_pairs': 0,
|
| 147 |
+
}
|
| 148 |
+
diff = sae_abs - random_abs
|
| 149 |
+
low, high = paired_bootstrap_difference_ci(sae_abs, random_abs, seed=seed)
|
| 150 |
+
return {
|
| 151 |
+
'sae_abs': float(np.mean(sae_abs)),
|
| 152 |
+
'random_abs': float(np.mean(random_abs)),
|
| 153 |
+
'ratio': float(np.mean(sae_abs) / max(float(np.mean(random_abs)), 1e-12)),
|
| 154 |
+
'paired_advantage': float(np.mean(diff)),
|
| 155 |
+
'ci': [float(low), float(high)],
|
| 156 |
+
'pvalue': float(paired_sign_flip_pvalue(sae_abs, random_abs, seed=seed + 1)),
|
| 157 |
+
'n_pairs': int(sae_abs.size),
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
|
| 161 |
def main() -> None:
|
| 162 |
args = parse_args()
|
|
|
|
| 164 |
layers = pd.read_csv(args.artifact_dir / 'layer_metrics.csv')
|
| 165 |
stability = pd.read_csv(args.artifact_dir / 'stability.csv')
|
| 166 |
causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
|
| 167 |
+
feature_set_path = args.artifact_dir / 'feature_set_results.csv'
|
| 168 |
+
feature_sets = pd.read_csv(feature_set_path) if feature_set_path.exists() else None
|
| 169 |
selected = _selected_features(catalog)
|
| 170 |
+
_save_plots(args.artifact_dir, selected, layers, causal, feature_sets)
|
| 171 |
|
| 172 |
mean_auc = float(selected['auroc'].mean())
|
| 173 |
median_auc = float(selected['auroc'].median())
|
|
|
|
| 176 |
mean_jaccard = float(stability['topk_jaccard'].mean())
|
| 177 |
mean_sparse_cos = float(stability['sparse_cosine'].mean())
|
| 178 |
|
| 179 |
+
single = _paired_stats(
|
| 180 |
+
causal,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
index=['task_id', 'intervention'],
|
| 182 |
+
sae_condition='sae_feature',
|
| 183 |
+
random_condition='random_norm_matched',
|
| 184 |
+
seed=43,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
)
|
| 186 |
+
sae = causal[causal['condition'] == 'sae_feature']
|
|
|
|
| 187 |
active_rate = float(np.mean(sae['feature_activation'] > 0))
|
| 188 |
top1_change = float(sae['top1_changed'].mean())
|
| 189 |
|
| 190 |
+
set_summary: dict[int, dict[str, float | list[float]]] = {}
|
| 191 |
+
largest_set: dict[str, float | list[float]] | None = None
|
| 192 |
+
largest_k: int | None = None
|
| 193 |
+
if feature_sets is not None and not feature_sets.empty:
|
| 194 |
+
for size in sorted(int(x) for x in feature_sets['set_size'].unique()):
|
| 195 |
+
subset = feature_sets[feature_sets['set_size'] == size]
|
| 196 |
+
set_summary[size] = _paired_stats(
|
| 197 |
+
subset,
|
| 198 |
+
index=['task_id', 'set_size'],
|
| 199 |
+
sae_condition='sae_feature_set',
|
| 200 |
+
random_condition='random_norm_matched',
|
| 201 |
+
seed=100 + size,
|
| 202 |
+
)
|
| 203 |
+
largest_k = max(set_summary)
|
| 204 |
+
largest_set = set_summary[largest_k]
|
| 205 |
+
|
| 206 |
+
sae_abs = float(single['sae_abs'])
|
| 207 |
+
random_abs = float(single['random_abs'])
|
| 208 |
+
ratio = float(single['ratio'])
|
| 209 |
+
|
| 210 |
+
single_ci_low = float(single['ci'][0])
|
| 211 |
+
single_p = float(single['pvalue'])
|
| 212 |
+
single_specific = ratio >= 1.5 and single_ci_low > 0.0 and single_p < 0.05
|
| 213 |
+
|
| 214 |
if mean_auc >= 0.8 and sae_abs < 0.08:
|
| 215 |
interpretation = (
|
| 216 |
+
'The selected sparse features were strongly predictive on held-out prompts, but single-feature '
|
| 217 |
+
'interventions produced only modest downstream changes. FeatureLens therefore treats the '
|
| 218 |
+
'representation-level signal as correlational rather than automatically causal.'
|
| 219 |
+
)
|
| 220 |
+
elif mean_auc >= 0.8 and sae_abs >= 0.08 and single_specific:
|
| 221 |
+
interpretation = (
|
| 222 |
+
'The selected sparse features were strongly predictive and single-feature interventions produced '
|
| 223 |
+
'larger target-continuation shifts than norm-matched random residual perturbations. The paired '
|
| 224 |
+
'bootstrap interval excludes zero and the sign-flip test passes the configured 0.05 threshold, '
|
| 225 |
+
'supporting a causal-specificity claim for at least some predictive features.'
|
| 226 |
)
|
| 227 |
elif mean_auc >= 0.8 and sae_abs >= 0.08 and ratio >= 1.5:
|
| 228 |
interpretation = (
|
| 229 |
+
'The selected sparse features were strongly predictive and their point-estimate intervention '
|
| 230 |
+
'effects exceeded norm-matched random controls, but the paired uncertainty test does not support '
|
| 231 |
+
'a strong causal-specificity claim at the 0.05 threshold. The result is reported as suggestive '
|
| 232 |
+
'rather than conclusive.'
|
| 233 |
)
|
| 234 |
elif mean_auc < 0.65:
|
| 235 |
interpretation = (
|
| 236 |
+
'Feature/concept predictiveness was limited on held-out prompts, so strong causal claims would '
|
| 237 |
+
'be premature. The main result is diagnostic: concept design or feature selection should be '
|
| 238 |
+
'refined before interpreting intervention effects.'
|
| 239 |
)
|
| 240 |
else:
|
| 241 |
interpretation = (
|
| 242 |
+
'The results show mixed predictive and causal evidence. FeatureLens reports association, '
|
| 243 |
+
'robustness, and intervention measurements separately rather than collapsing them into one score.'
|
|
|
|
| 244 |
)
|
| 245 |
|
| 246 |
+
if largest_set is not None and largest_k is not None:
|
| 247 |
+
set_advantage = float(largest_set['paired_advantage'])
|
| 248 |
+
set_ci_low = float(largest_set['ci'][0])
|
| 249 |
+
set_p = float(largest_set['pvalue'])
|
| 250 |
+
set_specific = set_ci_low > 0.0 and set_p < 0.05
|
| 251 |
+
if set_advantage > float(single['paired_advantage']) + 0.02 and set_specific:
|
| 252 |
+
interpretation += (
|
| 253 |
+
f' Joint ablation of the top {largest_k} same-layer concept features produced a larger '
|
| 254 |
+
'paired advantage over random controls than the single-feature edits, with paired uncertainty '
|
| 255 |
+
'supporting the difference. This is consistent with causal influence being distributed across '
|
| 256 |
+
'a sparse feature set rather than concentrated in one unit.'
|
| 257 |
+
)
|
| 258 |
+
elif set_advantage > float(single['paired_advantage']) + 0.02:
|
| 259 |
+
interpretation += (
|
| 260 |
+
f' The top-{largest_k} joint-ablation point estimate exceeded the single-feature advantage, '
|
| 261 |
+
'but its paired uncertainty test does not support a strong distributed-causality claim at the '
|
| 262 |
+
'0.05 threshold. The pattern is therefore treated as suggestive only.'
|
| 263 |
+
)
|
| 264 |
+
elif abs(set_advantage) <= 0.02:
|
| 265 |
+
interpretation += (
|
| 266 |
+
f' Expanding the intervention to the top {largest_k} same-layer features did not materially '
|
| 267 |
+
'increase specificity over random controls, which argues against assuming that a broader '
|
| 268 |
+
'concept-associated sparse subspace is automatically more causal.'
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
effect_label = 'mean log p/token' if _effect_column(causal) == 'target_mean_logprob_delta' else 'target log-probability'
|
| 272 |
headline = (
|
| 273 |
+
f'Selected SAE features averaged {mean_auc:.3f} held-out AUROC; single-feature SAE interventions '
|
| 274 |
+
f'changed {effect_label} by {sae_abs:.3f} in absolute value on average versus {random_abs:.3f} '
|
| 275 |
+
'for norm-matched random residual controls.'
|
| 276 |
)
|
| 277 |
highlights = [
|
| 278 |
f'Median selected-feature held-out AUROC: {median_auc:.3f}; mean AUROC 95% bootstrap CI [{auc_ci_low:.3f}, {auc_ci_high:.3f}].',
|
| 279 |
f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
|
| 280 |
f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
|
| 281 |
+
f'Selected feature active on {active_rate:.1%} of causal prompts; modified next-token top-1 on {top1_change:.1%}.',
|
| 282 |
+
f'Single-feature mean absolute causal effect / random-control effect ratio: {ratio:.2f}×.',
|
| 283 |
+
f'Single-feature paired mean |effect| advantage over random: {float(single["paired_advantage"]):+.3f}, 95% bootstrap CI [{float(single["ci"][0]):+.3f}, {float(single["ci"][1]):+.3f}], sign-flip p={float(single["pvalue"]):.4f}.',
|
| 284 |
]
|
| 285 |
+
if largest_set is not None and largest_k is not None:
|
| 286 |
+
highlights.append(
|
| 287 |
+
f'Top-{largest_k} joint ablation: SAE/random mean absolute effect ratio {float(largest_set["ratio"]):.2f}×; '
|
| 288 |
+
f'paired advantage {float(largest_set["paired_advantage"]):+.3f}, 95% CI '
|
| 289 |
+
f'[{float(largest_set["ci"][0]):+.3f}, {float(largest_set["ci"][1]):+.3f}], '
|
| 290 |
+
f'sign-flip p={float(largest_set["pvalue"]):.4f}.'
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
summary = {
|
| 294 |
'headline': headline,
|
| 295 |
'highlights': highlights,
|
|
|
|
| 302 |
'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
|
| 303 |
'mean_paraphrase_topk_jaccard': mean_jaccard,
|
| 304 |
'mean_paraphrase_sparse_cosine': mean_sparse_cos,
|
| 305 |
+
'single_feature_effect_metric': _effect_column(causal),
|
| 306 |
+
'mean_abs_sae_effect': sae_abs,
|
| 307 |
+
'mean_abs_random_effect': random_abs,
|
| 308 |
'causal_to_random_effect_ratio': ratio,
|
| 309 |
+
'paired_mean_abs_effect_advantage': float(single['paired_advantage']),
|
| 310 |
+
'paired_mean_abs_effect_advantage_bootstrap_ci_95': single['ci'],
|
| 311 |
+
'paired_sign_flip_pvalue': float(single['pvalue']),
|
| 312 |
'causal_prompt_feature_active_rate': active_rate,
|
| 313 |
'sae_top1_change_rate': top1_change,
|
| 314 |
+
'feature_set_results': {str(k): value for k, value in set_summary.items()},
|
| 315 |
},
|
| 316 |
}
|
| 317 |
(args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
|
|
|
|
| 336 |
'## Experimental design',
|
| 337 |
'',
|
| 338 |
'- Model: Qwen3-1.7B-Base.',
|
| 339 |
+
'- SAEs: Qwen-Scope residual-stream TopK SAEs at configured early/middle/late layers.',
|
| 340 |
'- Discovery set: controlled concept prompts with paired paraphrases.',
|
| 341 |
+
'- Split discipline: paraphrase groups stay entirely in train or held-out test.',
|
| 342 |
'- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
|
| 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 direction matched to each SAE perturbation L2 norm.',
|
| 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.',
|
| 350 |
'',
|
| 351 |
'## Figures',
|
| 352 |
'',
|
|
|
|
| 354 |
'',
|
| 355 |
'',
|
| 356 |
'',
|
| 357 |
+
'',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
]
|
| 359 |
+
if feature_sets is not None and not feature_sets.empty:
|
| 360 |
+
lines.extend(['', ''])
|
| 361 |
+
lines.extend(
|
| 362 |
+
[
|
| 363 |
+
'',
|
| 364 |
+
'## Interpretation guardrails',
|
| 365 |
+
'',
|
| 366 |
+
'A high feature/concept AUROC or high paraphrase overlap is correlational evidence only. Causal evidence requires a downstream change under intervention and is interpreted relative to a norm-matched random control. Feature-set effects are not assumed stronger a priori; they are separately measured. The narrative above is generated from saved metrics, with no hard-coded result values.',
|
| 367 |
+
'',
|
| 368 |
+
'## Reproducibility',
|
| 369 |
+
'',
|
| 370 |
+
'Run `python experiments/run_all.py` from the repository root. Raw activation matrices, splits, selected features, single-feature rows, feature-set rows, figures, and this report are materialized under `artifacts/`.',
|
| 371 |
+
'',
|
| 372 |
+
]
|
| 373 |
+
)
|
| 374 |
(args.artifact_dir / 'report.md').write_text('\n'.join(lines), encoding='utf-8')
|
| 375 |
print(headline)
|
| 376 |
print(f'Wrote {args.artifact_dir / "report.md"}')
|
experiments/run_all.py
CHANGED
|
@@ -19,6 +19,7 @@ def main() -> None:
|
|
| 19 |
run('collect_activations.py')
|
| 20 |
run('evaluate_features.py')
|
| 21 |
run('run_causal.py')
|
|
|
|
| 22 |
run('make_report.py')
|
| 23 |
print('\nFeatureLens experiment pipeline complete. See artifacts/report.md')
|
| 24 |
|
|
|
|
| 19 |
run('collect_activations.py')
|
| 20 |
run('evaluate_features.py')
|
| 21 |
run('run_causal.py')
|
| 22 |
+
run('run_feature_sets.py')
|
| 23 |
run('make_report.py')
|
| 24 |
print('\nFeatureLens experiment pipeline complete. See artifacts/report.md')
|
| 25 |
|
experiments/run_causal.py
CHANGED
|
@@ -11,7 +11,7 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
| 11 |
from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
|
| 12 |
from featurelens.config import SETTINGS
|
| 13 |
from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
|
| 14 |
-
from featurelens.metrics import js_divergence_from_logits,
|
| 15 |
from featurelens.sae import SAEStore
|
| 16 |
|
| 17 |
|
|
@@ -25,7 +25,6 @@ def parse_args() -> argparse.Namespace:
|
|
| 25 |
|
| 26 |
|
| 27 |
def load_selected_features(path: Path) -> dict[str, dict]:
|
| 28 |
-
rows = []
|
| 29 |
with path.open(newline='', encoding='utf-8') as handle:
|
| 30 |
rows = list(csv.DictReader(handle))
|
| 31 |
selected: dict[str, dict] = {}
|
|
@@ -54,6 +53,18 @@ def replace_hidden(output, hidden):
|
|
| 54 |
return (hidden, *output[1:]) if isinstance(output, tuple) else hidden
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
@torch.inference_mode()
|
| 58 |
def main() -> None:
|
| 59 |
args = parse_args()
|
|
@@ -91,8 +102,14 @@ def main() -> None:
|
|
| 91 |
layer = int(choice['layer'])
|
| 92 |
feature_id = int(choice['feature_id'])
|
| 93 |
sae = sae_store.get(layer)
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
capture: dict = {}
|
| 97 |
|
| 98 |
def capture_hook(_module, _inp, output):
|
|
@@ -100,87 +117,125 @@ def main() -> None:
|
|
| 100 |
capture['hidden'] = hidden_from_output(output).detach()
|
| 101 |
|
| 102 |
handle = model.model.layers[layer].register_forward_hook(capture_hook)
|
| 103 |
-
baseline_out = model(**
|
| 104 |
handle.remove()
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
encoding = sae.encode(residual)
|
| 108 |
original_activation = encoding.activation_for(feature_id)
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
target_id = int(target_ids[0])
|
| 114 |
-
baseline_prob = float(torch.softmax(baseline_logits.float(), dim=-1)[target_id].item())
|
| 115 |
-
baseline_rank = int((baseline_logits > baseline_logits[target_id]).sum().item()) + 1
|
| 116 |
-
baseline_top1 = int(torch.argmax(baseline_logits).item())
|
| 117 |
|
| 118 |
specs = [
|
| 119 |
('ablate', InterventionSpec('ablate', 0.0)),
|
| 120 |
('amplify_2x', InterventionSpec('scale', 2.0)),
|
| 121 |
]
|
| 122 |
-
|
|
|
|
| 123 |
delta = residual_delta(sae.decoder_direction(feature_id), original_activation, spec)
|
| 124 |
-
control_delta = normalized_random_control(
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
print(f"Causal task {task_idx + 1}/{len(tasks)}: {concept}", flush=True)
|
| 185 |
|
| 186 |
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 11 |
from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
|
| 12 |
from featurelens.config import SETTINGS
|
| 13 |
from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
|
| 14 |
+
from featurelens.metrics import js_divergence_from_logits, sequence_logprob_summary
|
| 15 |
from featurelens.sae import SAEStore
|
| 16 |
|
| 17 |
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def load_selected_features(path: Path) -> dict[str, dict]:
|
|
|
|
| 28 |
with path.open(newline='', encoding='utf-8') as handle:
|
| 29 |
rows = list(csv.DictReader(handle))
|
| 30 |
selected: dict[str, dict] = {}
|
|
|
|
| 53 |
return (hidden, *output[1:]) if isinstance(output, tuple) else hidden
|
| 54 |
|
| 55 |
|
| 56 |
+
def append_target(inputs: dict[str, torch.Tensor], target_ids: list[int]) -> dict[str, torch.Tensor]:
|
| 57 |
+
prompt_ids = inputs['input_ids']
|
| 58 |
+
target = torch.tensor(target_ids, dtype=prompt_ids.dtype, device=prompt_ids.device).unsqueeze(0)
|
| 59 |
+
full_ids = torch.cat([prompt_ids, target], dim=1)
|
| 60 |
+
attention = inputs.get('attention_mask', torch.ones_like(prompt_ids))
|
| 61 |
+
target_mask = torch.ones((1, len(target_ids)), dtype=attention.dtype, device=attention.device)
|
| 62 |
+
return {
|
| 63 |
+
'input_ids': full_ids,
|
| 64 |
+
'attention_mask': torch.cat([attention, target_mask], dim=1),
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
@torch.inference_mode()
|
| 69 |
def main() -> None:
|
| 70 |
args = parse_args()
|
|
|
|
| 102 |
layer = int(choice['layer'])
|
| 103 |
feature_id = int(choice['feature_id'])
|
| 104 |
sae = sae_store.get(layer)
|
| 105 |
+
prompt_inputs = tokenizer(task['prompt'], return_tensors='pt', truncation=True, max_length=192)
|
| 106 |
+
prompt_inputs = {key: value.to(device) for key, value in prompt_inputs.items()}
|
| 107 |
+
prompt_len = int(prompt_inputs['input_ids'].shape[1])
|
| 108 |
+
target_ids = tokenizer(task['target'], add_special_tokens=False)['input_ids']
|
| 109 |
+
if not target_ids:
|
| 110 |
+
raise RuntimeError(f"Target tokenization empty for task {task['id']}")
|
| 111 |
+
target_ids = [int(x) for x in target_ids]
|
| 112 |
+
full_inputs = append_target(prompt_inputs, target_ids)
|
| 113 |
capture: dict = {}
|
| 114 |
|
| 115 |
def capture_hook(_module, _inp, output):
|
|
|
|
| 117 |
capture['hidden'] = hidden_from_output(output).detach()
|
| 118 |
|
| 119 |
handle = model.model.layers[layer].register_forward_hook(capture_hook)
|
| 120 |
+
baseline_out = model(**full_inputs, use_cache=False)
|
| 121 |
handle.remove()
|
| 122 |
+
|
| 123 |
+
baseline_logits = baseline_out.logits[0]
|
| 124 |
+
baseline_next = baseline_logits[prompt_len - 1]
|
| 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 |
+
control_delta = normalized_random_control(
|
| 146 |
+
delta,
|
| 147 |
+
seed=args.seed + task_idx * 101 + spec_idx,
|
| 148 |
+
)
|
| 149 |
+
condition_meta.extend(
|
| 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 |
+
repeated = {key: value.repeat(len(condition_meta), 1) for key, value in full_inputs.items()}
|
| 169 |
+
deltas = torch.stack([item[3] for item in condition_meta], dim=0)
|
| 170 |
+
applied = {'done': False}
|
| 171 |
+
|
| 172 |
+
def batch_edit_hook(_module, _inp, output):
|
| 173 |
+
if applied['done']:
|
| 174 |
+
return output
|
| 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)
|
| 183 |
+
|
| 184 |
+
hook = model.model.layers[layer].register_forward_hook(batch_edit_hook)
|
| 185 |
+
edited_out = model(**repeated, use_cache=False)
|
| 186 |
+
hook.remove()
|
| 187 |
+
|
| 188 |
+
for row_idx, (intervention_name, condition, spec, applied_delta, delta_activation) in enumerate(condition_meta):
|
| 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())
|
| 192 |
+
modified_rank = int((modified_next > modified_next[target_id]).sum().item()) + 1
|
| 193 |
+
modified_top1 = int(torch.argmax(modified_next).item())
|
| 194 |
+
modified_seq, modified_mean, _ = sequence_logprob_summary(
|
| 195 |
+
modified_logits,
|
| 196 |
+
prompt_length=prompt_len,
|
| 197 |
+
target_ids=target_ids,
|
| 198 |
+
)
|
| 199 |
+
results.append(
|
| 200 |
+
{
|
| 201 |
+
'task_id': task['id'],
|
| 202 |
+
'concept': concept,
|
| 203 |
+
'prompt': task['prompt'],
|
| 204 |
+
'target_text': task['target'],
|
| 205 |
+
'target_first_token': tokenizer.decode([target_id]),
|
| 206 |
+
'target_token_count': len(target_ids),
|
| 207 |
+
'layer': layer,
|
| 208 |
+
'feature_id': feature_id,
|
| 209 |
+
'feature_train_auroc': choice['train_auroc'],
|
| 210 |
+
'feature_test_auroc': choice['test_auroc'],
|
| 211 |
+
'feature_test_f1': choice['test_f1'],
|
| 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 |
+
# First-token metrics retained for backwards compatibility and local diagnostics.
|
| 218 |
+
'baseline_target_prob': baseline_prob,
|
| 219 |
+
'modified_target_prob': modified_prob,
|
| 220 |
+
'target_prob_delta': modified_prob - baseline_prob,
|
| 221 |
+
'target_logprob_delta': float(
|
| 222 |
+
torch.log_softmax(modified_next.float(), dim=-1)[target_id].item()
|
| 223 |
+
- torch.log_softmax(baseline_next.float(), dim=-1)[target_id].item()
|
| 224 |
+
),
|
| 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,
|
| 232 |
+
'baseline_target_mean_logprob': baseline_mean,
|
| 233 |
+
'modified_target_mean_logprob': modified_mean,
|
| 234 |
+
'target_mean_logprob_delta': modified_mean - baseline_mean,
|
| 235 |
+
'js_divergence': js_divergence_from_logits(baseline_next, modified_next),
|
| 236 |
+
'top1_changed': int(modified_top1 != baseline_top1),
|
| 237 |
+
}
|
| 238 |
+
)
|
| 239 |
print(f"Causal task {task_idx + 1}/{len(tasks)}: {concept}", flush=True)
|
| 240 |
|
| 241 |
args.output.parent.mkdir(parents=True, exist_ok=True)
|
experiments/run_feature_sets.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 9 |
+
|
| 10 |
+
from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
|
| 11 |
+
from featurelens.config import SETTINGS
|
| 12 |
+
from featurelens.interventions import InterventionSpec, joint_residual_delta, normalized_random_control
|
| 13 |
+
from featurelens.metrics import js_divergence_from_logits, sequence_logprob_summary
|
| 14 |
+
from featurelens.sae import SAEStore
|
| 15 |
+
from featurelens.selection import load_feature_sets
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_args() -> argparse.Namespace:
|
| 19 |
+
parser = argparse.ArgumentParser(description='Run top-k joint SAE feature-set ablations.')
|
| 20 |
+
parser.add_argument('--tasks', type=Path, default=DATA_DIR / 'causal_tasks.jsonl')
|
| 21 |
+
parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
|
| 22 |
+
parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / '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 |
+
|
| 28 |
+
def hidden_from_output(output):
|
| 29 |
+
return output[0] if isinstance(output, tuple) else output
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def replace_hidden(output, hidden):
|
| 33 |
+
return (hidden, *output[1:]) if isinstance(output, tuple) else hidden
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def append_target(inputs: dict[str, torch.Tensor], target_ids: list[int]) -> dict[str, torch.Tensor]:
|
| 37 |
+
prompt_ids = inputs['input_ids']
|
| 38 |
+
target = torch.tensor(target_ids, dtype=prompt_ids.dtype, device=prompt_ids.device).unsqueeze(0)
|
| 39 |
+
attention = inputs.get('attention_mask', torch.ones_like(prompt_ids))
|
| 40 |
+
target_mask = torch.ones((1, len(target_ids)), dtype=attention.dtype, device=attention.device)
|
| 41 |
+
return {
|
| 42 |
+
'input_ids': torch.cat([prompt_ids, target], dim=1),
|
| 43 |
+
'attention_mask': torch.cat([attention, target_mask], dim=1),
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@torch.inference_mode()
|
| 49 |
+
def main() -> None:
|
| 50 |
+
args = parse_args()
|
| 51 |
+
set_seed(args.seed)
|
| 52 |
+
sizes = sorted({int(size) for size in args.sizes if int(size) > 0})
|
| 53 |
+
if not sizes:
|
| 54 |
+
raise ValueError('At least one positive feature-set size is required.')
|
| 55 |
+
|
| 56 |
+
tasks = load_jsonl(args.tasks)
|
| 57 |
+
selected = load_feature_sets(args.catalog, max(sizes))
|
| 58 |
+
missing = sorted({task['concept'] for task in tasks}.difference(selected))
|
| 59 |
+
if missing:
|
| 60 |
+
raise RuntimeError(f'No feature sets for concepts: {missing}')
|
| 61 |
+
|
| 62 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 63 |
+
model_dtype = torch.float16 if device.type == 'cuda' else torch.float32
|
| 64 |
+
tokenizer = AutoTokenizer.from_pretrained(SETTINGS.model_id)
|
| 65 |
+
if tokenizer.pad_token_id is None:
|
| 66 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 67 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 68 |
+
SETTINGS.model_id,
|
| 69 |
+
torch_dtype=model_dtype,
|
| 70 |
+
low_cpu_mem_usage=True,
|
| 71 |
+
).to(device)
|
| 72 |
+
model.eval()
|
| 73 |
+
layers = sorted({int(item['layer']) for item in selected.values()})
|
| 74 |
+
sae_store = SAEStore(
|
| 75 |
+
SETTINGS.sae_repo_id,
|
| 76 |
+
layers=layers,
|
| 77 |
+
device=device,
|
| 78 |
+
dtype=torch.float32,
|
| 79 |
+
top_k=SETTINGS.sae_top_k,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
results: list[dict] = []
|
| 83 |
+
for task_idx, task in enumerate(tasks):
|
| 84 |
+
concept = task['concept']
|
| 85 |
+
layer = int(selected[concept]['layer'])
|
| 86 |
+
candidate_ids = [int(x) for x in selected[concept]['feature_ids']]
|
| 87 |
+
sae = sae_store.get(layer)
|
| 88 |
+
prompt_inputs = tokenizer(task['prompt'], return_tensors='pt', truncation=True, max_length=192)
|
| 89 |
+
prompt_inputs = {key: value.to(device) for key, value in prompt_inputs.items()}
|
| 90 |
+
prompt_len = int(prompt_inputs['input_ids'].shape[1])
|
| 91 |
+
target_ids = tokenizer(task['target'], add_special_tokens=False)['input_ids']
|
| 92 |
+
if not target_ids:
|
| 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):
|
| 100 |
+
if 'hidden' not in capture:
|
| 101 |
+
capture['hidden'] = hidden_from_output(output).detach()
|
| 102 |
+
|
| 103 |
+
handle = model.model.layers[layer].register_forward_hook(capture_hook)
|
| 104 |
+
baseline_out = model(**full_inputs, use_cache=False)
|
| 105 |
+
handle.remove()
|
| 106 |
+
baseline_logits = baseline_out.logits[0]
|
| 107 |
+
baseline_next = baseline_logits[prompt_len - 1]
|
| 108 |
+
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
|
| 109 |
+
baseline_logits,
|
| 110 |
+
prompt_length=prompt_len,
|
| 111 |
+
target_ids=target_ids,
|
| 112 |
+
)
|
| 113 |
+
residual = capture['hidden'][0, prompt_len - 1]
|
| 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]
|
| 121 |
+
directions = torch.stack([sae.decoder_direction(feature_id) for feature_id in feature_ids])
|
| 122 |
+
delta, _ = joint_residual_delta(
|
| 123 |
+
directions,
|
| 124 |
+
activations,
|
| 125 |
+
InterventionSpec('ablate', 0.0),
|
| 126 |
+
)
|
| 127 |
+
control = normalized_random_control(
|
| 128 |
+
delta,
|
| 129 |
+
seed=args.seed + task_idx * 101 + size,
|
| 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 |
+
repeated = {key: value.repeat(len(condition_meta), 1) for key, value in full_inputs.items()}
|
| 139 |
+
deltas = torch.stack([item[3] for item in condition_meta])
|
| 140 |
+
applied = {'done': False}
|
| 141 |
+
|
| 142 |
+
def edit_hook(_module, _inp, output):
|
| 143 |
+
if applied['done']:
|
| 144 |
+
return output
|
| 145 |
+
hidden = hidden_from_output(output)
|
| 146 |
+
modified = hidden.clone()
|
| 147 |
+
modified[:, prompt_len - 1, :] = (
|
| 148 |
+
modified[:, prompt_len - 1, :] + deltas.to(hidden.device, hidden.dtype)
|
| 149 |
+
)
|
| 150 |
+
applied['done'] = True
|
| 151 |
+
return replace_hidden(output, modified)
|
| 152 |
+
|
| 153 |
+
hook = model.model.layers[layer].register_forward_hook(edit_hook)
|
| 154 |
+
edited_out = model(**repeated, use_cache=False)
|
| 155 |
+
hook.remove()
|
| 156 |
+
|
| 157 |
+
for row_idx, (size, condition, feature_ids, applied_delta) in enumerate(condition_meta):
|
| 158 |
+
logits = edited_out.logits[row_idx]
|
| 159 |
+
seq_logp, mean_logp, _ = sequence_logprob_summary(
|
| 160 |
+
logits,
|
| 161 |
+
prompt_length=prompt_len,
|
| 162 |
+
target_ids=target_ids,
|
| 163 |
+
)
|
| 164 |
+
active_count = sum(encoding.activation_for(feature_id) > 0 for feature_id in feature_ids)
|
| 165 |
+
results.append(
|
| 166 |
+
{
|
| 167 |
+
'task_id': task['id'],
|
| 168 |
+
'concept': concept,
|
| 169 |
+
'prompt': task['prompt'],
|
| 170 |
+
'target_text': task['target'],
|
| 171 |
+
'target_token_count': len(target_ids),
|
| 172 |
+
'layer': layer,
|
| 173 |
+
'set_size': int(size),
|
| 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,
|
| 181 |
+
'baseline_target_mean_logprob': baseline_mean,
|
| 182 |
+
'modified_target_mean_logprob': mean_logp,
|
| 183 |
+
'target_mean_logprob_delta': mean_logp - baseline_mean,
|
| 184 |
+
'js_divergence': js_divergence_from_logits(
|
| 185 |
+
baseline_next,
|
| 186 |
+
logits[prompt_len - 1],
|
| 187 |
+
),
|
| 188 |
+
}
|
| 189 |
+
)
|
| 190 |
+
print(f"Feature-set task {task_idx + 1}/{len(tasks)}: {concept}", flush=True)
|
| 191 |
+
|
| 192 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 193 |
+
with args.output.open('w', newline='', encoding='utf-8') as handle:
|
| 194 |
+
writer = csv.DictWriter(handle, fieldnames=list(results[0].keys()))
|
| 195 |
+
writer.writeheader()
|
| 196 |
+
writer.writerows(results)
|
| 197 |
+
print(f'Wrote {len(results)} feature-set rows to {args.output}')
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
if __name__ == '__main__':
|
| 201 |
+
main()
|
featurelens/interventions.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from dataclasses import dataclass
|
|
|
|
| 4 |
|
| 5 |
import torch
|
| 6 |
|
|
@@ -32,6 +33,47 @@ def residual_delta(
|
|
| 32 |
return decoder_direction * spec.delta_activation(original_activation)
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def normalized_random_control(delta: torch.Tensor, seed: int) -> torch.Tensor:
|
| 36 |
"""Generate a deterministic random residual perturbation with identical L2 norm."""
|
| 37 |
norm = torch.linalg.vector_norm(delta.float())
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from dataclasses import dataclass
|
| 4 |
+
from collections.abc import Sequence
|
| 5 |
|
| 6 |
import torch
|
| 7 |
|
|
|
|
| 33 |
return decoder_direction * spec.delta_activation(original_activation)
|
| 34 |
|
| 35 |
|
| 36 |
+
def joint_residual_delta(
|
| 37 |
+
decoder_directions: torch.Tensor,
|
| 38 |
+
original_activations: Sequence[float] | torch.Tensor,
|
| 39 |
+
spec: InterventionSpec,
|
| 40 |
+
) -> tuple[torch.Tensor, list[float]]:
|
| 41 |
+
"""
|
| 42 |
+
Sum reconstruction-preserving deltas for a set of SAE features.
|
| 43 |
+
|
| 44 |
+
``decoder_directions`` must have shape ``[n_features, d_model]``. The same
|
| 45 |
+
ablation/scale intervention is applied to every selected feature. ``inject``
|
| 46 |
+
is intentionally rejected for feature sets because a shared additive
|
| 47 |
+
coefficient has ambiguous semantics across unrelated decoder directions.
|
| 48 |
+
"""
|
| 49 |
+
mode = spec.mode.lower().strip()
|
| 50 |
+
if mode not in {'ablate', 'scale'}:
|
| 51 |
+
raise ValueError("Feature-set interventions support only 'ablate' or 'scale'.")
|
| 52 |
+
|
| 53 |
+
directions = decoder_directions
|
| 54 |
+
if directions.ndim != 2:
|
| 55 |
+
raise ValueError('decoder_directions must have shape [n_features, d_model].')
|
| 56 |
+
|
| 57 |
+
if isinstance(original_activations, torch.Tensor):
|
| 58 |
+
activations = original_activations.detach().float().reshape(-1).tolist()
|
| 59 |
+
else:
|
| 60 |
+
activations = [float(x) for x in original_activations]
|
| 61 |
+
|
| 62 |
+
if len(activations) != directions.shape[0]:
|
| 63 |
+
raise ValueError('Number of activations must match decoder directions.')
|
| 64 |
+
if not activations:
|
| 65 |
+
raise ValueError('Select at least one feature for a feature-set intervention.')
|
| 66 |
+
|
| 67 |
+
coefficient_deltas = [spec.delta_activation(value) for value in activations]
|
| 68 |
+
coeff = torch.tensor(
|
| 69 |
+
coefficient_deltas,
|
| 70 |
+
device=directions.device,
|
| 71 |
+
dtype=directions.dtype,
|
| 72 |
+
)
|
| 73 |
+
delta = torch.sum(directions * coeff[:, None], dim=0)
|
| 74 |
+
return delta, coefficient_deltas
|
| 75 |
+
|
| 76 |
+
|
| 77 |
def normalized_random_control(delta: torch.Tensor, seed: int) -> torch.Tensor:
|
| 78 |
"""Generate a deterministic random residual perturbation with identical L2 norm."""
|
| 79 |
norm = torch.linalg.vector_norm(delta.float())
|
featurelens/metrics.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import math
|
|
|
|
| 4 |
|
| 5 |
import numpy as np
|
| 6 |
import torch
|
|
@@ -34,10 +35,85 @@ def safe_log_probability(probability: float) -> float:
|
|
| 34 |
return math.log(max(float(probability), 1e-12))
|
| 35 |
|
| 36 |
|
| 37 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
a = set(indices_a[np.asarray(values_a) > 0].tolist())
|
| 39 |
b = set(indices_b[np.asarray(values_b) > 0].tolist())
|
| 40 |
union = a | b
|
| 41 |
if not union:
|
| 42 |
return 1.0
|
| 43 |
return len(a & b) / len(union)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import math
|
| 4 |
+
from collections.abc import Sequence
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
import torch
|
|
|
|
| 35 |
return math.log(max(float(probability), 1e-12))
|
| 36 |
|
| 37 |
|
| 38 |
+
def target_token_logprobs(
|
| 39 |
+
logits: torch.Tensor,
|
| 40 |
+
*,
|
| 41 |
+
prompt_length: int,
|
| 42 |
+
target_ids: Sequence[int] | torch.Tensor,
|
| 43 |
+
) -> torch.Tensor:
|
| 44 |
+
"""
|
| 45 |
+
Return teacher-forced log probabilities for an exact target continuation.
|
| 46 |
+
|
| 47 |
+
``logits`` must be ``[sequence, vocab]`` for the concatenated prompt + target
|
| 48 |
+
sequence. The token at target position ``j`` is predicted by the logit row
|
| 49 |
+
immediately before that token.
|
| 50 |
+
"""
|
| 51 |
+
if logits.ndim != 2:
|
| 52 |
+
raise ValueError('logits must have shape [sequence, vocab].')
|
| 53 |
+
ids = torch.as_tensor(target_ids, device=logits.device, dtype=torch.long).reshape(-1)
|
| 54 |
+
if ids.numel() == 0:
|
| 55 |
+
raise ValueError('target_ids must contain at least one token.')
|
| 56 |
+
start = int(prompt_length) - 1
|
| 57 |
+
stop = start + int(ids.numel())
|
| 58 |
+
if start < 0 or stop > logits.shape[0]:
|
| 59 |
+
raise ValueError('Prompt/target lengths are incompatible with logits sequence length.')
|
| 60 |
+
rows = logits[start:stop].float()
|
| 61 |
+
return torch.log_softmax(rows, dim=-1).gather(1, ids[:, None]).squeeze(1)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def sequence_logprob_summary(
|
| 65 |
+
logits: torch.Tensor,
|
| 66 |
+
*,
|
| 67 |
+
prompt_length: int,
|
| 68 |
+
target_ids: Sequence[int] | torch.Tensor,
|
| 69 |
+
) -> tuple[float, float, list[float]]:
|
| 70 |
+
"""Return total log p, mean log p/token, and token-level log probabilities."""
|
| 71 |
+
token_values = target_token_logprobs(
|
| 72 |
+
logits,
|
| 73 |
+
prompt_length=prompt_length,
|
| 74 |
+
target_ids=target_ids,
|
| 75 |
+
)
|
| 76 |
+
total = float(token_values.sum().item())
|
| 77 |
+
mean = float(token_values.mean().item())
|
| 78 |
+
return total, mean, [float(x) for x in token_values.detach().cpu().tolist()]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def sparse_jaccard(
|
| 82 |
+
indices_a: np.ndarray,
|
| 83 |
+
values_a: np.ndarray,
|
| 84 |
+
indices_b: np.ndarray,
|
| 85 |
+
values_b: np.ndarray,
|
| 86 |
+
) -> float:
|
| 87 |
a = set(indices_a[np.asarray(values_a) > 0].tolist())
|
| 88 |
b = set(indices_b[np.asarray(values_b) > 0].tolist())
|
| 89 |
union = a | b
|
| 90 |
if not union:
|
| 91 |
return 1.0
|
| 92 |
return len(a & b) / len(union)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def sparse_topk_cosine(
|
| 96 |
+
indices_a: Sequence[int] | torch.Tensor,
|
| 97 |
+
values_a: Sequence[float] | torch.Tensor,
|
| 98 |
+
indices_b: Sequence[int] | torch.Tensor,
|
| 99 |
+
values_b: Sequence[float] | torch.Tensor,
|
| 100 |
+
) -> float:
|
| 101 |
+
"""Cosine similarity between two sparse TopK vectors without densifying SAE width."""
|
| 102 |
+
idx_a = torch.as_tensor(indices_a, dtype=torch.long).reshape(-1).cpu().tolist()
|
| 103 |
+
val_a = torch.as_tensor(values_a, dtype=torch.float64).reshape(-1).cpu().tolist()
|
| 104 |
+
idx_b = torch.as_tensor(indices_b, dtype=torch.long).reshape(-1).cpu().tolist()
|
| 105 |
+
val_b = torch.as_tensor(values_b, dtype=torch.float64).reshape(-1).cpu().tolist()
|
| 106 |
+
|
| 107 |
+
a = {int(i): float(v) for i, v in zip(idx_a, val_a, strict=True) if float(v) > 0}
|
| 108 |
+
b = {int(i): float(v) for i, v in zip(idx_b, val_b, strict=True) if float(v) > 0}
|
| 109 |
+
if not a and not b:
|
| 110 |
+
return 1.0
|
| 111 |
+
if not a or not b:
|
| 112 |
+
return 0.0
|
| 113 |
+
|
| 114 |
+
dot = sum(value * b.get(feature_id, 0.0) for feature_id, value in a.items())
|
| 115 |
+
norm_a = math.sqrt(sum(value * value for value in a.values()))
|
| 116 |
+
norm_b = math.sqrt(sum(value * value for value in b.values()))
|
| 117 |
+
if norm_a == 0.0 or norm_b == 0.0:
|
| 118 |
+
return 0.0
|
| 119 |
+
return float(dot / (norm_a * norm_b))
|
featurelens/runtime.py
CHANGED
|
@@ -15,10 +15,16 @@ from .catalog import FeatureCatalog
|
|
| 15 |
from .config import SETTINGS, Settings
|
| 16 |
from .interventions import (
|
| 17 |
InterventionSpec,
|
|
|
|
| 18 |
normalized_random_control,
|
| 19 |
residual_delta,
|
| 20 |
)
|
| 21 |
-
from .metrics import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
from .sae import SAEStore, SparseEncoding
|
| 23 |
|
| 24 |
|
|
@@ -64,14 +70,20 @@ class InterventionResult:
|
|
| 64 |
random_js_divergence: float
|
| 65 |
js_specificity_ratio: float
|
| 66 |
target_text: str
|
| 67 |
-
target_token: str
|
| 68 |
target_token_count: int
|
|
|
|
| 69 |
baseline_target_prob: float | None
|
| 70 |
modified_target_prob: float | None
|
| 71 |
random_target_prob: float | None
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
target_specificity_ratio: float | None
|
|
|
|
| 75 |
top_token_rows: list[list[object]]
|
| 76 |
|
| 77 |
|
|
@@ -85,9 +97,48 @@ class LayerSweepResult:
|
|
| 85 |
@dataclass
|
| 86 |
class DoseResponseResult:
|
| 87 |
feature_activation: float
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
rows: list[list[object]]
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
class FeatureLensRuntime:
|
|
@@ -153,6 +204,40 @@ class FeatureLensRuntime:
|
|
| 153 |
)
|
| 154 |
return {key: value.to(self.device) for key, value in batch.items()}
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
@staticmethod
|
| 157 |
def _hidden_from_output(output):
|
| 158 |
return output[0] if isinstance(output, tuple) else output
|
|
@@ -209,11 +294,7 @@ class FeatureLensRuntime:
|
|
| 209 |
if hidden.ndim != 3:
|
| 210 |
return output
|
| 211 |
seq_len = hidden.shape[1]
|
| 212 |
-
idx = int(token_index)
|
| 213 |
-
if idx < 0:
|
| 214 |
-
idx = seq_len + idx
|
| 215 |
-
if idx < 0 or idx >= seq_len:
|
| 216 |
-
raise IndexError(f'Token index {token_index} outside prompt length {seq_len}.')
|
| 217 |
modified = hidden.clone()
|
| 218 |
modified[:, idx, :] = modified[:, idx, :] + delta.to(hidden.device, hidden.dtype)
|
| 219 |
applied['done'] = True
|
|
@@ -225,6 +306,39 @@ class FeatureLensRuntime:
|
|
| 225 |
finally:
|
| 226 |
handle.remove()
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
@staticmethod
|
| 229 |
def _resolve_index(token_index: int, seq_len: int) -> int:
|
| 230 |
idx = int(token_index)
|
|
@@ -235,10 +349,22 @@ class FeatureLensRuntime:
|
|
| 235 |
return idx
|
| 236 |
|
| 237 |
@staticmethod
|
| 238 |
-
def _control_seed(text: str, layer: int,
|
| 239 |
-
payload = f'{text}\0{layer}\0{
|
| 240 |
return int.from_bytes(hashlib.sha256(payload).digest()[:4], 'big', signed=False)
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
@torch.inference_mode()
|
| 243 |
def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
|
| 244 |
self.ensure_ready(preload_saes=False)
|
|
@@ -306,7 +432,9 @@ class FeatureLensRuntime:
|
|
| 306 |
entropy = 0.0
|
| 307 |
else:
|
| 308 |
probs = positive / total
|
| 309 |
-
entropy = float(
|
|
|
|
|
|
|
| 310 |
top5_fraction = (
|
| 311 |
float(values[: min(5, values.numel())].sum().item() / total.item())
|
| 312 |
if float(total.item()) > 0
|
|
@@ -350,6 +478,37 @@ class FeatureLensRuntime:
|
|
| 350 |
rows.sort(key=lambda row: max(row[1], row[2]), reverse=True)
|
| 351 |
return rows[: min(len(rows), 12)]
|
| 352 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
@torch.inference_mode()
|
| 354 |
def intervene(
|
| 355 |
self,
|
|
@@ -364,70 +523,130 @@ class FeatureLensRuntime:
|
|
| 364 |
) -> InterventionResult:
|
| 365 |
self.ensure_ready(preload_saes=False)
|
| 366 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 367 |
-
|
| 368 |
-
prompt_len = int(
|
| 369 |
idx = self._resolve_index(int(token_index), prompt_len)
|
| 370 |
-
|
| 371 |
sae = self.sae_store.get(int(layer))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
capture: dict = {}
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
residual = capture['hidden'][0, idx]
|
| 383 |
encoding = sae.encode(residual)
|
| 384 |
original_activation = encoding.activation_for(int(feature_id))
|
| 385 |
spec = InterventionSpec(mode=mode, coefficient=float(coefficient))
|
| 386 |
delta = residual_delta(sae.decoder_direction(int(feature_id)), original_activation, spec)
|
| 387 |
-
|
| 388 |
-
with self._delta_hook(int(layer), idx, delta):
|
| 389 |
-
modified = self.model.generate(**inputs, **generation_kwargs)
|
| 390 |
-
|
| 391 |
-
# Live negative control: one extra forward pass, not another full generation.
|
| 392 |
control_delta = normalized_random_control(
|
| 393 |
delta,
|
| 394 |
-
seed=self._control_seed(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
)
|
| 396 |
-
with self._delta_hook(int(layer), idx, control_delta):
|
| 397 |
-
random_out = self.model(**inputs, use_cache=False)
|
| 398 |
-
random_logits = random_out.logits[0, -1]
|
| 399 |
|
| 400 |
-
|
| 401 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
|
| 403 |
modified_text = self.tokenizer.decode(modified_ids, skip_special_tokens=True)
|
| 404 |
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
modified_logits = modified.scores[0][0]
|
| 409 |
-
js = js_divergence_from_logits(baseline_logits, modified_logits)
|
| 410 |
-
random_js = js_divergence_from_logits(baseline_logits, random_logits)
|
| 411 |
js_ratio = abs(js) / max(abs(random_js), 1e-12)
|
| 412 |
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
|
| 432 |
return InterventionResult(
|
| 433 |
baseline_text=baseline_text,
|
|
@@ -439,16 +658,25 @@ class FeatureLensRuntime:
|
|
| 439 |
random_js_divergence=float(random_js),
|
| 440 |
js_specificity_ratio=float(js_ratio),
|
| 441 |
target_text=target_text,
|
| 442 |
-
|
| 443 |
-
|
| 444 |
baseline_target_prob=bp,
|
| 445 |
modified_target_prob=mp,
|
| 446 |
random_target_prob=rp,
|
| 447 |
-
|
| 448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
target_specificity_ratio=specificity,
|
|
|
|
| 450 |
top_token_rows=self._top_token_rows(
|
| 451 |
-
self.tokenizer,
|
|
|
|
|
|
|
|
|
|
| 452 |
),
|
| 453 |
)
|
| 454 |
|
|
@@ -466,55 +694,368 @@ class FeatureLensRuntime:
|
|
| 466 |
raise ValueError('Dose-response requires a target continuation.')
|
| 467 |
self.ensure_ready(preload_saes=False)
|
| 468 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 469 |
-
|
| 470 |
-
prompt_len = int(
|
| 471 |
idx = self._resolve_index(int(token_index), prompt_len)
|
|
|
|
|
|
|
| 472 |
sae = self.sae_store.get(int(layer))
|
| 473 |
|
| 474 |
capture: dict = {}
|
| 475 |
with self._capture_hook(int(layer), capture):
|
| 476 |
-
baseline_out = self.model(**
|
| 477 |
-
baseline_logits = baseline_out.logits[0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
residual = capture['hidden'][0, idx]
|
| 479 |
encoding = sae.encode(residual)
|
| 480 |
original_activation = encoding.activation_for(int(feature_id))
|
| 481 |
direction = sae.decoder_direction(int(feature_id))
|
| 482 |
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
target_id = int(target_ids[0])
|
| 487 |
-
target_token = self.tokenizer.decode([target_id])
|
| 488 |
-
baseline_prob = float(torch.softmax(baseline_logits.float(), dim=-1)[target_id].item())
|
| 489 |
-
|
| 490 |
-
rows: list[list[object]] = []
|
| 491 |
for multiplier in multipliers:
|
| 492 |
spec = InterventionSpec('scale', float(multiplier))
|
| 493 |
delta = residual_delta(direction, original_activation, spec)
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
rows.append(
|
| 503 |
[
|
| 504 |
float(multiplier),
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
float(
|
| 510 |
-
float(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
]
|
| 512 |
)
|
| 513 |
return DoseResponseResult(
|
| 514 |
feature_activation=float(original_activation),
|
| 515 |
-
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
rows=rows,
|
|
|
|
| 518 |
)
|
| 519 |
|
| 520 |
|
|
|
|
| 15 |
from .config import SETTINGS, Settings
|
| 16 |
from .interventions import (
|
| 17 |
InterventionSpec,
|
| 18 |
+
joint_residual_delta,
|
| 19 |
normalized_random_control,
|
| 20 |
residual_delta,
|
| 21 |
)
|
| 22 |
+
from .metrics import (
|
| 23 |
+
js_divergence_from_logits,
|
| 24 |
+
reconstruction_metrics,
|
| 25 |
+
sequence_logprob_summary,
|
| 26 |
+
sparse_topk_cosine,
|
| 27 |
+
)
|
| 28 |
from .sae import SAEStore, SparseEncoding
|
| 29 |
|
| 30 |
|
|
|
|
| 70 |
random_js_divergence: float
|
| 71 |
js_specificity_ratio: float
|
| 72 |
target_text: str
|
|
|
|
| 73 |
target_token_count: int
|
| 74 |
+
target_tokens: list[str]
|
| 75 |
baseline_target_prob: float | None
|
| 76 |
modified_target_prob: float | None
|
| 77 |
random_target_prob: float | None
|
| 78 |
+
baseline_sequence_logprob: float | None
|
| 79 |
+
modified_sequence_logprob: float | None
|
| 80 |
+
random_sequence_logprob: float | None
|
| 81 |
+
sequence_logprob_delta: float | None
|
| 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 |
|
| 89 |
|
|
|
|
| 97 |
@dataclass
|
| 98 |
class DoseResponseResult:
|
| 99 |
feature_activation: float
|
| 100 |
+
target_tokens: list[str]
|
| 101 |
+
rows: list[list[object]]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@dataclass
|
| 105 |
+
class FeatureSetResult:
|
| 106 |
+
feature_ids: list[int]
|
| 107 |
+
feature_rows: list[list[object]]
|
| 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
|
| 115 |
+
sequence_logprob_delta: float
|
| 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 |
+
|
| 123 |
+
|
| 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]
|
| 133 |
+
token_index_a: int
|
| 134 |
+
tokens_b: list[str]
|
| 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]]
|
| 141 |
+
chart_rows: list[list[object]]
|
| 142 |
|
| 143 |
|
| 144 |
class FeatureLensRuntime:
|
|
|
|
| 204 |
)
|
| 205 |
return {key: value.to(self.device) for key, value in batch.items()}
|
| 206 |
|
| 207 |
+
def _target_ids(self, target_text: str) -> list[int]:
|
| 208 |
+
assert self.tokenizer is not None
|
| 209 |
+
ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
|
| 210 |
+
if not ids:
|
| 211 |
+
raise ValueError('Target continuation tokenized to an empty sequence.')
|
| 212 |
+
return [int(x) for x in ids]
|
| 213 |
+
|
| 214 |
+
def _append_target(
|
| 215 |
+
self,
|
| 216 |
+
prompt_inputs: dict[str, torch.Tensor],
|
| 217 |
+
target_ids: Sequence[int],
|
| 218 |
+
) -> dict[str, torch.Tensor]:
|
| 219 |
+
prompt_ids = prompt_inputs['input_ids']
|
| 220 |
+
target = torch.tensor(
|
| 221 |
+
list(target_ids),
|
| 222 |
+
dtype=prompt_ids.dtype,
|
| 223 |
+
device=prompt_ids.device,
|
| 224 |
+
).unsqueeze(0)
|
| 225 |
+
full_ids = torch.cat([prompt_ids, target], dim=1)
|
| 226 |
+
if 'attention_mask' in prompt_inputs:
|
| 227 |
+
target_mask = torch.ones(
|
| 228 |
+
(prompt_ids.shape[0], len(target_ids)),
|
| 229 |
+
dtype=prompt_inputs['attention_mask'].dtype,
|
| 230 |
+
device=prompt_ids.device,
|
| 231 |
+
)
|
| 232 |
+
attention = torch.cat([prompt_inputs['attention_mask'], target_mask], dim=1)
|
| 233 |
+
else:
|
| 234 |
+
attention = torch.ones_like(full_ids)
|
| 235 |
+
return {'input_ids': full_ids, 'attention_mask': attention}
|
| 236 |
+
|
| 237 |
+
@staticmethod
|
| 238 |
+
def _repeat_inputs(inputs: dict[str, torch.Tensor], repeats: int) -> dict[str, torch.Tensor]:
|
| 239 |
+
return {key: value.repeat(int(repeats), 1) for key, value in inputs.items()}
|
| 240 |
+
|
| 241 |
@staticmethod
|
| 242 |
def _hidden_from_output(output):
|
| 243 |
return output[0] if isinstance(output, tuple) else output
|
|
|
|
| 294 |
if hidden.ndim != 3:
|
| 295 |
return output
|
| 296 |
seq_len = hidden.shape[1]
|
| 297 |
+
idx = self._resolve_index(int(token_index), seq_len)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
modified = hidden.clone()
|
| 299 |
modified[:, idx, :] = modified[:, idx, :] + delta.to(hidden.device, hidden.dtype)
|
| 300 |
applied['done'] = True
|
|
|
|
| 306 |
finally:
|
| 307 |
handle.remove()
|
| 308 |
|
| 309 |
+
@contextmanager
|
| 310 |
+
def _batch_delta_hook(
|
| 311 |
+
self,
|
| 312 |
+
layer: int,
|
| 313 |
+
token_index: int,
|
| 314 |
+
deltas: torch.Tensor,
|
| 315 |
+
) -> Iterator[None]:
|
| 316 |
+
"""Apply one residual delta per batch row in a single model forward."""
|
| 317 |
+
assert self.model is not None
|
| 318 |
+
if deltas.ndim != 2:
|
| 319 |
+
raise ValueError('deltas must have shape [batch, d_model].')
|
| 320 |
+
applied = {'done': False}
|
| 321 |
+
|
| 322 |
+
def hook(_module, _inputs, output):
|
| 323 |
+
if applied['done']:
|
| 324 |
+
return output
|
| 325 |
+
hidden = self._hidden_from_output(output)
|
| 326 |
+
if hidden.ndim != 3:
|
| 327 |
+
return output
|
| 328 |
+
if hidden.shape[0] != deltas.shape[0]:
|
| 329 |
+
raise ValueError('Delta batch size does not match model batch size.')
|
| 330 |
+
idx = self._resolve_index(int(token_index), hidden.shape[1])
|
| 331 |
+
modified = hidden.clone()
|
| 332 |
+
modified[:, idx, :] = modified[:, idx, :] + deltas.to(hidden.device, hidden.dtype)
|
| 333 |
+
applied['done'] = True
|
| 334 |
+
return self._replace_hidden_in_output(output, modified)
|
| 335 |
+
|
| 336 |
+
handle = self.model.model.layers[int(layer)].register_forward_hook(hook)
|
| 337 |
+
try:
|
| 338 |
+
yield
|
| 339 |
+
finally:
|
| 340 |
+
handle.remove()
|
| 341 |
+
|
| 342 |
@staticmethod
|
| 343 |
def _resolve_index(token_index: int, seq_len: int) -> int:
|
| 344 |
idx = int(token_index)
|
|
|
|
| 349 |
return idx
|
| 350 |
|
| 351 |
@staticmethod
|
| 352 |
+
def _control_seed(text: str, layer: int, key: str, mode: str, coefficient: float) -> int:
|
| 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 {
|
| 359 |
+
int(feature_id): float(value)
|
| 360 |
+
for feature_id, value in zip(
|
| 361 |
+
encoding.indices.detach().cpu().tolist(),
|
| 362 |
+
encoding.values.detach().float().cpu().tolist(),
|
| 363 |
+
strict=True,
|
| 364 |
+
)
|
| 365 |
+
if float(value) > 0
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
@torch.inference_mode()
|
| 369 |
def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
|
| 370 |
self.ensure_ready(preload_saes=False)
|
|
|
|
| 432 |
entropy = 0.0
|
| 433 |
else:
|
| 434 |
probs = positive / total
|
| 435 |
+
entropy = float(
|
| 436 |
+
(-(probs * torch.log(probs)).sum() / math.log(positive.numel())).item()
|
| 437 |
+
)
|
| 438 |
top5_fraction = (
|
| 439 |
float(values[: min(5, values.numel())].sum().item() / total.item())
|
| 440 |
if float(total.item()) > 0
|
|
|
|
| 478 |
rows.sort(key=lambda row: max(row[1], row[2]), reverse=True)
|
| 479 |
return rows[: min(len(rows), 12)]
|
| 480 |
|
| 481 |
+
def _target_rows(
|
| 482 |
+
self,
|
| 483 |
+
target_ids: Sequence[int],
|
| 484 |
+
baseline_token_logps: Sequence[float],
|
| 485 |
+
modified_token_logps: Sequence[float],
|
| 486 |
+
random_token_logps: Sequence[float],
|
| 487 |
+
) -> list[list[object]]:
|
| 488 |
+
assert self.tokenizer is not None
|
| 489 |
+
rows = []
|
| 490 |
+
for idx, (token_id, bp, mp, rp) in enumerate(
|
| 491 |
+
zip(
|
| 492 |
+
target_ids,
|
| 493 |
+
baseline_token_logps,
|
| 494 |
+
modified_token_logps,
|
| 495 |
+
random_token_logps,
|
| 496 |
+
strict=True,
|
| 497 |
+
)
|
| 498 |
+
):
|
| 499 |
+
rows.append(
|
| 500 |
+
[
|
| 501 |
+
idx,
|
| 502 |
+
repr(self.tokenizer.decode([int(token_id)])),
|
| 503 |
+
float(bp),
|
| 504 |
+
float(mp),
|
| 505 |
+
float(rp),
|
| 506 |
+
float(mp - bp),
|
| 507 |
+
float(rp - bp),
|
| 508 |
+
]
|
| 509 |
+
)
|
| 510 |
+
return rows
|
| 511 |
+
|
| 512 |
@torch.inference_mode()
|
| 513 |
def intervene(
|
| 514 |
self,
|
|
|
|
| 523 |
) -> InterventionResult:
|
| 524 |
self.ensure_ready(preload_saes=False)
|
| 525 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 526 |
+
prompt_inputs = self._inputs(text)
|
| 527 |
+
prompt_len = int(prompt_inputs['input_ids'].shape[1])
|
| 528 |
idx = self._resolve_index(int(token_index), prompt_len)
|
|
|
|
| 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 |
+
full_inputs = self._append_target(prompt_inputs, target_ids)
|
| 540 |
+
with self._capture_hook(int(layer), capture):
|
| 541 |
+
baseline_full = self.model(**full_inputs, use_cache=False)
|
| 542 |
+
baseline_next_logits = baseline_full.logits[0, prompt_len - 1]
|
| 543 |
+
baseline_seq, baseline_mean, baseline_token_logps = sequence_logprob_summary(
|
| 544 |
+
baseline_full.logits[0],
|
| 545 |
+
prompt_length=prompt_len,
|
| 546 |
+
target_ids=target_ids,
|
| 547 |
+
)
|
| 548 |
+
else:
|
| 549 |
+
with self._capture_hook(int(layer), capture):
|
| 550 |
+
baseline_prompt = self.model(**prompt_inputs, use_cache=False)
|
| 551 |
+
baseline_next_logits = baseline_prompt.logits[0, -1]
|
| 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 |
control_delta = normalized_random_control(
|
| 559 |
delta,
|
| 560 |
+
seed=self._control_seed(
|
| 561 |
+
text,
|
| 562 |
+
int(layer),
|
| 563 |
+
str(int(feature_id)),
|
| 564 |
+
mode,
|
| 565 |
+
float(coefficient),
|
| 566 |
+
),
|
| 567 |
)
|
|
|
|
|
|
|
|
|
|
| 568 |
|
| 569 |
+
modified_next_logits: torch.Tensor
|
| 570 |
+
random_next_logits: torch.Tensor
|
| 571 |
+
modified_seq = modified_mean = random_seq = random_mean = None
|
| 572 |
+
modified_token_logps: list[float] = []
|
| 573 |
+
random_token_logps: list[float] = []
|
| 574 |
+
|
| 575 |
+
if target_ids:
|
| 576 |
+
full_inputs = self._append_target(prompt_inputs, target_ids)
|
| 577 |
+
repeated = self._repeat_inputs(full_inputs, 2)
|
| 578 |
+
deltas = torch.stack([delta, control_delta], dim=0)
|
| 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 |
+
modified_logits_all,
|
| 587 |
+
prompt_length=prompt_len,
|
| 588 |
+
target_ids=target_ids,
|
| 589 |
+
)
|
| 590 |
+
random_seq, random_mean, random_token_logps = sequence_logprob_summary(
|
| 591 |
+
random_logits_all,
|
| 592 |
+
prompt_length=prompt_len,
|
| 593 |
+
target_ids=target_ids,
|
| 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),
|
| 605 |
+
'do_sample': False,
|
| 606 |
+
'return_dict_in_generate': True,
|
| 607 |
+
'output_scores': False,
|
| 608 |
+
'pad_token_id': self.tokenizer.eos_token_id,
|
| 609 |
+
}
|
| 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,
|
|
|
|
| 658 |
random_js_divergence=float(random_js),
|
| 659 |
js_specificity_ratio=float(js_ratio),
|
| 660 |
target_text=target_text,
|
| 661 |
+
target_token_count=len(target_ids),
|
| 662 |
+
target_tokens=target_tokens,
|
| 663 |
baseline_target_prob=bp,
|
| 664 |
modified_target_prob=mp,
|
| 665 |
random_target_prob=rp,
|
| 666 |
+
baseline_sequence_logprob=baseline_seq,
|
| 667 |
+
modified_sequence_logprob=modified_seq,
|
| 668 |
+
random_sequence_logprob=random_seq,
|
| 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=random_mean_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 |
|
|
|
|
| 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)
|
| 698 |
+
prompt_len = int(prompt_inputs['input_ids'].shape[1])
|
| 699 |
idx = self._resolve_index(int(token_index), prompt_len)
|
| 700 |
+
target_ids = self._target_ids(target_text)
|
| 701 |
+
full_inputs = self._append_target(prompt_inputs, target_ids)
|
| 702 |
sae = self.sae_store.get(int(layer))
|
| 703 |
|
| 704 |
capture: dict = {}
|
| 705 |
with self._capture_hook(int(layer), capture):
|
| 706 |
+
baseline_out = self.model(**full_inputs, use_cache=False)
|
| 707 |
+
baseline_logits = baseline_out.logits[0]
|
| 708 |
+
baseline_next = baseline_logits[prompt_len - 1]
|
| 709 |
+
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
|
| 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)
|
| 725 |
+
deltas.append(delta)
|
| 726 |
+
delta_coefficients.append(float(spec.delta_activation(original_activation)))
|
| 727 |
+
norms.append(float(torch.linalg.vector_norm(delta.float()).item()))
|
| 728 |
+
|
| 729 |
+
repeated = self._repeat_inputs(full_inputs, len(deltas))
|
| 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 |
[
|
| 743 |
float(multiplier),
|
| 744 |
+
delta_coefficients[row_idx],
|
| 745 |
+
norms[row_idx],
|
| 746 |
+
float(baseline_mean),
|
| 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 |
+
|
| 764 |
+
@torch.inference_mode()
|
| 765 |
+
def intervene_feature_set(
|
| 766 |
+
self,
|
| 767 |
+
text: str,
|
| 768 |
+
layer: int,
|
| 769 |
+
token_index: int,
|
| 770 |
+
feature_ids: Sequence[int],
|
| 771 |
+
mode: str,
|
| 772 |
+
coefficient: float,
|
| 773 |
+
target_text: str,
|
| 774 |
+
) -> FeatureSetResult:
|
| 775 |
+
if not target_text.strip():
|
| 776 |
+
raise ValueError('Feature-set causal testing requires a target continuation.')
|
| 777 |
+
ids = list(dict.fromkeys(int(x) for x in feature_ids))
|
| 778 |
+
if not ids:
|
| 779 |
+
raise ValueError('Select at least one feature.')
|
| 780 |
+
if len(ids) > 12:
|
| 781 |
+
raise ValueError('Select at most 12 features for a live feature-set intervention.')
|
| 782 |
+
if mode not in {'ablate', 'scale'}:
|
| 783 |
+
raise ValueError("Feature-set mode must be 'ablate' or 'scale'.")
|
| 784 |
+
|
| 785 |
+
self.ensure_ready(preload_saes=False)
|
| 786 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 787 |
+
prompt_inputs = self._inputs(text)
|
| 788 |
+
prompt_len = int(prompt_inputs['input_ids'].shape[1])
|
| 789 |
+
idx = self._resolve_index(int(token_index), prompt_len)
|
| 790 |
+
target_ids = self._target_ids(target_text)
|
| 791 |
+
full_inputs = self._append_target(prompt_inputs, target_ids)
|
| 792 |
+
sae = self.sae_store.get(int(layer))
|
| 793 |
+
|
| 794 |
+
capture: dict = {}
|
| 795 |
+
with self._capture_hook(int(layer), capture):
|
| 796 |
+
baseline_out = self.model(**full_inputs, use_cache=False)
|
| 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 |
+
control = normalized_random_control(
|
| 804 |
+
delta,
|
| 805 |
+
seed=self._control_seed(
|
| 806 |
+
text,
|
| 807 |
+
int(layer),
|
| 808 |
+
','.join(str(x) for x in ids),
|
| 809 |
+
mode,
|
| 810 |
+
float(coefficient),
|
| 811 |
+
),
|
| 812 |
+
)
|
| 813 |
+
|
| 814 |
+
repeated = self._repeat_inputs(full_inputs, 2)
|
| 815 |
+
with self._batch_delta_hook(int(layer), idx, torch.stack([delta, control], dim=0)):
|
| 816 |
+
outputs = self.model(**repeated, use_cache=False)
|
| 817 |
+
|
| 818 |
+
baseline_logits = baseline_out.logits[0]
|
| 819 |
+
modified_logits = outputs.logits[0]
|
| 820 |
+
random_logits = outputs.logits[1]
|
| 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 |
+
random_mean_delta = float(random_mean - baseline_mean)
|
| 838 |
+
next_idx = prompt_len - 1
|
| 839 |
+
js = js_divergence_from_logits(baseline_logits[next_idx], modified_logits[next_idx])
|
| 840 |
+
random_js = js_divergence_from_logits(baseline_logits[next_idx], random_logits[next_idx])
|
| 841 |
+
|
| 842 |
+
feature_rows = [
|
| 843 |
+
[
|
| 844 |
+
feature_id,
|
| 845 |
+
float(activation),
|
| 846 |
+
float(delta_coefficient),
|
| 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(
|
| 857 |
+
feature_ids=ids,
|
| 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(random_js),
|
| 862 |
+
js_specificity_ratio=float(abs(js) / max(abs(random_js), 1e-12)),
|
| 863 |
+
baseline_sequence_logprob=float(baseline_seq),
|
| 864 |
+
modified_sequence_logprob=float(modified_seq),
|
| 865 |
+
random_sequence_logprob=float(random_seq),
|
| 866 |
+
sequence_logprob_delta=float(modified_seq - baseline_seq),
|
| 867 |
+
random_sequence_logprob_delta=float(random_seq - baseline_seq),
|
| 868 |
+
mean_logprob_delta=mean_delta,
|
| 869 |
+
random_mean_logprob_delta=random_mean_delta,
|
| 870 |
+
target_specificity_ratio=float(abs(mean_delta) / max(abs(random_mean_delta), 1e-12)),
|
| 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 |
+
|
| 880 |
+
@torch.inference_mode()
|
| 881 |
+
def feature_set_size_sweep(
|
| 882 |
+
self,
|
| 883 |
+
text: str,
|
| 884 |
+
layer: int,
|
| 885 |
+
token_index: int,
|
| 886 |
+
target_text: str,
|
| 887 |
+
sizes: Sequence[int] = (1, 3, 5),
|
| 888 |
+
) -> FeatureSetSweepResult:
|
| 889 |
+
"""Jointly ablate the strongest k active features for k in ``sizes``."""
|
| 890 |
+
if not target_text.strip():
|
| 891 |
+
raise ValueError('Feature-set size sweep requires a target continuation.')
|
| 892 |
+
self.ensure_ready(preload_saes=False)
|
| 893 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 894 |
+
prompt_inputs = self._inputs(text)
|
| 895 |
+
prompt_len = int(prompt_inputs['input_ids'].shape[1])
|
| 896 |
+
idx = self._resolve_index(int(token_index), prompt_len)
|
| 897 |
+
target_ids = self._target_ids(target_text)
|
| 898 |
+
full_inputs = self._append_target(prompt_inputs, target_ids)
|
| 899 |
+
sae = self.sae_store.get(int(layer))
|
| 900 |
+
|
| 901 |
+
capture: dict = {}
|
| 902 |
+
with self._capture_hook(int(layer), capture):
|
| 903 |
+
baseline_out = self.model(**full_inputs, use_cache=False)
|
| 904 |
+
residual = capture['hidden'][0, idx]
|
| 905 |
+
encoding = sae.encode(residual)
|
| 906 |
+
active_ids = [
|
| 907 |
+
int(feature_id)
|
| 908 |
+
for feature_id, value in zip(
|
| 909 |
+
encoding.indices.detach().cpu().tolist(),
|
| 910 |
+
encoding.values.detach().float().cpu().tolist(),
|
| 911 |
+
strict=True,
|
| 912 |
+
)
|
| 913 |
+
if float(value) > 0
|
| 914 |
+
]
|
| 915 |
+
valid_sizes = [int(size) for size in sizes if int(size) > 0 and int(size) <= len(active_ids)]
|
| 916 |
+
if not valid_sizes:
|
| 917 |
+
raise ValueError('Not enough active features for the requested set sizes.')
|
| 918 |
+
|
| 919 |
+
deltas: list[torch.Tensor] = []
|
| 920 |
+
controls: list[torch.Tensor] = []
|
| 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 |
+
control = normalized_random_control(
|
| 933 |
+
delta,
|
| 934 |
+
seed=self._control_seed(
|
| 935 |
+
text,
|
| 936 |
+
int(layer),
|
| 937 |
+
f'top-{size}',
|
| 938 |
+
'ablate_set',
|
| 939 |
+
0.0,
|
| 940 |
+
),
|
| 941 |
+
)
|
| 942 |
+
deltas.append(delta)
|
| 943 |
+
controls.append(control)
|
| 944 |
+
feature_lists.append(selected)
|
| 945 |
+
norms.append(float(torch.linalg.vector_norm(delta.float()).item()))
|
| 946 |
+
|
| 947 |
+
all_deltas = torch.stack([item for pair in zip(deltas, controls, strict=True) for item in pair])
|
| 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 = baseline_out.logits[0]
|
| 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 sweep_idx, size in enumerate(valid_sizes):
|
| 961 |
+
sae_logits = outputs.logits[2 * sweep_idx]
|
| 962 |
+
random_logits = outputs.logits[2 * sweep_idx + 1]
|
| 963 |
+
sae_seq, sae_mean, _ = sequence_logprob_summary(
|
| 964 |
+
sae_logits,
|
| 965 |
+
prompt_length=prompt_len,
|
| 966 |
+
target_ids=target_ids,
|
| 967 |
+
)
|
| 968 |
+
random_seq, random_mean, _ = sequence_logprob_summary(
|
| 969 |
+
random_logits,
|
| 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 feature_lists[sweep_idx]),
|
| 981 |
+
norms[sweep_idx],
|
| 982 |
+
float(baseline_mean),
|
| 983 |
+
float(sae_mean),
|
| 984 |
+
sae_delta,
|
| 985 |
+
random_delta,
|
| 986 |
+
float(abs(sae_delta) / max(abs(random_delta), 1e-12)),
|
| 987 |
+
float(sae_seq - baseline_seq),
|
| 988 |
+
float(sae_js),
|
| 989 |
+
float(random_js),
|
| 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,
|
| 1000 |
+
text_a: str,
|
| 1001 |
+
text_b: str,
|
| 1002 |
+
layer: int,
|
| 1003 |
+
token_index_a: int = -1,
|
| 1004 |
+
token_index_b: int = -1,
|
| 1005 |
+
top_n: int = 12,
|
| 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.analyze(text_a, int(layer), int(token_index_a), max(int(top_n), 12))
|
| 1010 |
+
b = self.analyze(text_b, int(layer), int(token_index_b), max(int(top_n), 12))
|
| 1011 |
+
|
| 1012 |
+
map_a = self._encoding_map(a.features)
|
| 1013 |
+
map_b = self._encoding_map(b.features)
|
| 1014 |
+
set_a = set(map_a)
|
| 1015 |
+
set_b = set(map_b)
|
| 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)]]
|
| 1027 |
+
top_union = list(dict.fromkeys(top_ids_a + top_ids_b))
|
| 1028 |
+
shared_top_n = len(set(top_ids_a) & set(top_ids_b))
|
| 1029 |
+
rows: list[list[object]] = []
|
| 1030 |
+
chart_rows: list[list[object]] = []
|
| 1031 |
+
for feature_id in top_union:
|
| 1032 |
+
va = float(map_a.get(feature_id, 0.0))
|
| 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])
|
| 1046 |
+
|
| 1047 |
+
rows.sort(key=lambda row: max(float(row[1]), float(row[2])), reverse=True)
|
| 1048 |
+
return ParaphraseResult(
|
| 1049 |
+
tokens_a=a.tokens,
|
| 1050 |
+
token_index_a=a.token_index,
|
| 1051 |
+
tokens_b=b.tokens,
|
| 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 |
|
featurelens/selection.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import csv
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def load_feature_sets(path: str | Path, max_size: int) -> dict[str, dict]:
|
| 9 |
+
"""
|
| 10 |
+
Select a same-layer feature set for each concept from a feature catalog.
|
| 11 |
+
|
| 12 |
+
The layer whose best training feature is strongest is selected first. The top
|
| 13 |
+
distinct features from that same SAE dictionary are then returned. Keeping a
|
| 14 |
+
set within one layer is essential because decoder directions from different
|
| 15 |
+
residual spaces should not be summed into one intervention.
|
| 16 |
+
"""
|
| 17 |
+
with Path(path).open(newline='', encoding='utf-8') as handle:
|
| 18 |
+
rows = list(csv.DictReader(handle))
|
| 19 |
+
|
| 20 |
+
grouped: dict[str, list[dict]] = defaultdict(list)
|
| 21 |
+
for row in rows:
|
| 22 |
+
item = dict(row)
|
| 23 |
+
item['layer'] = int(row['layer'])
|
| 24 |
+
item['feature_id'] = int(row['feature_id'])
|
| 25 |
+
item['train_auroc'] = float(row['train_auroc'])
|
| 26 |
+
item['activation_contrast'] = (
|
| 27 |
+
float(row['activation_rate_pos']) - float(row['activation_rate_neg'])
|
| 28 |
+
)
|
| 29 |
+
grouped[row['concept']].append(item)
|
| 30 |
+
|
| 31 |
+
result: dict[str, dict] = {}
|
| 32 |
+
for concept, concept_rows in grouped.items():
|
| 33 |
+
best_by_layer: dict[int, tuple[float, float]] = {}
|
| 34 |
+
for row in concept_rows:
|
| 35 |
+
key = (row['train_auroc'], row['activation_contrast'])
|
| 36 |
+
best_by_layer[row['layer']] = max(
|
| 37 |
+
best_by_layer.get(row['layer'], (-1.0, -1.0)),
|
| 38 |
+
key,
|
| 39 |
+
)
|
| 40 |
+
chosen_layer = max(best_by_layer, key=best_by_layer.get)
|
| 41 |
+
layer_rows = [row for row in concept_rows if row['layer'] == chosen_layer]
|
| 42 |
+
layer_rows.sort(
|
| 43 |
+
key=lambda row: (row['train_auroc'], row['activation_contrast']),
|
| 44 |
+
reverse=True,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
seen: set[int] = set()
|
| 48 |
+
feature_ids: list[int] = []
|
| 49 |
+
for row in layer_rows:
|
| 50 |
+
feature_id = int(row['feature_id'])
|
| 51 |
+
if feature_id not in seen:
|
| 52 |
+
seen.add(feature_id)
|
| 53 |
+
feature_ids.append(feature_id)
|
| 54 |
+
if len(feature_ids) >= int(max_size):
|
| 55 |
+
break
|
| 56 |
+
|
| 57 |
+
result[concept] = {
|
| 58 |
+
'layer': int(chosen_layer),
|
| 59 |
+
'feature_ids': feature_ids,
|
| 60 |
+
}
|
| 61 |
+
return result
|
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.3.0"
|
| 4 |
description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
|
| 5 |
requires-python = ">=3.10"
|
| 6 |
|
research_config.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
| 32 |
"scale_2x"
|
| 33 |
],
|
| 34 |
"negative_control": "norm-matched random residual direction",
|
| 35 |
-
"primary_causal_metric": "target
|
| 36 |
"live_causal_controls": "norm-matched random residual direction",
|
| 37 |
"dose_response_multipliers": [
|
| 38 |
0.0,
|
|
@@ -45,5 +45,23 @@
|
|
| 45 |
"statistical_inference": [
|
| 46 |
"bootstrap_95_ci",
|
| 47 |
"paired_sign_flip_test"
|
| 48 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
|
|
|
| 32 |
"scale_2x"
|
| 33 |
],
|
| 34 |
"negative_control": "norm-matched random residual direction",
|
| 35 |
+
"primary_causal_metric": "full target continuation mean log-probability delta per token (teacher-forced)",
|
| 36 |
"live_causal_controls": "norm-matched random residual direction",
|
| 37 |
"dose_response_multipliers": [
|
| 38 |
0.0,
|
|
|
|
| 45 |
"statistical_inference": [
|
| 46 |
"bootstrap_95_ci",
|
| 47 |
"paired_sign_flip_test"
|
| 48 |
+
],
|
| 49 |
+
"live_features_v0_3": [
|
| 50 |
+
"full_continuation_scoring",
|
| 51 |
+
"joint_multi_feature_intervention",
|
| 52 |
+
"topk_feature_set_size_sweep",
|
| 53 |
+
"paraphrase_robustness_explorer"
|
| 54 |
+
],
|
| 55 |
+
"feature_set_sizes": [
|
| 56 |
+
1,
|
| 57 |
+
3,
|
| 58 |
+
5
|
| 59 |
+
],
|
| 60 |
+
"feature_set_interventions": [
|
| 61 |
+
"ablate",
|
| 62 |
+
"scale"
|
| 63 |
+
],
|
| 64 |
+
"feature_set_negative_control": "norm-matched random residual direction",
|
| 65 |
+
"dose_response_execution": "batched residual edits in one model forward after baseline",
|
| 66 |
+
"feature_set_sweep_execution": "all SAE and random-control residual edits batched in one model forward after baseline"
|
| 67 |
}
|
scripts/release_check.py
CHANGED
|
@@ -7,256 +7,179 @@ from pathlib import Path
|
|
| 7 |
|
| 8 |
|
| 9 |
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
-
|
| 11 |
-
# Any non-ignored repository file larger than this is suspicious.
|
| 12 |
-
# FeatureLens should not contain model weights, SAE checkpoints,
|
| 13 |
-
# activation dumps, virtual environments, etc.
|
| 14 |
MAX_FILE_SIZE_BYTES = 5_000_000 # 5 MB
|
| 15 |
|
| 16 |
REQUIRED = [
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
]
|
| 29 |
|
| 30 |
|
| 31 |
def load_jsonl(path: Path) -> list[dict]:
|
| 32 |
-
"""Load a JSONL file into a list of dictionaries."""
|
| 33 |
return [
|
| 34 |
json.loads(line)
|
| 35 |
-
for line in path.read_text(encoding=
|
| 36 |
if line.strip()
|
| 37 |
]
|
| 38 |
|
| 39 |
|
| 40 |
def repository_candidates() -> list[Path]:
|
| 41 |
-
"""
|
| 42 |
-
Return files that are either:
|
| 43 |
-
|
| 44 |
-
- already tracked by Git, or
|
| 45 |
-
- untracked but not ignored by .gitignore.
|
| 46 |
-
|
| 47 |
-
This deliberately excludes files such as .venv contents when .venv/
|
| 48 |
-
is correctly listed in .gitignore.
|
| 49 |
-
|
| 50 |
-
Including untracked, non-ignored files is useful because it catches
|
| 51 |
-
accidental large files before someone runs `git add .`.
|
| 52 |
-
"""
|
| 53 |
try:
|
| 54 |
result = subprocess.run(
|
| 55 |
-
[
|
| 56 |
-
"git",
|
| 57 |
-
"ls-files",
|
| 58 |
-
"--cached",
|
| 59 |
-
"--others",
|
| 60 |
-
"--exclude-standard",
|
| 61 |
-
],
|
| 62 |
cwd=ROOT,
|
| 63 |
capture_output=True,
|
| 64 |
text=True,
|
| 65 |
check=True,
|
| 66 |
)
|
| 67 |
except FileNotFoundError as exc:
|
| 68 |
-
raise SystemExit(
|
| 69 |
-
"Git is required to run the FeatureLens release check."
|
| 70 |
-
) from exc
|
| 71 |
except subprocess.CalledProcessError as exc:
|
| 72 |
-
stderr = exc.stderr.strip()
|
| 73 |
raise SystemExit(
|
| 74 |
-
f
|
| 75 |
) from exc
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
for relative_path in result.stdout.splitlines():
|
| 80 |
relative_path = relative_path.strip()
|
| 81 |
-
|
| 82 |
if not relative_path:
|
| 83 |
continue
|
| 84 |
-
|
| 85 |
path = ROOT / relative_path
|
| 86 |
-
|
| 87 |
-
# A tracked file may have been deleted locally but not yet committed.
|
| 88 |
-
# Such a path should not be size-checked.
|
| 89 |
if path.is_file():
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
return candidates
|
| 93 |
|
| 94 |
|
| 95 |
def check_required_files() -> None:
|
| 96 |
-
"""Ensure the repository contains all files required for a release."""
|
| 97 |
missing = [path for path in REQUIRED if not (ROOT / path).exists()]
|
| 98 |
-
|
| 99 |
if missing:
|
| 100 |
-
raise SystemExit(f
|
| 101 |
|
| 102 |
|
| 103 |
def check_config(config: dict) -> None:
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
raise SystemExit(
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
if config.get("model_id") != "Qwen/Qwen3-1.7B-Base":
|
| 112 |
-
raise SystemExit(
|
| 113 |
-
f'Unexpected model_id: {config.get("model_id")}.'
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
if config.get("sae_width") != 32768:
|
| 117 |
-
raise SystemExit(
|
| 118 |
-
f'Unexpected sae_width: {config.get("sae_width")}. '
|
| 119 |
-
"Expected 32768."
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
expected_multipliers = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0]
|
| 123 |
-
|
| 124 |
-
if config.get("dose_response_multipliers") != expected_multipliers:
|
| 125 |
-
raise SystemExit(
|
| 126 |
-
"Unexpected dose_response_multipliers: "
|
| 127 |
-
f'{config.get("dose_response_multipliers")}. '
|
| 128 |
-
f"Expected {expected_multipliers}."
|
| 129 |
)
|
| 130 |
|
| 131 |
|
| 132 |
def check_datasets(config: dict) -> tuple[list[dict], list[dict]]:
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
causal = load_jsonl(ROOT / "data" / "causal_tasks.jsonl")
|
| 136 |
-
|
| 137 |
-
expected_prompt_count = config.get("discovery_prompts")
|
| 138 |
-
expected_causal_count = config.get("causal_tasks")
|
| 139 |
|
| 140 |
-
if len(prompts) !=
|
| 141 |
raise SystemExit(
|
| 142 |
-
|
| 143 |
-
f
|
| 144 |
)
|
| 145 |
-
|
| 146 |
-
if len(causal) != expected_causal_count:
|
| 147 |
raise SystemExit(
|
| 148 |
-
|
| 149 |
-
f
|
| 150 |
)
|
| 151 |
|
| 152 |
-
concept_counts = Counter(row[
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
if set(concept_counts) != set(expected_concepts):
|
| 157 |
-
raise SystemExit(
|
| 158 |
-
"Discovery dataset concepts do not match research_config.json.\n"
|
| 159 |
-
f"Dataset concepts: {sorted(concept_counts)}\n"
|
| 160 |
-
f"Config concepts: {sorted(expected_concepts)}"
|
| 161 |
-
)
|
| 162 |
-
|
| 163 |
-
# The controlled discovery benchmark is intentionally balanced.
|
| 164 |
if len(set(concept_counts.values())) != 1:
|
| 165 |
-
raise SystemExit(
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
|
| 169 |
return prompts, causal
|
| 170 |
|
| 171 |
|
| 172 |
def check_oversized_files() -> None:
|
| 173 |
-
"""
|
| 174 |
-
Reject unexpectedly large files that could be committed/pushed.
|
| 175 |
-
|
| 176 |
-
Importantly, this does NOT recursively scan .venv or other ignored
|
| 177 |
-
directories. Git decides what counts as a repository candidate.
|
| 178 |
-
"""
|
| 179 |
oversized: list[str] = []
|
| 180 |
-
|
| 181 |
for path in repository_candidates():
|
| 182 |
size_bytes = path.stat().st_size
|
| 183 |
-
|
| 184 |
if size_bytes > MAX_FILE_SIZE_BYTES:
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
oversized.append(
|
| 189 |
-
f"{relative_path} ({size_mb:.1f} MB)"
|
| 190 |
-
)
|
| 191 |
|
| 192 |
if oversized:
|
| 193 |
-
formatted =
|
| 194 |
-
|
| 195 |
raise SystemExit(
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
"Model weights, SAE checkpoints, activation dumps, virtual "
|
| 201 |
-
"environments, and caches should not be committed."
|
| 202 |
)
|
| 203 |
|
| 204 |
|
| 205 |
def check_readme() -> None:
|
| 206 |
-
|
| 207 |
-
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
| 208 |
-
|
| 209 |
required_strings = [
|
| 210 |
-
|
| 211 |
'sdk_version: "6.24.0"',
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
for value in required_strings
|
| 218 |
-
if value not in readme
|
| 219 |
]
|
| 220 |
-
|
| 221 |
if missing:
|
| 222 |
-
raise SystemExit(
|
| 223 |
-
f"README.md is missing required metadata/content: {missing}"
|
| 224 |
-
)
|
| 225 |
|
| 226 |
-
readme_lower = readme.lower()
|
| 227 |
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
)
|
| 232 |
-
|
| 233 |
-
if "dose" not in readme_lower:
|
| 234 |
-
raise SystemExit(
|
| 235 |
-
"README.md should describe the dose-response experiment."
|
| 236 |
-
)
|
| 237 |
|
| 238 |
|
| 239 |
def main() -> None:
|
| 240 |
check_required_files()
|
| 241 |
-
|
| 242 |
-
config = json.loads(
|
| 243 |
-
(ROOT / "research_config.json").read_text(encoding="utf-8")
|
| 244 |
-
)
|
| 245 |
-
|
| 246 |
check_config(config)
|
| 247 |
-
|
| 248 |
prompts, causal = check_datasets(config)
|
| 249 |
-
|
| 250 |
check_oversized_files()
|
| 251 |
-
|
| 252 |
check_readme()
|
|
|
|
| 253 |
|
| 254 |
-
print(
|
| 255 |
-
print(f
|
| 256 |
-
print(f
|
| 257 |
print(f' layers: {config["layers"]}')
|
| 258 |
-
print(
|
|
|
|
| 259 |
|
| 260 |
|
| 261 |
-
if __name__ ==
|
| 262 |
-
main()
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
MAX_FILE_SIZE_BYTES = 5_000_000 # 5 MB
|
| 11 |
|
| 12 |
REQUIRED = [
|
| 13 |
+
'README.md',
|
| 14 |
+
'app.py',
|
| 15 |
+
'requirements.txt',
|
| 16 |
+
'research_config.json',
|
| 17 |
+
'featurelens/runtime.py',
|
| 18 |
+
'featurelens/sae.py',
|
| 19 |
+
'featurelens/interventions.py',
|
| 20 |
+
'featurelens/metrics.py',
|
| 21 |
+
'featurelens/stats.py',
|
| 22 |
+
'experiments/run_all.py',
|
| 23 |
+
'experiments/run_causal.py',
|
| 24 |
+
'experiments/run_feature_sets.py',
|
| 25 |
+
'data/prompts.jsonl',
|
| 26 |
+
'data/causal_tasks.jsonl',
|
| 27 |
+
'docs/VALIDATION.md',
|
| 28 |
]
|
| 29 |
|
| 30 |
|
| 31 |
def load_jsonl(path: Path) -> list[dict]:
|
|
|
|
| 32 |
return [
|
| 33 |
json.loads(line)
|
| 34 |
+
for line in path.read_text(encoding='utf-8').splitlines()
|
| 35 |
if line.strip()
|
| 36 |
]
|
| 37 |
|
| 38 |
|
| 39 |
def repository_candidates() -> list[Path]:
|
| 40 |
+
"""Return tracked files plus untracked files that are not ignored by Git."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
try:
|
| 42 |
result = subprocess.run(
|
| 43 |
+
['git', 'ls-files', '--cached', '--others', '--exclude-standard'],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
cwd=ROOT,
|
| 45 |
capture_output=True,
|
| 46 |
text=True,
|
| 47 |
check=True,
|
| 48 |
)
|
| 49 |
except FileNotFoundError as exc:
|
| 50 |
+
raise SystemExit('Git is required to run the FeatureLens release check.') from exc
|
|
|
|
|
|
|
| 51 |
except subprocess.CalledProcessError as exc:
|
|
|
|
| 52 |
raise SystemExit(
|
| 53 |
+
f'Could not inspect repository files with Git: {exc.stderr.strip()}'
|
| 54 |
) from exc
|
| 55 |
|
| 56 |
+
paths: list[Path] = []
|
|
|
|
| 57 |
for relative_path in result.stdout.splitlines():
|
| 58 |
relative_path = relative_path.strip()
|
|
|
|
| 59 |
if not relative_path:
|
| 60 |
continue
|
|
|
|
| 61 |
path = ROOT / relative_path
|
|
|
|
|
|
|
|
|
|
| 62 |
if path.is_file():
|
| 63 |
+
paths.append(path)
|
| 64 |
+
return paths
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def check_required_files() -> None:
|
|
|
|
| 68 |
missing = [path for path in REQUIRED if not (ROOT / path).exists()]
|
|
|
|
| 69 |
if missing:
|
| 70 |
+
raise SystemExit(f'Missing required files: {missing}')
|
| 71 |
|
| 72 |
|
| 73 |
def check_config(config: dict) -> None:
|
| 74 |
+
expected = {
|
| 75 |
+
'layers': [4, 14, 26],
|
| 76 |
+
'model_id': 'Qwen/Qwen3-1.7B-Base',
|
| 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 |
+
required_live = {
|
| 86 |
+
'full_continuation_scoring',
|
| 87 |
+
'joint_multi_feature_intervention',
|
| 88 |
+
'topk_feature_set_size_sweep',
|
| 89 |
+
'paraphrase_robustness_explorer',
|
| 90 |
+
}
|
| 91 |
+
actual_live = set(config.get('live_features_v0_3', []))
|
| 92 |
+
if actual_live != required_live:
|
| 93 |
raise SystemExit(
|
| 94 |
+
'research_config.json live_features_v0_3 mismatch: '
|
| 95 |
+
f'{sorted(actual_live)}'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
)
|
| 97 |
|
| 98 |
|
| 99 |
def check_datasets(config: dict) -> tuple[list[dict], list[dict]]:
|
| 100 |
+
prompts = load_jsonl(ROOT / 'data' / 'prompts.jsonl')
|
| 101 |
+
causal = load_jsonl(ROOT / 'data' / 'causal_tasks.jsonl')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
if len(prompts) != config.get('discovery_prompts'):
|
| 104 |
raise SystemExit(
|
| 105 |
+
f'Discovery prompt count mismatch: found {len(prompts)}, '
|
| 106 |
+
f'expected {config.get("discovery_prompts")}.'
|
| 107 |
)
|
| 108 |
+
if len(causal) != config.get('causal_tasks'):
|
|
|
|
| 109 |
raise SystemExit(
|
| 110 |
+
f'Causal task count mismatch: found {len(causal)}, '
|
| 111 |
+
f'expected {config.get("causal_tasks")}.'
|
| 112 |
)
|
| 113 |
|
| 114 |
+
concept_counts = Counter(row['concept'] for row in prompts)
|
| 115 |
+
if set(concept_counts) != set(config.get('concepts', [])):
|
| 116 |
+
raise SystemExit('Discovery dataset concepts do not match research_config.json.')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
if len(set(concept_counts.values())) != 1:
|
| 118 |
+
raise SystemExit(f'Discovery concepts are not balanced: {dict(concept_counts)}')
|
| 119 |
+
|
| 120 |
+
pair_counts = Counter(row['pair_id'] for row in prompts)
|
| 121 |
+
if set(pair_counts.values()) != {2}:
|
| 122 |
+
raise SystemExit('Every discovery paraphrase pair must contain exactly two prompts.')
|
| 123 |
|
| 124 |
return prompts, causal
|
| 125 |
|
| 126 |
|
| 127 |
def check_oversized_files() -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
oversized: list[str] = []
|
|
|
|
| 129 |
for path in repository_candidates():
|
| 130 |
size_bytes = path.stat().st_size
|
|
|
|
| 131 |
if size_bytes > MAX_FILE_SIZE_BYTES:
|
| 132 |
+
relative = path.relative_to(ROOT)
|
| 133 |
+
oversized.append(f'{relative} ({size_bytes / 1_000_000:.1f} MB)')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
if oversized:
|
| 136 |
+
formatted = '\n - '.join(oversized)
|
|
|
|
| 137 |
raise SystemExit(
|
| 138 |
+
'Repository contains unexpectedly large tracked/unignored candidates:\n'
|
| 139 |
+
f' - {formatted}\n\n'
|
| 140 |
+
'If a file is a legitimate local artifact, add it to .gitignore. Model weights, '
|
| 141 |
+
'SAE checkpoints, activation dumps, virtual environments, and caches should not be committed.'
|
|
|
|
|
|
|
| 142 |
)
|
| 143 |
|
| 144 |
|
| 145 |
def check_readme() -> None:
|
| 146 |
+
readme = (ROOT / 'README.md').read_text(encoding='utf-8')
|
|
|
|
|
|
|
| 147 |
required_strings = [
|
| 148 |
+
'sdk: gradio',
|
| 149 |
'sdk_version: "6.24.0"',
|
| 150 |
+
'Qwen/Qwen3-1.7B-Base',
|
| 151 |
+
'full-continuation',
|
| 152 |
+
'feature-set',
|
| 153 |
+
'paraphrase',
|
| 154 |
+
'norm-matched',
|
|
|
|
|
|
|
| 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.3 content: {missing}')
|
|
|
|
|
|
|
| 159 |
|
|
|
|
| 160 |
|
| 161 |
+
def check_pyproject() -> None:
|
| 162 |
+
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 163 |
+
if 'version = "0.3.0"' not in text:
|
| 164 |
+
raise SystemExit('pyproject.toml must declare version 0.3.0.')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
|
| 167 |
def main() -> None:
|
| 168 |
check_required_files()
|
| 169 |
+
config = json.loads((ROOT / 'research_config.json').read_text(encoding='utf-8'))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
check_config(config)
|
|
|
|
| 171 |
prompts, causal = check_datasets(config)
|
|
|
|
| 172 |
check_oversized_files()
|
|
|
|
| 173 |
check_readme()
|
| 174 |
+
check_pyproject()
|
| 175 |
|
| 176 |
+
print('FeatureLens release check: PASS')
|
| 177 |
+
print(f' discovery prompts: {len(prompts)}')
|
| 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(' release: v0.3.0')
|
| 182 |
|
| 183 |
|
| 184 |
+
if __name__ == '__main__':
|
| 185 |
+
main()
|
tests/test_feature_sets.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from featurelens.selection import load_feature_sets
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_feature_set_selection_stays_within_one_best_layer(tmp_path: Path) -> None:
|
| 9 |
+
catalog = tmp_path / 'feature_catalog.csv'
|
| 10 |
+
catalog.write_text(
|
| 11 |
+
'layer,concept,feature_id,train_auroc,auroc,f1,activation_rate_pos,activation_rate_neg\n'
|
| 12 |
+
'4,math,10,0.80,0.75,0.70,0.8,0.2\n'
|
| 13 |
+
'14,math,20,0.92,0.82,0.80,0.9,0.1\n'
|
| 14 |
+
'14,math,21,0.90,0.81,0.79,0.8,0.2\n'
|
| 15 |
+
'14,math,22,0.88,0.80,0.78,0.7,0.2\n'
|
| 16 |
+
'26,math,30,0.85,0.79,0.76,0.8,0.3\n',
|
| 17 |
+
encoding='utf-8',
|
| 18 |
+
)
|
| 19 |
+
selected = load_feature_sets(catalog, max_size=3)
|
| 20 |
+
assert selected['math']['layer'] == 14
|
| 21 |
+
assert selected['math']['feature_ids'] == [20, 21, 22]
|
tests/test_interventions.py
CHANGED
|
@@ -1,8 +1,14 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import torch
|
| 4 |
|
| 5 |
-
from featurelens.interventions import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def test_ablation_delta() -> None:
|
|
@@ -27,6 +33,42 @@ def test_residual_delta_is_decoder_direction_times_coefficient_delta() -> None:
|
|
| 27 |
assert torch.allclose(delta, torch.tensor([-3.0, -6.0, 3.0]))
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def test_random_control_matches_norm_and_is_deterministic() -> None:
|
| 31 |
delta = torch.tensor([3.0, 4.0, 0.0])
|
| 32 |
a = normalized_random_control(delta, seed=7)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import pytest
|
| 4 |
import torch
|
| 5 |
|
| 6 |
+
from featurelens.interventions import (
|
| 7 |
+
InterventionSpec,
|
| 8 |
+
joint_residual_delta,
|
| 9 |
+
normalized_random_control,
|
| 10 |
+
residual_delta,
|
| 11 |
+
)
|
| 12 |
|
| 13 |
|
| 14 |
def test_ablation_delta() -> None:
|
|
|
|
| 33 |
assert torch.allclose(delta, torch.tensor([-3.0, -6.0, 3.0]))
|
| 34 |
|
| 35 |
|
| 36 |
+
def test_joint_ablation_sums_feature_deltas() -> None:
|
| 37 |
+
directions = torch.tensor(
|
| 38 |
+
[
|
| 39 |
+
[1.0, 0.0, 2.0],
|
| 40 |
+
[0.0, 1.0, -1.0],
|
| 41 |
+
]
|
| 42 |
+
)
|
| 43 |
+
delta, coefficient_deltas = joint_residual_delta(
|
| 44 |
+
directions,
|
| 45 |
+
[2.0, 3.0],
|
| 46 |
+
InterventionSpec('ablate', 0.0),
|
| 47 |
+
)
|
| 48 |
+
assert coefficient_deltas == [-2.0, -3.0]
|
| 49 |
+
assert torch.allclose(delta, torch.tensor([-2.0, -3.0, -1.0]))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_joint_scale_uses_same_multiplier_per_feature() -> None:
|
| 53 |
+
directions = torch.eye(2)
|
| 54 |
+
delta, coefficient_deltas = joint_residual_delta(
|
| 55 |
+
directions,
|
| 56 |
+
[2.0, 4.0],
|
| 57 |
+
InterventionSpec('scale', 1.5),
|
| 58 |
+
)
|
| 59 |
+
assert coefficient_deltas == [1.0, 2.0]
|
| 60 |
+
assert torch.allclose(delta, torch.tensor([1.0, 2.0]))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_joint_injection_is_rejected() -> None:
|
| 64 |
+
with pytest.raises(ValueError, match='only'):
|
| 65 |
+
joint_residual_delta(
|
| 66 |
+
torch.eye(2),
|
| 67 |
+
[1.0, 1.0],
|
| 68 |
+
InterventionSpec('inject', 2.0),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
def test_random_control_matches_norm_and_is_deterministic() -> None:
|
| 73 |
delta = torch.tensor([3.0, 4.0, 0.0])
|
| 74 |
a = normalized_random_control(delta, seed=7)
|
tests/test_metrics.py
CHANGED
|
@@ -1,8 +1,17 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import torch
|
| 4 |
|
| 5 |
-
from featurelens.metrics import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def test_reconstruction_metrics_perfect_match() -> None:
|
|
@@ -20,3 +29,49 @@ def test_js_divergence_zero_for_identical_logits() -> None:
|
|
| 20 |
|
| 21 |
def test_safe_log_probability_is_finite_at_zero() -> None:
|
| 22 |
assert safe_log_probability(0.0) < 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
import torch
|
| 6 |
|
| 7 |
+
from featurelens.metrics import (
|
| 8 |
+
js_divergence_from_logits,
|
| 9 |
+
reconstruction_metrics,
|
| 10 |
+
safe_log_probability,
|
| 11 |
+
sequence_logprob_summary,
|
| 12 |
+
sparse_topk_cosine,
|
| 13 |
+
target_token_logprobs,
|
| 14 |
+
)
|
| 15 |
|
| 16 |
|
| 17 |
def test_reconstruction_metrics_perfect_match() -> None:
|
|
|
|
| 29 |
|
| 30 |
def test_safe_log_probability_is_finite_at_zero() -> None:
|
| 31 |
assert safe_log_probability(0.0) < 0.0
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_target_logprobs_score_every_continuation_token() -> None:
|
| 35 |
+
# prompt length = 2, target ids = [1, 0].
|
| 36 |
+
# Target token 0 is predicted from logits row 1; token 1 from row 2.
|
| 37 |
+
logits = torch.tensor(
|
| 38 |
+
[
|
| 39 |
+
[0.0, 0.0],
|
| 40 |
+
[0.0, 2.0],
|
| 41 |
+
[3.0, 0.0],
|
| 42 |
+
[0.0, 0.0],
|
| 43 |
+
]
|
| 44 |
+
)
|
| 45 |
+
values = target_token_logprobs(logits, prompt_length=2, target_ids=[1, 0])
|
| 46 |
+
expected_0 = torch.log_softmax(logits[1], dim=-1)[1]
|
| 47 |
+
expected_1 = torch.log_softmax(logits[2], dim=-1)[0]
|
| 48 |
+
assert torch.allclose(values, torch.stack([expected_0, expected_1]))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_sequence_summary_total_and_mean_are_consistent() -> None:
|
| 52 |
+
logits = torch.tensor(
|
| 53 |
+
[
|
| 54 |
+
[0.0, 0.0],
|
| 55 |
+
[0.0, 2.0],
|
| 56 |
+
[3.0, 0.0],
|
| 57 |
+
[0.0, 0.0],
|
| 58 |
+
]
|
| 59 |
+
)
|
| 60 |
+
total, mean, token_values = sequence_logprob_summary(
|
| 61 |
+
logits,
|
| 62 |
+
prompt_length=2,
|
| 63 |
+
target_ids=[1, 0],
|
| 64 |
+
)
|
| 65 |
+
assert len(token_values) == 2
|
| 66 |
+
assert math.isclose(total, sum(token_values), rel_tol=1e-6)
|
| 67 |
+
assert math.isclose(mean, total / 2.0, rel_tol=1e-6)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_sparse_topk_cosine_is_one_for_identical_sparse_vectors() -> None:
|
| 71 |
+
cosine = sparse_topk_cosine([1, 3], [2.0, 1.0], [1, 3], [2.0, 1.0])
|
| 72 |
+
assert abs(cosine - 1.0) < 1e-9
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_sparse_topk_cosine_is_zero_for_disjoint_support() -> None:
|
| 76 |
+
cosine = sparse_topk_cosine([1, 3], [2.0, 1.0], [2, 4], [5.0, 7.0])
|
| 77 |
+
assert cosine == 0.0
|