Spaces:
Running on Zero
Running on Zero
Commit ·
9d24374
1
Parent(s): 0b6bf61
Initial FeatureLens v0.1.0
Browse files- .github/workflows/ci.yml +26 -0
- .gitignore +12 -0
- LICENSE +21 -0
- README.md +186 -6
- app.py +288 -0
- artifacts/README.md +13 -0
- data/causal_tasks.jsonl +28 -0
- data/prompts.jsonl +224 -0
- docs/HF_DEPLOY.md +24 -0
- docs/METHODOLOGY.md +39 -0
- experiments/__init__.py +0 -0
- experiments/build_dataset.py +200 -0
- experiments/collect_activations.py +151 -0
- experiments/common.py +30 -0
- experiments/evaluate_features.py +204 -0
- experiments/make_report.py +209 -0
- experiments/run_all.py +27 -0
- experiments/run_causal.py +195 -0
- experiments/split.py +20 -0
- featurelens/__init__.py +14 -0
- featurelens/catalog.py +47 -0
- featurelens/config.py +38 -0
- featurelens/hf_runtime.py +16 -0
- featurelens/interventions.py +44 -0
- featurelens/metrics.py +43 -0
- featurelens/runtime.py +336 -0
- featurelens/sae.py +125 -0
- pyproject.toml +18 -0
- requirements-dev.txt +3 -0
- requirements.txt +9 -0
- research_config.json +26 -0
- scripts/release_check.py +64 -0
- tests/test_catalog.py +15 -0
- tests/test_data.py +27 -0
- tests/test_interventions.py +35 -0
- tests/test_metrics.py +22 -0
- tests/test_sae.py +57 -0
- tests/test_split.py +23 -0
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
pull_request:
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
test:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
steps:
|
| 11 |
+
- uses: actions/checkout@v4
|
| 12 |
+
- uses: actions/setup-python@v5
|
| 13 |
+
with:
|
| 14 |
+
python-version: '3.12'
|
| 15 |
+
- name: Install lightweight test dependencies
|
| 16 |
+
run: |
|
| 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 experiments/split.py experiments/build_dataset.py
|
| 21 |
+
- name: Unit tests
|
| 22 |
+
run: python -m pytest -q
|
| 23 |
+
- name: Compile
|
| 24 |
+
run: python -m compileall -q app.py featurelens experiments scripts
|
| 25 |
+
- name: Release check
|
| 26 |
+
run: python scripts/release_check.py
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.ruff_cache/
|
| 5 |
+
.venv/
|
| 6 |
+
.env
|
| 7 |
+
.DS_Store
|
| 8 |
+
artifacts/activations/
|
| 9 |
+
artifacts/*.npy
|
| 10 |
+
artifacts/*.npz
|
| 11 |
+
*.pt
|
| 12 |
+
*.safetensors
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Archit Sharma
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,13 +1,193 @@
|
|
| 1 |
---
|
| 2 |
title: FeatureLens
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: FeatureLens
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
python_version: "3.12"
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# FeatureLens — Causal Interpretability Workbench
|
| 14 |
+
|
| 15 |
+
**FeatureLens asks one concrete question:**
|
| 16 |
+
|
| 17 |
+
> Do sparse features that predict a concept also causally influence model behaviour?
|
| 18 |
+
|
| 19 |
+
It uses **Qwen3-1.7B-Base** with the official **Qwen-Scope residual-stream sparse autoencoders (SAEs)**. The live Hugging Face Space inspects feature activations and performs causal residual-stream interventions; the offline pipeline measures whether predictive features remain predictive on held-out paraphrases and whether manipulating them actually changes downstream behavior.
|
| 20 |
+
|
| 21 |
+
This is deliberately **not** a clone of an SAE viewer and does not depend on thesis code or thesis results.
|
| 22 |
+
|
| 23 |
+
## What is different from a feature viewer?
|
| 24 |
+
|
| 25 |
+
FeatureLens separates three claims that are often blurred together:
|
| 26 |
+
|
| 27 |
+
1. **Representation:** does an SAE reconstruct the residual stream reasonably well?
|
| 28 |
+
2. **Prediction:** does a feature distinguish a controlled concept on held-out prompts?
|
| 29 |
+
3. **Causation:** does changing that feature coefficient alter downstream token probabilities or generation more than a norm-matched random perturbation?
|
| 30 |
+
|
| 31 |
+
A feature can score highly on (2) and weakly on (3). FeatureLens treats that as a valid result rather than a failure.
|
| 32 |
+
|
| 33 |
+
## Live workbench
|
| 34 |
+
|
| 35 |
+
The Gradio app supports:
|
| 36 |
+
|
| 37 |
+
- prompt input and token-level inspection;
|
| 38 |
+
- early / middle / late residual layers: **4, 14, 26**;
|
| 39 |
+
- strongest TopK SAE features at a selected token;
|
| 40 |
+
- reconstruction cosine and normalized MSE;
|
| 41 |
+
- feature **ablation**, **scaling**, or **injection**;
|
| 42 |
+
- baseline vs modified greedy generation;
|
| 43 |
+
- first-token probability table and Jensen-Shannon divergence;
|
| 44 |
+
- optional target-continuation probability / log-probability delta;
|
| 45 |
+
- optional concept hints loaded from real offline benchmark artifacts.
|
| 46 |
+
|
| 47 |
+
### Reconstruction-preserving causal edit
|
| 48 |
+
|
| 49 |
+
Qwen-Scope provides encoder and decoder weights. Let the original residual be `h`, the selected sparse feature activation be `z_i`, and the feature decoder direction be `d_i`.
|
| 50 |
+
|
| 51 |
+
FeatureLens patches the **original** residual:
|
| 52 |
+
|
| 53 |
+
```text
|
| 54 |
+
ablate: h' = h - z_i d_i
|
| 55 |
+
scale α: h' = h + (α - 1) z_i d_i
|
| 56 |
+
inject δ: h' = h + δ d_i
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
It does **not** replace `h` with the SAE reconstruction. This keeps SAE reconstruction error out of the causal perturbation.
|
| 60 |
+
|
| 61 |
+
## Offline experiment
|
| 62 |
+
|
| 63 |
+
The repository ships a controlled benchmark with:
|
| 64 |
+
|
| 65 |
+
- **224 discovery prompts** across 7 concepts;
|
| 66 |
+
- **112 paraphrase pairs** kept together during splitting;
|
| 67 |
+
- concepts: code, mathematics, positive sentiment, negative sentiment, French, factual entities, uncertainty;
|
| 68 |
+
- **28 separate causal completion tasks**;
|
| 69 |
+
- one forward pass captures all configured residual layers for each batch;
|
| 70 |
+
- TopK sparse feature activations and dense residuals are saved separately.
|
| 71 |
+
|
| 72 |
+
The evaluation computes:
|
| 73 |
+
|
| 74 |
+
- SAE reconstruction cosine / NMSE;
|
| 75 |
+
- actual active-feature count / sparsity;
|
| 76 |
+
- held-out feature/concept AUROC and F1;
|
| 77 |
+
- paraphrase TopK Jaccard and sparse-activation cosine;
|
| 78 |
+
- layer-wise multinomial linear probes on dense residual states;
|
| 79 |
+
- selected-feature ablation and 2× amplification;
|
| 80 |
+
- target first-token probability, log-probability, rank and JS divergence;
|
| 81 |
+
- **norm-matched random residual-direction controls**.
|
| 82 |
+
|
| 83 |
+
Feature selection uses the **training split**. Held-out AUROC/F1 are reported afterward. Paraphrases from the same pair never cross the train/test boundary.
|
| 84 |
+
|
| 85 |
+
## Run the full benchmark
|
| 86 |
+
|
| 87 |
+
A CUDA machine is strongly recommended. The default pipeline downloads Qwen3-1.7B-Base plus the three selected Qwen-Scope layer checkpoints.
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
python -m venv .venv
|
| 91 |
+
source .venv/bin/activate
|
| 92 |
+
pip install -r requirements.txt
|
| 93 |
+
python experiments/run_all.py
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
Outputs are materialized under `artifacts/`:
|
| 97 |
+
|
| 98 |
+
```text
|
| 99 |
+
artifacts/
|
| 100 |
+
├── activations/
|
| 101 |
+
│ ├── metadata.json
|
| 102 |
+
│ ├── residuals_layer4.npy
|
| 103 |
+
│ ├── features_layer4.npz
|
| 104 |
+
│ └── ...
|
| 105 |
+
├── feature_catalog.csv
|
| 106 |
+
├── layer_metrics.csv
|
| 107 |
+
├── stability.csv
|
| 108 |
+
├── causal_results.csv
|
| 109 |
+
├── summary.json
|
| 110 |
+
├── report.md
|
| 111 |
+
└── figures/
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
`report.md` and `summary.json` are generated from the measured results. The code contains no fabricated benchmark numbers.
|
| 115 |
+
|
| 116 |
+
## Hugging Face ZeroGPU deployment
|
| 117 |
+
|
| 118 |
+
FeatureLens is intentionally a **Gradio SDK Space**, not a Docker Space, because ZeroGPU currently supports Gradio SDK Spaces.
|
| 119 |
+
|
| 120 |
+
1. Create a new **Gradio** Space.
|
| 121 |
+
2. In the Space hardware settings, select **ZeroGPU**.
|
| 122 |
+
3. Push this repository as-is.
|
| 123 |
+
4. No API key is required; both Qwen repositories are public.
|
| 124 |
+
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.
|
| 125 |
+
|
| 126 |
+
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.
|
| 127 |
+
|
| 128 |
+
Useful environment overrides:
|
| 129 |
+
|
| 130 |
+
```text
|
| 131 |
+
FEATURELENS_MODEL_ID=Qwen/Qwen3-1.7B-Base
|
| 132 |
+
FEATURELENS_SAE_REPO=Qwen/SAE-Res-Qwen3-1.7B-Base-W32K-L0_50
|
| 133 |
+
FEATURELENS_LAYERS=4,14,26
|
| 134 |
+
FEATURELENS_EAGER_LOAD=1
|
| 135 |
+
FEATURELENS_SAE_DTYPE=float16
|
| 136 |
+
FEATURELENS_MAX_NEW_TOKENS=32
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
For offline benchmarking, use `FEATURELENS_SAE_DTYPE=float32` (the scripts already use FP32 SAE weights by default).
|
| 140 |
+
|
| 141 |
+
## Repository layout
|
| 142 |
+
|
| 143 |
+
```text
|
| 144 |
+
FeatureLens/
|
| 145 |
+
├── app.py # Gradio / ZeroGPU workbench
|
| 146 |
+
├── featurelens/
|
| 147 |
+
│ ├── config.py # model + layer configuration
|
| 148 |
+
│ ├── sae.py # TopK SAE loading / encode / decode
|
| 149 |
+
│ ├── interventions.py # causal deltas + random controls
|
| 150 |
+
│ ├── runtime.py # hooks, generation, probability deltas
|
| 151 |
+
│ ├── metrics.py # reconstruction + divergence metrics
|
| 152 |
+
│ └── catalog.py # offline result integration
|
| 153 |
+
├── experiments/
|
| 154 |
+
│ ├── build_dataset.py
|
| 155 |
+
│ ├── collect_activations.py
|
| 156 |
+
│ ├── evaluate_features.py
|
| 157 |
+
│ ├── run_causal.py
|
| 158 |
+
│ ├── make_report.py
|
| 159 |
+
│ └── run_all.py
|
| 160 |
+
├── data/
|
| 161 |
+
│ ├── prompts.jsonl
|
| 162 |
+
│ └── causal_tasks.jsonl
|
| 163 |
+
├── tests/
|
| 164 |
+
├── scripts/release_check.py
|
| 165 |
+
├── docs/
|
| 166 |
+
└── research_config.json
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
## Methodological limitations
|
| 170 |
+
|
| 171 |
+
- Qwen-Scope features are TopK sparse directions, not guaranteed monosemantic concepts.
|
| 172 |
+
- The controlled benchmark is intentionally small enough to reproduce on modest research compute; it is not a universal feature ontology.
|
| 173 |
+
- 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.
|
| 174 |
+
- A target string may tokenize into multiple tokens. The live workbench explicitly labels its target metric as the **first-token** probability in that case.
|
| 175 |
+
- 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.
|
| 176 |
+
|
| 177 |
+
## Reproducibility and checks
|
| 178 |
+
|
| 179 |
+
```bash
|
| 180 |
+
python -m ruff check app.py featurelens experiments tests scripts
|
| 181 |
+
python -m pytest -q
|
| 182 |
+
python -m compileall -q app.py featurelens experiments scripts
|
| 183 |
+
python scripts/release_check.py
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
## Resume-ready description
|
| 187 |
+
|
| 188 |
+
> **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
|
| 189 |
+
> Built an SAE-based interpretability system for Qwen3-1.7B that discovers concept-associated residual features, benchmarks held-out predictiveness and paraphrase stability against linear probes, and causally ablates/amplifies features with norm-matched controls while measuring downstream token-probability and generation shifts.
|
| 190 |
+
|
| 191 |
+
## Acknowledgements
|
| 192 |
+
|
| 193 |
+
FeatureLens builds on the open Qwen3 model and Qwen-Scope SAE checkpoints from the Qwen team. See the upstream model cards and Qwen-Scope technical report for model and SAE details and licensing requirements.
|
app.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import gradio as gr
|
| 4 |
+
|
| 5 |
+
from featurelens.config import SETTINGS
|
| 6 |
+
from featurelens.hf_runtime import gpu
|
| 7 |
+
from featurelens.runtime import RUNTIME
|
| 8 |
+
|
| 9 |
+
CSS = """
|
| 10 |
+
:root { --fl-blue:#2563eb; --fl-ink:#0f172a; --fl-muted:#64748b; }
|
| 11 |
+
.gradio-container { max-width: 1280px !important; }
|
| 12 |
+
.hero { padding: 12px 2px 6px; }
|
| 13 |
+
.hero h1 { margin-bottom: 2px; font-size: 2.1rem; letter-spacing:-0.03em; }
|
| 14 |
+
.hero p { color: var(--fl-muted); margin-top: 0; }
|
| 15 |
+
.panel { border:1px solid #e2e8f0; border-radius:14px; padding:8px; }
|
| 16 |
+
.token-wrap { display:flex; flex-wrap:wrap; gap:5px; padding:8px 2px; line-height:1.7; }
|
| 17 |
+
.token { background:#f1f5f9; border:1px solid #e2e8f0; border-radius:6px; padding:2px 7px; font-family:ui-monospace,SFMono-Regular,monospace; font-size:12px; }
|
| 18 |
+
.token.selected { background:#dbeafe; border:2px solid #2563eb; color:#1e3a8a; }
|
| 19 |
+
.token sup { opacity:.55; margin-right:4px; }
|
| 20 |
+
.small-note { color:#64748b; font-size:12px; }
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _analysis_metrics_markdown(result) -> str:
|
| 25 |
+
return (
|
| 26 |
+
f"**Layer {result.layer} · token {result.token_index}** \n"
|
| 27 |
+
f"Active SAE features: **{int(result.metrics['active_features'])}/{SETTINGS.sae_top_k}** \n"
|
| 28 |
+
f"Reconstruction cosine: **{result.metrics['cosine']:.4f}** \n"
|
| 29 |
+
f"Normalized MSE: **{result.metrics['nmse']:.4f}**"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _intervention_metrics_markdown(result) -> str:
|
| 34 |
+
target = 'No target continuation supplied.'
|
| 35 |
+
if result.baseline_target_prob is not None:
|
| 36 |
+
warning = ''
|
| 37 |
+
if result.target_token_count > 1:
|
| 38 |
+
warning = (
|
| 39 |
+
f" \n_Note: target text tokenizes to {result.target_token_count} tokens; "
|
| 40 |
+
'the displayed probability is for its first token only._'
|
| 41 |
+
)
|
| 42 |
+
target = (
|
| 43 |
+
f"Target first token: `{result.target_token}` \n"
|
| 44 |
+
f"Baseline p: **{result.baseline_target_prob:.6f}** · "
|
| 45 |
+
f"Modified p: **{result.modified_target_prob:.6f}** \n"
|
| 46 |
+
f"Δ log p: **{result.target_logprob_delta:+.4f}**{warning}"
|
| 47 |
+
)
|
| 48 |
+
return (
|
| 49 |
+
f"Original feature activation: **{result.feature_activation:.4f}** \n"
|
| 50 |
+
f"Δ feature coefficient: **{result.delta_activation:+.4f}** \n"
|
| 51 |
+
f"Residual perturbation L2: **{result.perturbation_norm:.4f}** \n"
|
| 52 |
+
f"Next-token JS divergence: **{result.js_divergence:.6f}** \n\n"
|
| 53 |
+
f"{target}"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@gpu(duration=45)
|
| 58 |
+
def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
|
| 59 |
+
if not prompt.strip():
|
| 60 |
+
raise gr.Error('Enter a prompt first.')
|
| 61 |
+
result = RUNTIME.analyze(prompt, int(layer), int(token_index), int(top_n))
|
| 62 |
+
choices = [str(int(row[1])) for row in result.rows]
|
| 63 |
+
feature_update = gr.update(choices=choices, value=choices[0] if choices else None)
|
| 64 |
+
return (
|
| 65 |
+
RUNTIME.token_html(result.tokens, result.token_index),
|
| 66 |
+
result.rows,
|
| 67 |
+
feature_update,
|
| 68 |
+
_analysis_metrics_markdown(result),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@gpu(duration=60)
|
| 73 |
+
def run_intervention(
|
| 74 |
+
prompt: str,
|
| 75 |
+
layer: int,
|
| 76 |
+
token_index: int,
|
| 77 |
+
feature_id: str,
|
| 78 |
+
mode: str,
|
| 79 |
+
coefficient: float,
|
| 80 |
+
target_text: str,
|
| 81 |
+
max_new_tokens: int,
|
| 82 |
+
):
|
| 83 |
+
if not prompt.strip():
|
| 84 |
+
raise gr.Error('Enter a prompt first.')
|
| 85 |
+
if feature_id is None or str(feature_id).strip() == '':
|
| 86 |
+
raise gr.Error('Choose or enter a feature id.')
|
| 87 |
+
try:
|
| 88 |
+
fid = int(float(feature_id))
|
| 89 |
+
except ValueError as exc:
|
| 90 |
+
raise gr.Error('Feature id must be an integer.') from exc
|
| 91 |
+
result = RUNTIME.intervene(
|
| 92 |
+
text=prompt,
|
| 93 |
+
layer=int(layer),
|
| 94 |
+
token_index=int(token_index),
|
| 95 |
+
feature_id=fid,
|
| 96 |
+
mode=mode,
|
| 97 |
+
coefficient=float(coefficient),
|
| 98 |
+
target_text=target_text,
|
| 99 |
+
max_new_tokens=int(max_new_tokens),
|
| 100 |
+
)
|
| 101 |
+
return (
|
| 102 |
+
result.baseline_text,
|
| 103 |
+
result.modified_text,
|
| 104 |
+
_intervention_metrics_markdown(result),
|
| 105 |
+
result.top_token_rows,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def mode_help(mode: str):
|
| 110 |
+
if mode == 'ablate':
|
| 111 |
+
return gr.update(
|
| 112 |
+
value=0.0,
|
| 113 |
+
interactive=False,
|
| 114 |
+
label='Coefficient (unused for ablation)',
|
| 115 |
+
info='Ablation sets the selected TopK feature coefficient to zero.',
|
| 116 |
+
)
|
| 117 |
+
if mode == 'scale':
|
| 118 |
+
return gr.update(
|
| 119 |
+
value=2.0,
|
| 120 |
+
interactive=True,
|
| 121 |
+
label='Feature multiplier',
|
| 122 |
+
info='1.0 = unchanged, 0 = ablate, 2.0 = double the original coefficient.',
|
| 123 |
+
)
|
| 124 |
+
return gr.update(
|
| 125 |
+
value=5.0,
|
| 126 |
+
interactive=True,
|
| 127 |
+
label='Additive feature coefficient',
|
| 128 |
+
info='Adds this amount along the feature decoder direction, even if the feature is inactive.',
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
with gr.Blocks(title='FeatureLens — Causal Interpretability Workbench') as demo:
|
| 133 |
+
gr.HTML(
|
| 134 |
+
'<div class="hero"><h1>FeatureLens</h1>'
|
| 135 |
+
'<p>Causal sparse-feature interpretability for Qwen3-1.7B: inspect → intervene → measure.</p></div>'
|
| 136 |
+
)
|
| 137 |
+
gr.Markdown(
|
| 138 |
+
f"Model: `{SETTINGS.model_id}` · Qwen-Scope TopK SAE · layers "
|
| 139 |
+
f"`{', '.join(map(str, SETTINGS.layers))}` · width `{SETTINGS.sae_width:,}`"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
with gr.Tab('Workbench'):
|
| 143 |
+
with gr.Row(equal_height=False):
|
| 144 |
+
with gr.Column(scale=2):
|
| 145 |
+
prompt = gr.Textbox(
|
| 146 |
+
label='Prompt',
|
| 147 |
+
lines=7,
|
| 148 |
+
value='The derivative of x squared is',
|
| 149 |
+
placeholder='Enter a prompt to inspect...',
|
| 150 |
+
)
|
| 151 |
+
with gr.Row():
|
| 152 |
+
layer = gr.Dropdown(
|
| 153 |
+
choices=list(SETTINGS.layers),
|
| 154 |
+
value=SETTINGS.layers[1] if len(SETTINGS.layers) > 1 else SETTINGS.layers[0],
|
| 155 |
+
label='Residual layer',
|
| 156 |
+
)
|
| 157 |
+
token_index = gr.Number(
|
| 158 |
+
value=-1,
|
| 159 |
+
precision=0,
|
| 160 |
+
label='Token index',
|
| 161 |
+
info='Use -1 for the final prompt token. Analyze once to see all token indices.',
|
| 162 |
+
)
|
| 163 |
+
top_n = gr.Slider(5, 20, value=12, step=1, label='Top features')
|
| 164 |
+
analyze_btn = gr.Button('Inspect SAE features', variant='primary')
|
| 165 |
+
token_view = gr.HTML(
|
| 166 |
+
'<div class="small-note">Token positions will appear here after analysis.</div>'
|
| 167 |
+
)
|
| 168 |
+
analysis_metrics = gr.Markdown()
|
| 169 |
+
|
| 170 |
+
with gr.Column(scale=3):
|
| 171 |
+
feature_table = gr.Dataframe(
|
| 172 |
+
headers=['Rank', 'Feature id', 'Activation', 'Offline concept hint'],
|
| 173 |
+
datatype=['number', 'number', 'number', 'str'],
|
| 174 |
+
interactive=False,
|
| 175 |
+
label='Strongest TopK features at the selected token',
|
| 176 |
+
wrap=True,
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
gr.Markdown('### Causal intervention')
|
| 180 |
+
gr.Markdown(
|
| 181 |
+
'The edit is **reconstruction-preserving**: FeatureLens changes only the selected SAE '
|
| 182 |
+
'coefficient and adds the decoded delta to the *original* residual stream. It does not '
|
| 183 |
+
'replace the residual with an SAE reconstruction.'
|
| 184 |
+
)
|
| 185 |
+
with gr.Row(equal_height=False):
|
| 186 |
+
with gr.Column(scale=2):
|
| 187 |
+
feature_id = gr.Dropdown(
|
| 188 |
+
choices=[],
|
| 189 |
+
allow_custom_value=True,
|
| 190 |
+
label='Feature id',
|
| 191 |
+
info='Analyze first to populate the strongest features, or enter any id 0–32767.',
|
| 192 |
+
)
|
| 193 |
+
mode = gr.Radio(
|
| 194 |
+
choices=['ablate', 'scale', 'inject'],
|
| 195 |
+
value='ablate',
|
| 196 |
+
label='Intervention',
|
| 197 |
+
)
|
| 198 |
+
coefficient = gr.Number(
|
| 199 |
+
value=0.0,
|
| 200 |
+
interactive=False,
|
| 201 |
+
label='Coefficient (unused for ablation)',
|
| 202 |
+
)
|
| 203 |
+
target_text = gr.Textbox(
|
| 204 |
+
label='Optional target continuation',
|
| 205 |
+
placeholder='e.g. 2x',
|
| 206 |
+
info='FeatureLens reports the first-token probability shift for this continuation.',
|
| 207 |
+
)
|
| 208 |
+
max_new = gr.Slider(
|
| 209 |
+
4,
|
| 210 |
+
SETTINGS.max_new_tokens,
|
| 211 |
+
value=min(20, SETTINGS.max_new_tokens),
|
| 212 |
+
step=1,
|
| 213 |
+
label='Max new tokens',
|
| 214 |
+
)
|
| 215 |
+
intervene_btn = gr.Button('Run baseline vs modified', variant='primary')
|
| 216 |
+
intervention_metrics = gr.Markdown()
|
| 217 |
+
|
| 218 |
+
with gr.Column(scale=3):
|
| 219 |
+
with gr.Row():
|
| 220 |
+
baseline_out = gr.Textbox(label='Baseline output', lines=8, interactive=False)
|
| 221 |
+
modified_out = gr.Textbox(label='Modified output', lines=8, interactive=False)
|
| 222 |
+
token_prob_table = gr.Dataframe(
|
| 223 |
+
headers=['Token', 'Baseline p', 'Modified p', 'Δ probability'],
|
| 224 |
+
datatype=['str', 'number', 'number', 'number'],
|
| 225 |
+
interactive=False,
|
| 226 |
+
label='First generated token distribution',
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
analyze_btn.click(
|
| 230 |
+
analyze_prompt,
|
| 231 |
+
inputs=[prompt, layer, token_index, top_n],
|
| 232 |
+
outputs=[token_view, feature_table, feature_id, analysis_metrics],
|
| 233 |
+
)
|
| 234 |
+
mode.change(mode_help, inputs=[mode], outputs=[coefficient])
|
| 235 |
+
intervene_btn.click(
|
| 236 |
+
run_intervention,
|
| 237 |
+
inputs=[
|
| 238 |
+
prompt,
|
| 239 |
+
layer,
|
| 240 |
+
token_index,
|
| 241 |
+
feature_id,
|
| 242 |
+
mode,
|
| 243 |
+
coefficient,
|
| 244 |
+
target_text,
|
| 245 |
+
max_new,
|
| 246 |
+
],
|
| 247 |
+
outputs=[baseline_out, modified_out, intervention_metrics, token_prob_table],
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
with gr.Tab('Offline benchmark'):
|
| 251 |
+
benchmark_md = gr.Markdown(RUNTIME.catalog.benchmark_markdown())
|
| 252 |
+
gr.Markdown(
|
| 253 |
+
'The offline pipeline evaluates feature/concept AUROC + F1, reconstruction quality, '
|
| 254 |
+
'paraphrase stability, layer-wise residual linear probes, causal ablation/amplification, '
|
| 255 |
+
'and norm-matched random-direction controls. Results are loaded from `artifacts/` and '
|
| 256 |
+
'are never hard-coded into the demo.'
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
with gr.Tab('Method'):
|
| 260 |
+
gr.Markdown(
|
| 261 |
+
r"""
|
| 262 |
+
### What the intervention means
|
| 263 |
+
|
| 264 |
+
For residual vector $h$, sparse code $z$, decoder column $d_i$, and selected feature $i$:
|
| 265 |
+
|
| 266 |
+
- **Ablate:** $h' = h - z_i d_i$
|
| 267 |
+
- **Scale by $\alpha$:** $h' = h + (\alpha - 1) z_i d_i$
|
| 268 |
+
- **Inject $\delta$:** $h' = h + \delta d_i$
|
| 269 |
+
|
| 270 |
+
This is equivalent to editing the SAE reconstruction by the chosen feature delta while retaining
|
| 271 |
+
$h$ itself, so SAE reconstruction error is not injected as a confound. The modified residual is
|
| 272 |
+
patched at one selected **prompt token**; downstream generation is then allowed to evolve normally.
|
| 273 |
+
|
| 274 |
+
### What FeatureLens does *not* claim
|
| 275 |
+
|
| 276 |
+
A high feature/concept AUROC is correlational evidence. A causal claim requires downstream effects
|
| 277 |
+
under intervention, held-out prompts, and comparison with controls. The offline report is designed
|
| 278 |
+
to make a weak or null causal result visible rather than hide it.
|
| 279 |
+
"""
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
gr.Markdown(
|
| 283 |
+
'Built with PyTorch, Transformers, Qwen3-1.7B-Base and Qwen-Scope SAEs. '
|
| 284 |
+
'This project is independent of any thesis dataset or thesis code.'
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
if __name__ == '__main__':
|
| 288 |
+
demo.queue(default_concurrency_limit=1).launch(css=CSS)
|
artifacts/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generated artifacts
|
| 2 |
+
|
| 3 |
+
This directory intentionally ships without invented results.
|
| 4 |
+
|
| 5 |
+
Run:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
python experiments/run_all.py
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
to create activation caches, `feature_catalog.csv`, `layer_metrics.csv`, `stability.csv`, `causal_results.csv`, `summary.json`, `report.md`, and figures.
|
| 12 |
+
|
| 13 |
+
Large activation arrays are ignored by Git. Commit the small CSV/JSON/report/figure outputs if you want the live Space to show benchmark-derived feature hints and the benchmark summary.
|
data/causal_tasks.jsonl
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"id": 0, "concept": "positive_sentiment", "prompt": "Sentiment: The meal was wonderful and the staff were kind. Label:", "target": " positive"}
|
| 2 |
+
{"id": 1, "concept": "positive_sentiment", "prompt": "Sentiment: I loved the thoughtful, beautifully written novel. Label:", "target": " positive"}
|
| 3 |
+
{"id": 2, "concept": "positive_sentiment", "prompt": "Sentiment: The service was fast and extremely helpful. Label:", "target": " positive"}
|
| 4 |
+
{"id": 3, "concept": "positive_sentiment", "prompt": "Sentiment: The journey was comfortable and enjoyable. Label:", "target": " positive"}
|
| 5 |
+
{"id": 4, "concept": "negative_sentiment", "prompt": "Sentiment: The meal was awful and the staff were rude. Label:", "target": " negative"}
|
| 6 |
+
{"id": 5, "concept": "negative_sentiment", "prompt": "Sentiment: I hated the tedious, badly written novel. Label:", "target": " negative"}
|
| 7 |
+
{"id": 6, "concept": "negative_sentiment", "prompt": "Sentiment: The service was slow and completely unhelpful. Label:", "target": " negative"}
|
| 8 |
+
{"id": 7, "concept": "negative_sentiment", "prompt": "Sentiment: The journey was stressful and unpleasant. Label:", "target": " negative"}
|
| 9 |
+
{"id": 8, "concept": "mathematics", "prompt": "2 + 3 =", "target": " 5"}
|
| 10 |
+
{"id": 9, "concept": "mathematics", "prompt": "7 - 4 =", "target": " 3"}
|
| 11 |
+
{"id": 10, "concept": "mathematics", "prompt": "6 * 2 =", "target": " 12"}
|
| 12 |
+
{"id": 11, "concept": "mathematics", "prompt": "The square root of 81 is", "target": " 9"}
|
| 13 |
+
{"id": 12, "concept": "code", "prompt": "Python function declaration keyword:", "target": " def"}
|
| 14 |
+
{"id": 13, "concept": "code", "prompt": "In Python, an exception handler begins with the keyword", "target": " except"}
|
| 15 |
+
{"id": 14, "concept": "code", "prompt": "SQL keyword used to retrieve rows:", "target": " SELECT"}
|
| 16 |
+
{"id": 15, "concept": "code", "prompt": "A Python conditional branch commonly starts with", "target": " if"}
|
| 17 |
+
{"id": 16, "concept": "french_language", "prompt": "Translate 'hello' into French:", "target": " bonjour"}
|
| 18 |
+
{"id": 17, "concept": "french_language", "prompt": "Translate 'thank you' into French:", "target": " merci"}
|
| 19 |
+
{"id": 18, "concept": "french_language", "prompt": "Translate 'yes' into French:", "target": " oui"}
|
| 20 |
+
{"id": 19, "concept": "french_language", "prompt": "Translate 'good evening' into French:", "target": " bonsoir"}
|
| 21 |
+
{"id": 20, "concept": "factual_entities", "prompt": "The scientist associated with radium research, Marie", "target": " Curie"}
|
| 22 |
+
{"id": 21, "concept": "factual_entities", "prompt": "The composer of the Fifth Symphony, Ludwig van", "target": " Beethoven"}
|
| 23 |
+
{"id": 22, "concept": "factual_entities", "prompt": "The computer scientist known for the Turing machine, Alan", "target": " Turing"}
|
| 24 |
+
{"id": 23, "concept": "factual_entities", "prompt": "The Japanese city famous for many historic temples,", "target": " Kyoto"}
|
| 25 |
+
{"id": 24, "concept": "uncertainty", "prompt": "The evidence is insufficient. The answer is", "target": " unknown"}
|
| 26 |
+
{"id": 25, "concept": "uncertainty", "prompt": "The passage never states the value, so it is", "target": " unknown"}
|
| 27 |
+
{"id": 26, "concept": "uncertainty", "prompt": "There is not enough information to determine the result. It remains", "target": " uncertain"}
|
| 28 |
+
{"id": 27, "concept": "uncertainty", "prompt": "No reliable conclusion can be drawn; the outcome is", "target": " unclear"}
|
data/prompts.jsonl
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"id": 0, "concept": "code", "pair_id": "code-00", "variant": 0, "text": "Write a Python function that returns the larger of two integers."}
|
| 2 |
+
{"id": 1, "concept": "code", "pair_id": "code-00", "variant": 1, "text": "In Python, create a function that chooses the maximum of two integer inputs."}
|
| 3 |
+
{"id": 2, "concept": "code", "pair_id": "code-01", "variant": 0, "text": "Explain what a recursive function does in a program."}
|
| 4 |
+
{"id": 3, "concept": "code", "pair_id": "code-01", "variant": 1, "text": "Describe recursion in the context of a programming function."}
|
| 5 |
+
{"id": 4, "concept": "code", "pair_id": "code-02", "variant": 0, "text": "Show a JavaScript loop that prints numbers from one to five."}
|
| 6 |
+
{"id": 5, "concept": "code", "pair_id": "code-02", "variant": 1, "text": "Give JavaScript code for iterating over the integers 1 through 5 and logging them."}
|
| 7 |
+
{"id": 6, "concept": "code", "pair_id": "code-03", "variant": 0, "text": "What is the purpose of a hash map in software?"}
|
| 8 |
+
{"id": 7, "concept": "code", "pair_id": "code-03", "variant": 1, "text": "Explain why programmers use hash maps or dictionaries."}
|
| 9 |
+
{"id": 8, "concept": "code", "pair_id": "code-04", "variant": 0, "text": "Write SQL that selects every row from a table named users."}
|
| 10 |
+
{"id": 9, "concept": "code", "pair_id": "code-04", "variant": 1, "text": "Provide a SQL query to retrieve all records in the users table."}
|
| 11 |
+
{"id": 10, "concept": "code", "pair_id": "code-05", "variant": 0, "text": "How do I catch an exception in Python?"}
|
| 12 |
+
{"id": 11, "concept": "code", "pair_id": "code-05", "variant": 1, "text": "Show the Python syntax for handling an exception with try and except."}
|
| 13 |
+
{"id": 12, "concept": "code", "pair_id": "code-06", "variant": 0, "text": "Explain the difference between a list and a tuple in Python."}
|
| 14 |
+
{"id": 13, "concept": "code", "pair_id": "code-06", "variant": 1, "text": "Compare Python lists with tuples."}
|
| 15 |
+
{"id": 14, "concept": "code", "pair_id": "code-07", "variant": 0, "text": "Write a function that checks whether a string is a palindrome."}
|
| 16 |
+
{"id": 15, "concept": "code", "pair_id": "code-07", "variant": 1, "text": "Create code that tests if text reads the same forwards and backwards."}
|
| 17 |
+
{"id": 16, "concept": "code", "pair_id": "code-08", "variant": 0, "text": "What does git rebase do?"}
|
| 18 |
+
{"id": 17, "concept": "code", "pair_id": "code-08", "variant": 1, "text": "Describe the effect of rebasing a Git branch."}
|
| 19 |
+
{"id": 18, "concept": "code", "pair_id": "code-09", "variant": 0, "text": "How can a REST API return JSON?"}
|
| 20 |
+
{"id": 19, "concept": "code", "pair_id": "code-09", "variant": 1, "text": "Explain how a web API endpoint sends a JSON response."}
|
| 21 |
+
{"id": 20, "concept": "code", "pair_id": "code-10", "variant": 0, "text": "Write pseudocode for binary search."}
|
| 22 |
+
{"id": 21, "concept": "code", "pair_id": "code-10", "variant": 1, "text": "Describe the binary-search algorithm as pseudocode."}
|
| 23 |
+
{"id": 22, "concept": "code", "pair_id": "code-11", "variant": 0, "text": "What is a class constructor used for?"}
|
| 24 |
+
{"id": 23, "concept": "code", "pair_id": "code-11", "variant": 1, "text": "Explain the role of a constructor when creating an object."}
|
| 25 |
+
{"id": 24, "concept": "code", "pair_id": "code-12", "variant": 0, "text": "Give a regular expression that matches a basic email-like string."}
|
| 26 |
+
{"id": 25, "concept": "code", "pair_id": "code-12", "variant": 1, "text": "Show a regex pattern for a simple email address format."}
|
| 27 |
+
{"id": 26, "concept": "code", "pair_id": "code-13", "variant": 0, "text": "Why is time complexity important when comparing algorithms?"}
|
| 28 |
+
{"id": 27, "concept": "code", "pair_id": "code-13", "variant": 1, "text": "Explain why Big-O runtime matters in algorithm analysis."}
|
| 29 |
+
{"id": 28, "concept": "code", "pair_id": "code-14", "variant": 0, "text": "How do unit tests help a codebase?"}
|
| 30 |
+
{"id": 29, "concept": "code", "pair_id": "code-14", "variant": 1, "text": "Describe the purpose of automated unit testing in software."}
|
| 31 |
+
{"id": 30, "concept": "code", "pair_id": "code-15", "variant": 0, "text": "Write Python that sorts a list of dictionaries by an age key."}
|
| 32 |
+
{"id": 31, "concept": "code", "pair_id": "code-15", "variant": 1, "text": "Show how to order Python dictionaries in a list according to their age field."}
|
| 33 |
+
{"id": 32, "concept": "mathematics", "pair_id": "mathematics-00", "variant": 0, "text": "Find the derivative of x cubed plus two x."}
|
| 34 |
+
{"id": 33, "concept": "mathematics", "pair_id": "mathematics-00", "variant": 1, "text": "Differentiate the function x^3 + 2x."}
|
| 35 |
+
{"id": 34, "concept": "mathematics", "pair_id": "mathematics-01", "variant": 0, "text": "Solve the equation 3x + 5 = 20."}
|
| 36 |
+
{"id": 35, "concept": "mathematics", "pair_id": "mathematics-01", "variant": 1, "text": "Find x when three x plus five equals twenty."}
|
| 37 |
+
{"id": 36, "concept": "mathematics", "pair_id": "mathematics-02", "variant": 0, "text": "What is the integral of cosine x?"}
|
| 38 |
+
{"id": 37, "concept": "mathematics", "pair_id": "mathematics-02", "variant": 1, "text": "Compute an antiderivative of cos(x)."}
|
| 39 |
+
{"id": 38, "concept": "mathematics", "pair_id": "mathematics-03", "variant": 0, "text": "Factor the polynomial x squared minus nine."}
|
| 40 |
+
{"id": 39, "concept": "mathematics", "pair_id": "mathematics-03", "variant": 1, "text": "Rewrite x^2 - 9 as a product of factors."}
|
| 41 |
+
{"id": 40, "concept": "mathematics", "pair_id": "mathematics-04", "variant": 0, "text": "Calculate the mean of 4, 7, 9, and 10."}
|
| 42 |
+
{"id": 41, "concept": "mathematics", "pair_id": "mathematics-04", "variant": 1, "text": "What is the arithmetic average of 4, 7, 9, 10?"}
|
| 43 |
+
{"id": 42, "concept": "mathematics", "pair_id": "mathematics-05", "variant": 0, "text": "Explain the Pythagorean theorem."}
|
| 44 |
+
{"id": 43, "concept": "mathematics", "pair_id": "mathematics-05", "variant": 1, "text": "State the relationship among the sides of a right triangle."}
|
| 45 |
+
{"id": 44, "concept": "mathematics", "pair_id": "mathematics-06", "variant": 0, "text": "What is the determinant of a two by two matrix?"}
|
| 46 |
+
{"id": 45, "concept": "mathematics", "pair_id": "mathematics-06", "variant": 1, "text": "Give the formula for the determinant of a 2x2 matrix."}
|
| 47 |
+
{"id": 46, "concept": "mathematics", "pair_id": "mathematics-07", "variant": 0, "text": "Simplify two to the third power times two squared."}
|
| 48 |
+
{"id": 47, "concept": "mathematics", "pair_id": "mathematics-07", "variant": 1, "text": "Reduce 2^3 multiplied by 2^2 using exponent rules."}
|
| 49 |
+
{"id": 48, "concept": "mathematics", "pair_id": "mathematics-08", "variant": 0, "text": "Convert one half into a percentage."}
|
| 50 |
+
{"id": 49, "concept": "mathematics", "pair_id": "mathematics-08", "variant": 1, "text": "Express 1/2 as a percent."}
|
| 51 |
+
{"id": 50, "concept": "mathematics", "pair_id": "mathematics-09", "variant": 0, "text": "What is the probability of heads on a fair coin?"}
|
| 52 |
+
{"id": 51, "concept": "mathematics", "pair_id": "mathematics-09", "variant": 1, "text": "For a fair coin, calculate the chance of flipping heads."}
|
| 53 |
+
{"id": 52, "concept": "mathematics", "pair_id": "mathematics-10", "variant": 0, "text": "Solve x squared equals sixteen."}
|
| 54 |
+
{"id": 53, "concept": "mathematics", "pair_id": "mathematics-10", "variant": 1, "text": "Find the real values of x satisfying x^2 = 16."}
|
| 55 |
+
{"id": 54, "concept": "mathematics", "pair_id": "mathematics-11", "variant": 0, "text": "What is the slope between points (1,2) and (3,6)?"}
|
| 56 |
+
{"id": 55, "concept": "mathematics", "pair_id": "mathematics-11", "variant": 1, "text": "Calculate the gradient of the line through (1,2) and (3,6)."}
|
| 57 |
+
{"id": 56, "concept": "mathematics", "pair_id": "mathematics-12", "variant": 0, "text": "Explain what a prime number is."}
|
| 58 |
+
{"id": 57, "concept": "mathematics", "pair_id": "mathematics-12", "variant": 1, "text": "Define a prime integer."}
|
| 59 |
+
{"id": 58, "concept": "mathematics", "pair_id": "mathematics-13", "variant": 0, "text": "Compute the dot product of vectors (1,2) and (3,4)."}
|
| 60 |
+
{"id": 59, "concept": "mathematics", "pair_id": "mathematics-13", "variant": 1, "text": "Find (1,2) · (3,4)."}
|
| 61 |
+
{"id": 60, "concept": "mathematics", "pair_id": "mathematics-14", "variant": 0, "text": "What is log base ten of one thousand?"}
|
| 62 |
+
{"id": 61, "concept": "mathematics", "pair_id": "mathematics-14", "variant": 1, "text": "Evaluate log_10(1000)."}
|
| 63 |
+
{"id": 62, "concept": "mathematics", "pair_id": "mathematics-15", "variant": 0, "text": "A circle has radius three. What is its area?"}
|
| 64 |
+
{"id": 63, "concept": "mathematics", "pair_id": "mathematics-15", "variant": 1, "text": "Calculate the area of a circle whose radius is 3."}
|
| 65 |
+
{"id": 64, "concept": "positive_sentiment", "pair_id": "positive_sentiment-00", "variant": 0, "text": "The film was delightful, clever, and beautifully acted."}
|
| 66 |
+
{"id": 65, "concept": "positive_sentiment", "pair_id": "positive_sentiment-00", "variant": 1, "text": "I found the movie charming, smart, and wonderfully performed."}
|
| 67 |
+
{"id": 66, "concept": "positive_sentiment", "pair_id": "positive_sentiment-01", "variant": 0, "text": "This restaurant served an excellent meal and the staff were kind."}
|
| 68 |
+
{"id": 67, "concept": "positive_sentiment", "pair_id": "positive_sentiment-01", "variant": 1, "text": "The food was fantastic and the service team was genuinely friendly."}
|
| 69 |
+
{"id": 68, "concept": "positive_sentiment", "pair_id": "positive_sentiment-02", "variant": 0, "text": "I am very pleased with how reliable this laptop has been."}
|
| 70 |
+
{"id": 69, "concept": "positive_sentiment", "pair_id": "positive_sentiment-02", "variant": 1, "text": "This laptop has worked dependably and I am extremely satisfied with it."}
|
| 71 |
+
{"id": 70, "concept": "positive_sentiment", "pair_id": "positive_sentiment-03", "variant": 0, "text": "The concert was energetic and unforgettable."}
|
| 72 |
+
{"id": 71, "concept": "positive_sentiment", "pair_id": "positive_sentiment-03", "variant": 1, "text": "I had an amazing time at the lively, memorable concert."}
|
| 73 |
+
{"id": 72, "concept": "positive_sentiment", "pair_id": "positive_sentiment-04", "variant": 0, "text": "Her explanation was clear and genuinely helpful."}
|
| 74 |
+
{"id": 73, "concept": "positive_sentiment", "pair_id": "positive_sentiment-04", "variant": 1, "text": "She explained the topic in a useful and easy-to-understand way."}
|
| 75 |
+
{"id": 74, "concept": "positive_sentiment", "pair_id": "positive_sentiment-05", "variant": 0, "text": "The hotel room was spotless and comfortable."}
|
| 76 |
+
{"id": 75, "concept": "positive_sentiment", "pair_id": "positive_sentiment-05", "variant": 1, "text": "Our room was exceptionally clean and pleasant to stay in."}
|
| 77 |
+
{"id": 76, "concept": "positive_sentiment", "pair_id": "positive_sentiment-06", "variant": 0, "text": "I loved the book from beginning to end."}
|
| 78 |
+
{"id": 77, "concept": "positive_sentiment", "pair_id": "positive_sentiment-06", "variant": 1, "text": "The novel kept me delighted all the way through."}
|
| 79 |
+
{"id": 78, "concept": "positive_sentiment", "pair_id": "positive_sentiment-07", "variant": 0, "text": "The new update makes the app much easier to use."}
|
| 80 |
+
{"id": 79, "concept": "positive_sentiment", "pair_id": "positive_sentiment-07", "variant": 1, "text": "After the update, the application feels significantly more convenient."}
|
| 81 |
+
{"id": 80, "concept": "positive_sentiment", "pair_id": "positive_sentiment-08", "variant": 0, "text": "Their customer support solved my problem quickly."}
|
| 82 |
+
{"id": 81, "concept": "positive_sentiment", "pair_id": "positive_sentiment-08", "variant": 1, "text": "Support handled the issue fast and left me very happy."}
|
| 83 |
+
{"id": 82, "concept": "positive_sentiment", "pair_id": "positive_sentiment-09", "variant": 0, "text": "The hike had spectacular views and perfect weather."}
|
| 84 |
+
{"id": 83, "concept": "positive_sentiment", "pair_id": "positive_sentiment-09", "variant": 1, "text": "We enjoyed gorgeous scenery and wonderful conditions on the hike."}
|
| 85 |
+
{"id": 84, "concept": "positive_sentiment", "pair_id": "positive_sentiment-10", "variant": 0, "text": "The presentation was engaging and well organized."}
|
| 86 |
+
{"id": 85, "concept": "positive_sentiment", "pair_id": "positive_sentiment-10", "variant": 1, "text": "I enjoyed the talk because it was compelling and structured clearly."}
|
| 87 |
+
{"id": 86, "concept": "positive_sentiment", "pair_id": "positive_sentiment-11", "variant": 0, "text": "This camera takes sharp photos and feels great to use."}
|
| 88 |
+
{"id": 87, "concept": "positive_sentiment", "pair_id": "positive_sentiment-11", "variant": 1, "text": "The camera produces crisp images and has a satisfying design."}
|
| 89 |
+
{"id": 88, "concept": "positive_sentiment", "pair_id": "positive_sentiment-12", "variant": 0, "text": "The workshop exceeded my expectations."}
|
| 90 |
+
{"id": 89, "concept": "positive_sentiment", "pair_id": "positive_sentiment-12", "variant": 1, "text": "I was impressed because the workshop was even better than I expected."}
|
| 91 |
+
{"id": 90, "concept": "positive_sentiment", "pair_id": "positive_sentiment-13", "variant": 0, "text": "Dinner turned out wonderfully and everyone enjoyed it."}
|
| 92 |
+
{"id": 91, "concept": "positive_sentiment", "pair_id": "positive_sentiment-13", "variant": 1, "text": "The evening meal was a success and all of us had a great time."}
|
| 93 |
+
{"id": 92, "concept": "positive_sentiment", "pair_id": "positive_sentiment-14", "variant": 0, "text": "The museum exhibition was fascinating."}
|
| 94 |
+
{"id": 93, "concept": "positive_sentiment", "pair_id": "positive_sentiment-14", "variant": 1, "text": "I thought the exhibition was deeply interesting and rewarding."}
|
| 95 |
+
{"id": 94, "concept": "positive_sentiment", "pair_id": "positive_sentiment-15", "variant": 0, "text": "The train journey was smooth and relaxing."}
|
| 96 |
+
{"id": 95, "concept": "positive_sentiment", "pair_id": "positive_sentiment-15", "variant": 1, "text": "The trip by train felt easy, calm, and comfortable."}
|
| 97 |
+
{"id": 96, "concept": "negative_sentiment", "pair_id": "negative_sentiment-00", "variant": 0, "text": "The film was tedious, confusing, and badly acted."}
|
| 98 |
+
{"id": 97, "concept": "negative_sentiment", "pair_id": "negative_sentiment-00", "variant": 1, "text": "I found the movie boring, incoherent, and poorly performed."}
|
| 99 |
+
{"id": 98, "concept": "negative_sentiment", "pair_id": "negative_sentiment-01", "variant": 0, "text": "This restaurant served cold food and the staff were rude."}
|
| 100 |
+
{"id": 99, "concept": "negative_sentiment", "pair_id": "negative_sentiment-01", "variant": 1, "text": "The meal arrived cold and the service team behaved unpleasantly."}
|
| 101 |
+
{"id": 100, "concept": "negative_sentiment", "pair_id": "negative_sentiment-02", "variant": 0, "text": "I am disappointed by how unreliable this laptop has been."}
|
| 102 |
+
{"id": 101, "concept": "negative_sentiment", "pair_id": "negative_sentiment-02", "variant": 1, "text": "This laptop keeps failing and I am extremely dissatisfied with it."}
|
| 103 |
+
{"id": 102, "concept": "negative_sentiment", "pair_id": "negative_sentiment-03", "variant": 0, "text": "The concert was chaotic and forgettable."}
|
| 104 |
+
{"id": 103, "concept": "negative_sentiment", "pair_id": "negative_sentiment-03", "variant": 1, "text": "I had a miserable time at the disorganized, dull concert."}
|
| 105 |
+
{"id": 104, "concept": "negative_sentiment", "pair_id": "negative_sentiment-04", "variant": 0, "text": "Her explanation was unclear and unhelpful."}
|
| 106 |
+
{"id": 105, "concept": "negative_sentiment", "pair_id": "negative_sentiment-04", "variant": 1, "text": "She explained the topic in a confusing way that did not help me."}
|
| 107 |
+
{"id": 106, "concept": "negative_sentiment", "pair_id": "negative_sentiment-05", "variant": 0, "text": "The hotel room was dirty and uncomfortable."}
|
| 108 |
+
{"id": 107, "concept": "negative_sentiment", "pair_id": "negative_sentiment-05", "variant": 1, "text": "Our room was unpleasant, unclean, and difficult to relax in."}
|
| 109 |
+
{"id": 108, "concept": "negative_sentiment", "pair_id": "negative_sentiment-06", "variant": 0, "text": "I regretted reading the book."}
|
| 110 |
+
{"id": 109, "concept": "negative_sentiment", "pair_id": "negative_sentiment-06", "variant": 1, "text": "The novel was a frustrating waste of my time."}
|
| 111 |
+
{"id": 110, "concept": "negative_sentiment", "pair_id": "negative_sentiment-07", "variant": 0, "text": "The new update makes the app harder to use."}
|
| 112 |
+
{"id": 111, "concept": "negative_sentiment", "pair_id": "negative_sentiment-07", "variant": 1, "text": "After the update, the application feels significantly more awkward."}
|
| 113 |
+
{"id": 112, "concept": "negative_sentiment", "pair_id": "negative_sentiment-08", "variant": 0, "text": "Their customer support ignored my problem."}
|
| 114 |
+
{"id": 113, "concept": "negative_sentiment", "pair_id": "negative_sentiment-08", "variant": 1, "text": "Support failed to resolve the issue and left me angry."}
|
| 115 |
+
{"id": 114, "concept": "negative_sentiment", "pair_id": "negative_sentiment-09", "variant": 0, "text": "The hike had awful weather and disappointing views."}
|
| 116 |
+
{"id": 115, "concept": "negative_sentiment", "pair_id": "negative_sentiment-09", "variant": 1, "text": "We dealt with terrible conditions and underwhelming scenery on the hike."}
|
| 117 |
+
{"id": 116, "concept": "negative_sentiment", "pair_id": "negative_sentiment-10", "variant": 0, "text": "The presentation was dull and poorly organized."}
|
| 118 |
+
{"id": 117, "concept": "negative_sentiment", "pair_id": "negative_sentiment-10", "variant": 1, "text": "I disliked the talk because it was tedious and structured badly."}
|
| 119 |
+
{"id": 118, "concept": "negative_sentiment", "pair_id": "negative_sentiment-11", "variant": 0, "text": "This camera takes blurry photos and feels cheap."}
|
| 120 |
+
{"id": 119, "concept": "negative_sentiment", "pair_id": "negative_sentiment-11", "variant": 1, "text": "The camera produces soft images and has a flimsy design."}
|
| 121 |
+
{"id": 120, "concept": "negative_sentiment", "pair_id": "negative_sentiment-12", "variant": 0, "text": "The workshop fell far below my expectations."}
|
| 122 |
+
{"id": 121, "concept": "negative_sentiment", "pair_id": "negative_sentiment-12", "variant": 1, "text": "I was disappointed because the workshop was much worse than I expected."}
|
| 123 |
+
{"id": 122, "concept": "negative_sentiment", "pair_id": "negative_sentiment-13", "variant": 0, "text": "Dinner went badly and nobody enjoyed it."}
|
| 124 |
+
{"id": 123, "concept": "negative_sentiment", "pair_id": "negative_sentiment-13", "variant": 1, "text": "The evening meal was a failure and all of us had a poor time."}
|
| 125 |
+
{"id": 124, "concept": "negative_sentiment", "pair_id": "negative_sentiment-14", "variant": 0, "text": "The museum exhibition was painfully boring."}
|
| 126 |
+
{"id": 125, "concept": "negative_sentiment", "pair_id": "negative_sentiment-14", "variant": 1, "text": "I thought the exhibition was dull and unrewarding."}
|
| 127 |
+
{"id": 126, "concept": "negative_sentiment", "pair_id": "negative_sentiment-15", "variant": 0, "text": "The train journey was stressful and uncomfortable."}
|
| 128 |
+
{"id": 127, "concept": "negative_sentiment", "pair_id": "negative_sentiment-15", "variant": 1, "text": "The trip by train felt frustrating, noisy, and unpleasant."}
|
| 129 |
+
{"id": 128, "concept": "french_language", "pair_id": "french_language-00", "variant": 0, "text": "Bonjour, comment allez-vous aujourd’hui ?"}
|
| 130 |
+
{"id": 129, "concept": "french_language", "pair_id": "french_language-00", "variant": 1, "text": "Salut, comment vas-tu aujourd’hui ?"}
|
| 131 |
+
{"id": 130, "concept": "french_language", "pair_id": "french_language-01", "variant": 0, "text": "Je voudrais réserver une table pour deux personnes."}
|
| 132 |
+
{"id": 131, "concept": "french_language", "pair_id": "french_language-01", "variant": 1, "text": "Puis-je réserver une table pour deux, s’il vous plaît ?"}
|
| 133 |
+
{"id": 132, "concept": "french_language", "pair_id": "french_language-02", "variant": 0, "text": "La bibliothèque ferme à dix-huit heures."}
|
| 134 |
+
{"id": 133, "concept": "french_language", "pair_id": "french_language-02", "variant": 1, "text": "La bibliothèque est ouverte jusqu’à dix-huit heures."}
|
| 135 |
+
{"id": 134, "concept": "french_language", "pair_id": "french_language-03", "variant": 0, "text": "Ce livre raconte une histoire très intéressante."}
|
| 136 |
+
{"id": 135, "concept": "french_language", "pair_id": "french_language-03", "variant": 1, "text": "L’histoire racontée dans ce livre est vraiment intéressante."}
|
| 137 |
+
{"id": 136, "concept": "french_language", "pair_id": "french_language-04", "variant": 0, "text": "Nous allons prendre le train demain matin."}
|
| 138 |
+
{"id": 137, "concept": "french_language", "pair_id": "french_language-04", "variant": 1, "text": "Demain matin, nous voyagerons en train."}
|
| 139 |
+
{"id": 138, "concept": "french_language", "pair_id": "french_language-05", "variant": 0, "text": "Pouvez-vous m’indiquer le chemin vers la gare ?"}
|
| 140 |
+
{"id": 139, "concept": "french_language", "pair_id": "french_language-05", "variant": 1, "text": "Comment puis-je aller jusqu’à la gare ?"}
|
| 141 |
+
{"id": 140, "concept": "french_language", "pair_id": "french_language-06", "variant": 0, "text": "J’aime apprendre de nouvelles langues."}
|
| 142 |
+
{"id": 141, "concept": "french_language", "pair_id": "french_language-06", "variant": 1, "text": "Apprendre des langues nouvelles me plaît beaucoup."}
|
| 143 |
+
{"id": 142, "concept": "french_language", "pair_id": "french_language-07", "variant": 0, "text": "Le temps est magnifique au bord de la mer."}
|
| 144 |
+
{"id": 143, "concept": "french_language", "pair_id": "french_language-07", "variant": 1, "text": "Il fait très beau près de la mer."}
|
| 145 |
+
{"id": 144, "concept": "french_language", "pair_id": "french_language-08", "variant": 0, "text": "Elle prépare le dîner dans la cuisine."}
|
| 146 |
+
{"id": 145, "concept": "french_language", "pair_id": "french_language-08", "variant": 1, "text": "Dans la cuisine, elle est en train de préparer le repas du soir."}
|
| 147 |
+
{"id": 146, "concept": "french_language", "pair_id": "french_language-09", "variant": 0, "text": "Nous avons visité un musée pendant le week-end."}
|
| 148 |
+
{"id": 147, "concept": "french_language", "pair_id": "french_language-09", "variant": 1, "text": "Ce week-end, nous sommes allés voir un musée."}
|
| 149 |
+
{"id": 148, "concept": "french_language", "pair_id": "french_language-10", "variant": 0, "text": "La réunion commence à neuf heures précises."}
|
| 150 |
+
{"id": 149, "concept": "french_language", "pair_id": "french_language-10", "variant": 1, "text": "Le rendez-vous débute exactement à neuf heures."}
|
| 151 |
+
{"id": 150, "concept": "french_language", "pair_id": "french_language-11", "variant": 0, "text": "Mon ordinateur ne fonctionne plus correctement."}
|
| 152 |
+
{"id": 151, "concept": "french_language", "pair_id": "french_language-11", "variant": 1, "text": "J’ai un problème : mon ordinateur marche mal maintenant."}
|
| 153 |
+
{"id": 152, "concept": "french_language", "pair_id": "french_language-12", "variant": 0, "text": "Cette ville possède de nombreux bâtiments historiques."}
|
| 154 |
+
{"id": 153, "concept": "french_language", "pair_id": "french_language-12", "variant": 1, "text": "On trouve beaucoup d’édifices historiques dans cette ville."}
|
| 155 |
+
{"id": 154, "concept": "french_language", "pair_id": "french_language-13", "variant": 0, "text": "Il faut acheter du pain et des légumes."}
|
| 156 |
+
{"id": 155, "concept": "french_language", "pair_id": "french_language-13", "variant": 1, "text": "Nous devons prendre du pain ainsi que des légumes."}
|
| 157 |
+
{"id": 156, "concept": "french_language", "pair_id": "french_language-14", "variant": 0, "text": "Je cherche un appartement près de l’université."}
|
| 158 |
+
{"id": 157, "concept": "french_language", "pair_id": "french_language-14", "variant": 1, "text": "Je voudrais trouver un logement proche de l’université."}
|
| 159 |
+
{"id": 158, "concept": "french_language", "pair_id": "french_language-15", "variant": 0, "text": "Merci beaucoup pour votre aide."}
|
| 160 |
+
{"id": 159, "concept": "french_language", "pair_id": "french_language-15", "variant": 1, "text": "Je vous remercie sincèrement de votre aide."}
|
| 161 |
+
{"id": 160, "concept": "factual_entities", "pair_id": "factual_entities-00", "variant": 0, "text": "Tell me about Marie Curie and her scientific work."}
|
| 162 |
+
{"id": 161, "concept": "factual_entities", "pair_id": "factual_entities-00", "variant": 1, "text": "Summarize the scientific contributions of Marie Curie."}
|
| 163 |
+
{"id": 162, "concept": "factual_entities", "pair_id": "factual_entities-01", "variant": 0, "text": "What is notable about the city of Kyoto?"}
|
| 164 |
+
{"id": 163, "concept": "factual_entities", "pair_id": "factual_entities-01", "variant": 1, "text": "Give a short factual overview of Kyoto."}
|
| 165 |
+
{"id": 164, "concept": "factual_entities", "pair_id": "factual_entities-02", "variant": 0, "text": "Explain the role of the Nile in ancient Egypt."}
|
| 166 |
+
{"id": 165, "concept": "factual_entities", "pair_id": "factual_entities-02", "variant": 1, "text": "Describe why the Nile mattered to ancient Egyptian civilization."}
|
| 167 |
+
{"id": 166, "concept": "factual_entities", "pair_id": "factual_entities-03", "variant": 0, "text": "Who was Ada Lovelace?"}
|
| 168 |
+
{"id": 167, "concept": "factual_entities", "pair_id": "factual_entities-03", "variant": 1, "text": "Provide a concise factual description of Ada Lovelace."}
|
| 169 |
+
{"id": 168, "concept": "factual_entities", "pair_id": "factual_entities-04", "variant": 0, "text": "What is Mount Everest?"}
|
| 170 |
+
{"id": 169, "concept": "factual_entities", "pair_id": "factual_entities-04", "variant": 1, "text": "Give basic factual information about Mount Everest."}
|
| 171 |
+
{"id": 170, "concept": "factual_entities", "pair_id": "factual_entities-05", "variant": 0, "text": "Describe the planet Saturn."}
|
| 172 |
+
{"id": 171, "concept": "factual_entities", "pair_id": "factual_entities-05", "variant": 1, "text": "Provide several factual details about Saturn."}
|
| 173 |
+
{"id": 172, "concept": "factual_entities", "pair_id": "factual_entities-06", "variant": 0, "text": "What is the Great Barrier Reef?"}
|
| 174 |
+
{"id": 173, "concept": "factual_entities", "pair_id": "factual_entities-06", "variant": 1, "text": "Give an overview of the Great Barrier Reef."}
|
| 175 |
+
{"id": 174, "concept": "factual_entities", "pair_id": "factual_entities-07", "variant": 0, "text": "Tell me about Ludwig van Beethoven."}
|
| 176 |
+
{"id": 175, "concept": "factual_entities", "pair_id": "factual_entities-07", "variant": 1, "text": "Summarize who Beethoven was and why he is remembered."}
|
| 177 |
+
{"id": 176, "concept": "factual_entities", "pair_id": "factual_entities-08", "variant": 0, "text": "What is the Amazon River?"}
|
| 178 |
+
{"id": 177, "concept": "factual_entities", "pair_id": "factual_entities-08", "variant": 1, "text": "Provide factual information about the Amazon River."}
|
| 179 |
+
{"id": 178, "concept": "factual_entities", "pair_id": "factual_entities-09", "variant": 0, "text": "Explain what the Rosetta Stone is."}
|
| 180 |
+
{"id": 179, "concept": "factual_entities", "pair_id": "factual_entities-09", "variant": 1, "text": "Describe the Rosetta Stone and its historical importance."}
|
| 181 |
+
{"id": 180, "concept": "factual_entities", "pair_id": "factual_entities-10", "variant": 0, "text": "Tell me about the element gold."}
|
| 182 |
+
{"id": 181, "concept": "factual_entities", "pair_id": "factual_entities-10", "variant": 1, "text": "Give a factual overview of the chemical element gold."}
|
| 183 |
+
{"id": 182, "concept": "factual_entities", "pair_id": "factual_entities-11", "variant": 0, "text": "What was the Renaissance?"}
|
| 184 |
+
{"id": 183, "concept": "factual_entities", "pair_id": "factual_entities-11", "variant": 1, "text": "Summarize the historical period known as the Renaissance."}
|
| 185 |
+
{"id": 184, "concept": "factual_entities", "pair_id": "factual_entities-12", "variant": 0, "text": "Describe the city of Hamburg."}
|
| 186 |
+
{"id": 185, "concept": "factual_entities", "pair_id": "factual_entities-12", "variant": 1, "text": "Give several basic facts about Hamburg, Germany."}
|
| 187 |
+
{"id": 186, "concept": "factual_entities", "pair_id": "factual_entities-13", "variant": 0, "text": "Who was Alan Turing?"}
|
| 188 |
+
{"id": 187, "concept": "factual_entities", "pair_id": "factual_entities-13", "variant": 1, "text": "Provide a factual summary of Alan Turing’s life and work."}
|
| 189 |
+
{"id": 188, "concept": "factual_entities", "pair_id": "factual_entities-14", "variant": 0, "text": "What is the Pacific Ocean?"}
|
| 190 |
+
{"id": 189, "concept": "factual_entities", "pair_id": "factual_entities-14", "variant": 1, "text": "Give a concise factual description of the Pacific Ocean."}
|
| 191 |
+
{"id": 190, "concept": "factual_entities", "pair_id": "factual_entities-15", "variant": 0, "text": "Explain what DNA is."}
|
| 192 |
+
{"id": 191, "concept": "factual_entities", "pair_id": "factual_entities-15", "variant": 1, "text": "Provide a factual description of DNA and its biological role."}
|
| 193 |
+
{"id": 192, "concept": "uncertainty", "pair_id": "uncertainty-00", "variant": 0, "text": "I have not given enough information to know which box contains the key."}
|
| 194 |
+
{"id": 193, "concept": "uncertainty", "pair_id": "uncertainty-00", "variant": 1, "text": "From the details provided, the location of the key cannot be determined."}
|
| 195 |
+
{"id": 194, "concept": "uncertainty", "pair_id": "uncertainty-01", "variant": 0, "text": "The evidence is incomplete, so the cause remains uncertain."}
|
| 196 |
+
{"id": 195, "concept": "uncertainty", "pair_id": "uncertainty-01", "variant": 1, "text": "There is insufficient evidence to identify the cause with confidence."}
|
| 197 |
+
{"id": 196, "concept": "uncertainty", "pair_id": "uncertainty-02", "variant": 0, "text": "I do not know which route they chose because the text never says."}
|
| 198 |
+
{"id": 197, "concept": "uncertainty", "pair_id": "uncertainty-02", "variant": 1, "text": "The passage does not specify the route, so the answer is unknown."}
|
| 199 |
+
{"id": 198, "concept": "uncertainty", "pair_id": "uncertainty-03", "variant": 0, "text": "Without the missing measurement, the result cannot be calculated."}
|
| 200 |
+
{"id": 199, "concept": "uncertainty", "pair_id": "uncertainty-03", "variant": 1, "text": "The calculation is underdetermined because a required value is absent."}
|
| 201 |
+
{"id": 200, "concept": "uncertainty", "pair_id": "uncertainty-04", "variant": 0, "text": "The source does not state when the event happened."}
|
| 202 |
+
{"id": 201, "concept": "uncertainty", "pair_id": "uncertainty-04", "variant": 1, "text": "The event date is not provided by the available source."}
|
| 203 |
+
{"id": 202, "concept": "uncertainty", "pair_id": "uncertainty-05", "variant": 0, "text": "Several explanations fit the observations, so no single one is established."}
|
| 204 |
+
{"id": 203, "concept": "uncertainty", "pair_id": "uncertainty-05", "variant": 1, "text": "The observations support multiple possibilities and do not settle on one explanation."}
|
| 205 |
+
{"id": 204, "concept": "uncertainty", "pair_id": "uncertainty-06", "variant": 0, "text": "There is not enough context to identify who the pronoun refers to."}
|
| 206 |
+
{"id": 205, "concept": "uncertainty", "pair_id": "uncertainty-06", "variant": 1, "text": "The pronoun’s referent is ambiguous given the limited context."}
|
| 207 |
+
{"id": 206, "concept": "uncertainty", "pair_id": "uncertainty-07", "variant": 0, "text": "The sample is too small to draw a reliable conclusion."}
|
| 208 |
+
{"id": 207, "concept": "uncertainty", "pair_id": "uncertainty-07", "variant": 1, "text": "A confident conclusion would be unjustified because the sample size is inadequate."}
|
| 209 |
+
{"id": 208, "concept": "uncertainty", "pair_id": "uncertainty-08", "variant": 0, "text": "I cannot verify that claim from the information available."}
|
| 210 |
+
{"id": 209, "concept": "uncertainty", "pair_id": "uncertainty-08", "variant": 1, "text": "The available information is insufficient to confirm the claim."}
|
| 211 |
+
{"id": 210, "concept": "uncertainty", "pair_id": "uncertainty-09", "variant": 0, "text": "The instructions omit the final step, so the intended outcome is unclear."}
|
| 212 |
+
{"id": 211, "concept": "uncertainty", "pair_id": "uncertainty-09", "variant": 1, "text": "Because the last instruction is missing, the desired result cannot be known."}
|
| 213 |
+
{"id": 212, "concept": "uncertainty", "pair_id": "uncertainty-10", "variant": 0, "text": "We have two plausible answers and no evidence that distinguishes them."}
|
| 214 |
+
{"id": 213, "concept": "uncertainty", "pair_id": "uncertainty-10", "variant": 1, "text": "Both answers remain possible because there is no discriminating evidence."}
|
| 215 |
+
{"id": 214, "concept": "uncertainty", "pair_id": "uncertainty-11", "variant": 0, "text": "The report gives a range but not an exact value."}
|
| 216 |
+
{"id": 215, "concept": "uncertainty", "pair_id": "uncertainty-11", "variant": 1, "text": "Only an interval is reported, so the precise value is unspecified."}
|
| 217 |
+
{"id": 216, "concept": "uncertainty", "pair_id": "uncertainty-12", "variant": 0, "text": "The image is too blurry to read the number confidently."}
|
| 218 |
+
{"id": 217, "concept": "uncertainty", "pair_id": "uncertainty-12", "variant": 1, "text": "The number cannot be identified reliably because the image lacks clarity."}
|
| 219 |
+
{"id": 218, "concept": "uncertainty", "pair_id": "uncertainty-13", "variant": 0, "text": "No forecast was provided, so tomorrow’s value is unknown."}
|
| 220 |
+
{"id": 219, "concept": "uncertainty", "pair_id": "uncertainty-13", "variant": 1, "text": "The future value cannot be stated because there is no forecast information."}
|
| 221 |
+
{"id": 220, "concept": "uncertainty", "pair_id": "uncertainty-14", "variant": 0, "text": "The experiment was not repeated, so the finding remains tentative."}
|
| 222 |
+
{"id": 221, "concept": "uncertainty", "pair_id": "uncertainty-14", "variant": 1, "text": "Without replication, the result should be treated as uncertain."}
|
| 223 |
+
{"id": 222, "concept": "uncertainty", "pair_id": "uncertainty-15", "variant": 0, "text": "The text names several candidates but never identifies the winner."}
|
| 224 |
+
{"id": 223, "concept": "uncertainty", "pair_id": "uncertainty-15", "variant": 1, "text": "A winner cannot be determined because the passage lists candidates without a result."}
|
docs/HF_DEPLOY.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face deployment
|
| 2 |
+
|
| 3 |
+
FeatureLens targets a Gradio SDK Space with ZeroGPU hardware.
|
| 4 |
+
|
| 5 |
+
## Why Gradio rather than Docker?
|
| 6 |
+
|
| 7 |
+
ZeroGPU currently supports Gradio SDK Spaces. The workbench therefore uses `app.py` plus `requirements.txt` and avoids a Dockerfile.
|
| 8 |
+
|
| 9 |
+
## Cold start
|
| 10 |
+
|
| 11 |
+
On Hugging Face, `FEATURELENS_EAGER_LOAD` defaults to `1`. The runtime loads Qwen3-1.7B-Base and only the configured Qwen-Scope layer files. The default layer set is 4, 14 and 26.
|
| 12 |
+
|
| 13 |
+
The model and SAEs are placed on `cuda` during startup. ZeroGPU provides CUDA emulation outside GPU-decorated callbacks and a real GPU during `spaces.GPU` execution.
|
| 14 |
+
|
| 15 |
+
## GPU-decorated functions
|
| 16 |
+
|
| 17 |
+
- feature inspection: 45-second maximum allocation;
|
| 18 |
+
- baseline-vs-modified generation: 60-second maximum allocation.
|
| 19 |
+
|
| 20 |
+
Keeping prompt length and generation length bounded protects the free daily quota and improves queue behavior.
|
| 21 |
+
|
| 22 |
+
## No persistent benchmark jobs in the Space
|
| 23 |
+
|
| 24 |
+
The full experiment is intentionally offline. A free ZeroGPU quota is appropriate for interactive inspection, not a hundreds-of-forward-passes benchmark. Run `python experiments/run_all.py` elsewhere once, commit the small report/catalog artifacts, and the Space will display them automatically.
|
docs/METHODOLOGY.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Methodology
|
| 2 |
+
|
| 3 |
+
## Primary hypothesis
|
| 4 |
+
|
| 5 |
+
A sparse feature that is predictive of a semantic category is not automatically a causal mechanism for behavior. FeatureLens evaluates association and intervention separately.
|
| 6 |
+
|
| 7 |
+
## Discovery data
|
| 8 |
+
|
| 9 |
+
Seven controlled concepts are represented by 16 independently authored prompt pairs per concept. Each pair contains two paraphrases. The split is grouped by pair id so lexical near-duplicates never cross train and test.
|
| 10 |
+
|
| 11 |
+
The final token residual is used for the offline concept benchmark because it has access to the complete prompt prefix and keeps the activation tensor small enough to reproduce easily. The live workbench is token-selectable and is not restricted to the final token.
|
| 12 |
+
|
| 13 |
+
## Sparse feature evaluation
|
| 14 |
+
|
| 15 |
+
For each configured layer, FeatureLens stores the TopK SAE code for each sample. Candidate features must fire at least three times on the training split. Features are ranked using training AUROC and an activation-rate contrast tie-break. AUROC and F1 shown in the report are then calculated on the held-out split.
|
| 16 |
+
|
| 17 |
+
This makes the feature catalog a selection procedure rather than a post-hoc leaderboard over the test set.
|
| 18 |
+
|
| 19 |
+
## Dense baseline
|
| 20 |
+
|
| 21 |
+
A multinomial logistic-regression probe is fit to the dense residual vectors at the same layers. This asks whether the representation carries concept information even if no single SAE feature isolates it cleanly.
|
| 22 |
+
|
| 23 |
+
## Causal intervention
|
| 24 |
+
|
| 25 |
+
If the SAE code of the selected prompt residual is `z` and the selected feature is `i`, FeatureLens changes only coefficient `z_i`. The resulting decoder delta is added back to the original residual. Full SAE reconstruction is never substituted for the original activation.
|
| 26 |
+
|
| 27 |
+
Ablation uses `Δz_i = -z_i`; 2× amplification uses `Δz_i = z_i`.
|
| 28 |
+
|
| 29 |
+
## Negative control
|
| 30 |
+
|
| 31 |
+
For every SAE residual perturbation, a deterministic random direction with identical L2 norm is generated and patched at the same layer/token. This does not prove specificity by itself, but it gives a much stronger baseline than reporting an intervention effect without a perturbation control.
|
| 32 |
+
|
| 33 |
+
## Causal outcomes
|
| 34 |
+
|
| 35 |
+
The causal task set is separate from feature discovery. The main behavioral score is the change in log-probability of the first token of a target completion. The pipeline also stores probability delta, target rank change, JS divergence of the next-token distribution, and whether the top-1 token changed.
|
| 36 |
+
|
| 37 |
+
## Interpretation
|
| 38 |
+
|
| 39 |
+
The auto-generated report uses conservative heuristics to summarize measured results, but preserves all raw rows. A high predictive AUROC with a small causal effect is explicitly reported as evidence that correlation did not translate into strong downstream control.
|
experiments/__init__.py
ADDED
|
File without changes
|
experiments/build_dataset.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 7 |
+
DATA_DIR = ROOT / 'data'
|
| 8 |
+
|
| 9 |
+
PAIRS: dict[str, list[tuple[str, str]]] = {
|
| 10 |
+
'code': [
|
| 11 |
+
('Write a Python function that returns the larger of two integers.', 'In Python, create a function that chooses the maximum of two integer inputs.'),
|
| 12 |
+
('Explain what a recursive function does in a program.', 'Describe recursion in the context of a programming function.'),
|
| 13 |
+
('Show a JavaScript loop that prints numbers from one to five.', 'Give JavaScript code for iterating over the integers 1 through 5 and logging them.'),
|
| 14 |
+
('What is the purpose of a hash map in software?', 'Explain why programmers use hash maps or dictionaries.'),
|
| 15 |
+
('Write SQL that selects every row from a table named users.', 'Provide a SQL query to retrieve all records in the users table.'),
|
| 16 |
+
('How do I catch an exception in Python?', 'Show the Python syntax for handling an exception with try and except.'),
|
| 17 |
+
('Explain the difference between a list and a tuple in Python.', 'Compare Python lists with tuples.'),
|
| 18 |
+
('Write a function that checks whether a string is a palindrome.', 'Create code that tests if text reads the same forwards and backwards.'),
|
| 19 |
+
('What does git rebase do?', 'Describe the effect of rebasing a Git branch.'),
|
| 20 |
+
('How can a REST API return JSON?', 'Explain how a web API endpoint sends a JSON response.'),
|
| 21 |
+
('Write pseudocode for binary search.', 'Describe the binary-search algorithm as pseudocode.'),
|
| 22 |
+
('What is a class constructor used for?', 'Explain the role of a constructor when creating an object.'),
|
| 23 |
+
('Give a regular expression that matches a basic email-like string.', 'Show a regex pattern for a simple email address format.'),
|
| 24 |
+
('Why is time complexity important when comparing algorithms?', 'Explain why Big-O runtime matters in algorithm analysis.'),
|
| 25 |
+
('How do unit tests help a codebase?', 'Describe the purpose of automated unit testing in software.'),
|
| 26 |
+
('Write Python that sorts a list of dictionaries by an age key.', 'Show how to order Python dictionaries in a list according to their age field.'),
|
| 27 |
+
],
|
| 28 |
+
'mathematics': [
|
| 29 |
+
('Find the derivative of x cubed plus two x.', 'Differentiate the function x^3 + 2x.'),
|
| 30 |
+
('Solve the equation 3x + 5 = 20.', 'Find x when three x plus five equals twenty.'),
|
| 31 |
+
('What is the integral of cosine x?', 'Compute an antiderivative of cos(x).'),
|
| 32 |
+
('Factor the polynomial x squared minus nine.', 'Rewrite x^2 - 9 as a product of factors.'),
|
| 33 |
+
('Calculate the mean of 4, 7, 9, and 10.', 'What is the arithmetic average of 4, 7, 9, 10?'),
|
| 34 |
+
('Explain the Pythagorean theorem.', 'State the relationship among the sides of a right triangle.'),
|
| 35 |
+
('What is the determinant of a two by two matrix?', 'Give the formula for the determinant of a 2x2 matrix.'),
|
| 36 |
+
('Simplify two to the third power times two squared.', 'Reduce 2^3 multiplied by 2^2 using exponent rules.'),
|
| 37 |
+
('Convert one half into a percentage.', 'Express 1/2 as a percent.'),
|
| 38 |
+
('What is the probability of heads on a fair coin?', 'For a fair coin, calculate the chance of flipping heads.'),
|
| 39 |
+
('Solve x squared equals sixteen.', 'Find the real values of x satisfying x^2 = 16.'),
|
| 40 |
+
('What is the slope between points (1,2) and (3,6)?', 'Calculate the gradient of the line through (1,2) and (3,6).'),
|
| 41 |
+
('Explain what a prime number is.', 'Define a prime integer.'),
|
| 42 |
+
('Compute the dot product of vectors (1,2) and (3,4).', 'Find (1,2) · (3,4).'),
|
| 43 |
+
('What is log base ten of one thousand?', 'Evaluate log_10(1000).'),
|
| 44 |
+
('A circle has radius three. What is its area?', 'Calculate the area of a circle whose radius is 3.'),
|
| 45 |
+
],
|
| 46 |
+
'positive_sentiment': [
|
| 47 |
+
('The film was delightful, clever, and beautifully acted.', 'I found the movie charming, smart, and wonderfully performed.'),
|
| 48 |
+
('This restaurant served an excellent meal and the staff were kind.', 'The food was fantastic and the service team was genuinely friendly.'),
|
| 49 |
+
('I am very pleased with how reliable this laptop has been.', 'This laptop has worked dependably and I am extremely satisfied with it.'),
|
| 50 |
+
('The concert was energetic and unforgettable.', 'I had an amazing time at the lively, memorable concert.'),
|
| 51 |
+
('Her explanation was clear and genuinely helpful.', 'She explained the topic in a useful and easy-to-understand way.'),
|
| 52 |
+
('The hotel room was spotless and comfortable.', 'Our room was exceptionally clean and pleasant to stay in.'),
|
| 53 |
+
('I loved the book from beginning to end.', 'The novel kept me delighted all the way through.'),
|
| 54 |
+
('The new update makes the app much easier to use.', 'After the update, the application feels significantly more convenient.'),
|
| 55 |
+
('Their customer support solved my problem quickly.', 'Support handled the issue fast and left me very happy.'),
|
| 56 |
+
('The hike had spectacular views and perfect weather.', 'We enjoyed gorgeous scenery and wonderful conditions on the hike.'),
|
| 57 |
+
('The presentation was engaging and well organized.', 'I enjoyed the talk because it was compelling and structured clearly.'),
|
| 58 |
+
('This camera takes sharp photos and feels great to use.', 'The camera produces crisp images and has a satisfying design.'),
|
| 59 |
+
('The workshop exceeded my expectations.', 'I was impressed because the workshop was even better than I expected.'),
|
| 60 |
+
('Dinner turned out wonderfully and everyone enjoyed it.', 'The evening meal was a success and all of us had a great time.'),
|
| 61 |
+
('The museum exhibition was fascinating.', 'I thought the exhibition was deeply interesting and rewarding.'),
|
| 62 |
+
('The train journey was smooth and relaxing.', 'The trip by train felt easy, calm, and comfortable.'),
|
| 63 |
+
],
|
| 64 |
+
'negative_sentiment': [
|
| 65 |
+
('The film was tedious, confusing, and badly acted.', 'I found the movie boring, incoherent, and poorly performed.'),
|
| 66 |
+
('This restaurant served cold food and the staff were rude.', 'The meal arrived cold and the service team behaved unpleasantly.'),
|
| 67 |
+
('I am disappointed by how unreliable this laptop has been.', 'This laptop keeps failing and I am extremely dissatisfied with it.'),
|
| 68 |
+
('The concert was chaotic and forgettable.', 'I had a miserable time at the disorganized, dull concert.'),
|
| 69 |
+
('Her explanation was unclear and unhelpful.', 'She explained the topic in a confusing way that did not help me.'),
|
| 70 |
+
('The hotel room was dirty and uncomfortable.', 'Our room was unpleasant, unclean, and difficult to relax in.'),
|
| 71 |
+
('I regretted reading the book.', 'The novel was a frustrating waste of my time.'),
|
| 72 |
+
('The new update makes the app harder to use.', 'After the update, the application feels significantly more awkward.'),
|
| 73 |
+
('Their customer support ignored my problem.', 'Support failed to resolve the issue and left me angry.'),
|
| 74 |
+
('The hike had awful weather and disappointing views.', 'We dealt with terrible conditions and underwhelming scenery on the hike.'),
|
| 75 |
+
('The presentation was dull and poorly organized.', 'I disliked the talk because it was tedious and structured badly.'),
|
| 76 |
+
('This camera takes blurry photos and feels cheap.', 'The camera produces soft images and has a flimsy design.'),
|
| 77 |
+
('The workshop fell far below my expectations.', 'I was disappointed because the workshop was much worse than I expected.'),
|
| 78 |
+
('Dinner went badly and nobody enjoyed it.', 'The evening meal was a failure and all of us had a poor time.'),
|
| 79 |
+
('The museum exhibition was painfully boring.', 'I thought the exhibition was dull and unrewarding.'),
|
| 80 |
+
('The train journey was stressful and uncomfortable.', 'The trip by train felt frustrating, noisy, and unpleasant.'),
|
| 81 |
+
],
|
| 82 |
+
'french_language': [
|
| 83 |
+
('Bonjour, comment allez-vous aujourd’hui ?', 'Salut, comment vas-tu aujourd’hui ?'),
|
| 84 |
+
('Je voudrais réserver une table pour deux personnes.', 'Puis-je réserver une table pour deux, s’il vous plaît ?'),
|
| 85 |
+
('La bibliothèque ferme à dix-huit heures.', 'La bibliothèque est ouverte jusqu’à dix-huit heures.'),
|
| 86 |
+
('Ce livre raconte une histoire très intéressante.', 'L’histoire racontée dans ce livre est vraiment intéressante.'),
|
| 87 |
+
('Nous allons prendre le train demain matin.', 'Demain matin, nous voyagerons en train.'),
|
| 88 |
+
('Pouvez-vous m’indiquer le chemin vers la gare ?', 'Comment puis-je aller jusqu’à la gare ?'),
|
| 89 |
+
('J’aime apprendre de nouvelles langues.', 'Apprendre des langues nouvelles me plaît beaucoup.'),
|
| 90 |
+
('Le temps est magnifique au bord de la mer.', 'Il fait très beau près de la mer.'),
|
| 91 |
+
('Elle prépare le dîner dans la cuisine.', 'Dans la cuisine, elle est en train de préparer le repas du soir.'),
|
| 92 |
+
('Nous avons visité un musée pendant le week-end.', 'Ce week-end, nous sommes allés voir un musée.'),
|
| 93 |
+
('La réunion commence à neuf heures précises.', 'Le rendez-vous débute exactement à neuf heures.'),
|
| 94 |
+
('Mon ordinateur ne fonctionne plus correctement.', 'J’ai un problème : mon ordinateur marche mal maintenant.'),
|
| 95 |
+
('Cette ville possède de nombreux bâtiments historiques.', 'On trouve beaucoup d’édifices historiques dans cette ville.'),
|
| 96 |
+
('Il faut acheter du pain et des légumes.', 'Nous devons prendre du pain ainsi que des légumes.'),
|
| 97 |
+
('Je cherche un appartement près de l’université.', 'Je voudrais trouver un logement proche de l’université.'),
|
| 98 |
+
('Merci beaucoup pour votre aide.', 'Je vous remercie sincèrement de votre aide.'),
|
| 99 |
+
],
|
| 100 |
+
'factual_entities': [
|
| 101 |
+
('Tell me about Marie Curie and her scientific work.', 'Summarize the scientific contributions of Marie Curie.'),
|
| 102 |
+
('What is notable about the city of Kyoto?', 'Give a short factual overview of Kyoto.'),
|
| 103 |
+
('Explain the role of the Nile in ancient Egypt.', 'Describe why the Nile mattered to ancient Egyptian civilization.'),
|
| 104 |
+
('Who was Ada Lovelace?', 'Provide a concise factual description of Ada Lovelace.'),
|
| 105 |
+
('What is Mount Everest?', 'Give basic factual information about Mount Everest.'),
|
| 106 |
+
('Describe the planet Saturn.', 'Provide several factual details about Saturn.'),
|
| 107 |
+
('What is the Great Barrier Reef?', 'Give an overview of the Great Barrier Reef.'),
|
| 108 |
+
('Tell me about Ludwig van Beethoven.', 'Summarize who Beethoven was and why he is remembered.'),
|
| 109 |
+
('What is the Amazon River?', 'Provide factual information about the Amazon River.'),
|
| 110 |
+
('Explain what the Rosetta Stone is.', 'Describe the Rosetta Stone and its historical importance.'),
|
| 111 |
+
('Tell me about the element gold.', 'Give a factual overview of the chemical element gold.'),
|
| 112 |
+
('What was the Renaissance?', 'Summarize the historical period known as the Renaissance.'),
|
| 113 |
+
('Describe the city of Hamburg.', 'Give several basic facts about Hamburg, Germany.'),
|
| 114 |
+
('Who was Alan Turing?', 'Provide a factual summary of Alan Turing’s life and work.'),
|
| 115 |
+
('What is the Pacific Ocean?', 'Give a concise factual description of the Pacific Ocean.'),
|
| 116 |
+
('Explain what DNA is.', 'Provide a factual description of DNA and its biological role.'),
|
| 117 |
+
],
|
| 118 |
+
'uncertainty': [
|
| 119 |
+
('I have not given enough information to know which box contains the key.', 'From the details provided, the location of the key cannot be determined.'),
|
| 120 |
+
('The evidence is incomplete, so the cause remains uncertain.', 'There is insufficient evidence to identify the cause with confidence.'),
|
| 121 |
+
('I do not know which route they chose because the text never says.', 'The passage does not specify the route, so the answer is unknown.'),
|
| 122 |
+
('Without the missing measurement, the result cannot be calculated.', 'The calculation is underdetermined because a required value is absent.'),
|
| 123 |
+
('The source does not state when the event happened.', 'The event date is not provided by the available source.'),
|
| 124 |
+
('Several explanations fit the observations, so no single one is established.', 'The observations support multiple possibilities and do not settle on one explanation.'),
|
| 125 |
+
('There is not enough context to identify who the pronoun refers to.', 'The pronoun’s referent is ambiguous given the limited context.'),
|
| 126 |
+
('The sample is too small to draw a reliable conclusion.', 'A confident conclusion would be unjustified because the sample size is inadequate.'),
|
| 127 |
+
('I cannot verify that claim from the information available.', 'The available information is insufficient to confirm the claim.'),
|
| 128 |
+
('The instructions omit the final step, so the intended outcome is unclear.', 'Because the last instruction is missing, the desired result cannot be known.'),
|
| 129 |
+
('We have two plausible answers and no evidence that distinguishes them.', 'Both answers remain possible because there is no discriminating evidence.'),
|
| 130 |
+
('The report gives a range but not an exact value.', 'Only an interval is reported, so the precise value is unspecified.'),
|
| 131 |
+
('The image is too blurry to read the number confidently.', 'The number cannot be identified reliably because the image lacks clarity.'),
|
| 132 |
+
('No forecast was provided, so tomorrow’s value is unknown.', 'The future value cannot be stated because there is no forecast information.'),
|
| 133 |
+
('The experiment was not repeated, so the finding remains tentative.', 'Without replication, the result should be treated as uncertain.'),
|
| 134 |
+
('The text names several candidates but never identifies the winner.', 'A winner cannot be determined because the passage lists candidates without a result.'),
|
| 135 |
+
],
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
CAUSAL_TASKS = [
|
| 139 |
+
{'concept': 'positive_sentiment', 'prompt': 'Sentiment: The meal was wonderful and the staff were kind. Label:', 'target': ' positive'},
|
| 140 |
+
{'concept': 'positive_sentiment', 'prompt': 'Sentiment: I loved the thoughtful, beautifully written novel. Label:', 'target': ' positive'},
|
| 141 |
+
{'concept': 'positive_sentiment', 'prompt': 'Sentiment: The service was fast and extremely helpful. Label:', 'target': ' positive'},
|
| 142 |
+
{'concept': 'positive_sentiment', 'prompt': 'Sentiment: The journey was comfortable and enjoyable. Label:', 'target': ' positive'},
|
| 143 |
+
{'concept': 'negative_sentiment', 'prompt': 'Sentiment: The meal was awful and the staff were rude. Label:', 'target': ' negative'},
|
| 144 |
+
{'concept': 'negative_sentiment', 'prompt': 'Sentiment: I hated the tedious, badly written novel. Label:', 'target': ' negative'},
|
| 145 |
+
{'concept': 'negative_sentiment', 'prompt': 'Sentiment: The service was slow and completely unhelpful. Label:', 'target': ' negative'},
|
| 146 |
+
{'concept': 'negative_sentiment', 'prompt': 'Sentiment: The journey was stressful and unpleasant. Label:', 'target': ' negative'},
|
| 147 |
+
{'concept': 'mathematics', 'prompt': '2 + 3 =', 'target': ' 5'},
|
| 148 |
+
{'concept': 'mathematics', 'prompt': '7 - 4 =', 'target': ' 3'},
|
| 149 |
+
{'concept': 'mathematics', 'prompt': '6 * 2 =', 'target': ' 12'},
|
| 150 |
+
{'concept': 'mathematics', 'prompt': 'The square root of 81 is', 'target': ' 9'},
|
| 151 |
+
{'concept': 'code', 'prompt': 'Python function declaration keyword:', 'target': ' def'},
|
| 152 |
+
{'concept': 'code', 'prompt': 'In Python, an exception handler begins with the keyword', 'target': ' except'},
|
| 153 |
+
{'concept': 'code', 'prompt': 'SQL keyword used to retrieve rows:', 'target': ' SELECT'},
|
| 154 |
+
{'concept': 'code', 'prompt': 'A Python conditional branch commonly starts with', 'target': ' if'},
|
| 155 |
+
{'concept': 'french_language', 'prompt': "Translate 'hello' into French:", 'target': ' bonjour'},
|
| 156 |
+
{'concept': 'french_language', 'prompt': "Translate 'thank you' into French:", 'target': ' merci'},
|
| 157 |
+
{'concept': 'french_language', 'prompt': "Translate 'yes' into French:", 'target': ' oui'},
|
| 158 |
+
{'concept': 'french_language', 'prompt': "Translate 'good evening' into French:", 'target': ' bonsoir'},
|
| 159 |
+
{'concept': 'factual_entities', 'prompt': 'The scientist associated with radium research, Marie', 'target': ' Curie'},
|
| 160 |
+
{'concept': 'factual_entities', 'prompt': 'The composer of the Fifth Symphony, Ludwig van', 'target': ' Beethoven'},
|
| 161 |
+
{'concept': 'factual_entities', 'prompt': 'The computer scientist known for the Turing machine, Alan', 'target': ' Turing'},
|
| 162 |
+
{'concept': 'factual_entities', 'prompt': 'The Japanese city famous for many historic temples,', 'target': ' Kyoto'},
|
| 163 |
+
{'concept': 'uncertainty', 'prompt': 'The evidence is insufficient. The answer is', 'target': ' unknown'},
|
| 164 |
+
{'concept': 'uncertainty', 'prompt': 'The passage never states the value, so it is', 'target': ' unknown'},
|
| 165 |
+
{'concept': 'uncertainty', 'prompt': 'There is not enough information to determine the result. It remains', 'target': ' uncertain'},
|
| 166 |
+
{'concept': 'uncertainty', 'prompt': 'No reliable conclusion can be drawn; the outcome is', 'target': ' unclear'},
|
| 167 |
+
]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def main() -> None:
|
| 171 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 172 |
+
prompt_path = DATA_DIR / 'prompts.jsonl'
|
| 173 |
+
with prompt_path.open('w', encoding='utf-8') as handle:
|
| 174 |
+
sample_id = 0
|
| 175 |
+
for concept, pairs in PAIRS.items():
|
| 176 |
+
for pair_num, (a, b) in enumerate(pairs):
|
| 177 |
+
pair_id = f'{concept}-{pair_num:02d}'
|
| 178 |
+
for variant, text in enumerate((a, b)):
|
| 179 |
+
record = {
|
| 180 |
+
'id': sample_id,
|
| 181 |
+
'concept': concept,
|
| 182 |
+
'pair_id': pair_id,
|
| 183 |
+
'variant': variant,
|
| 184 |
+
'text': text,
|
| 185 |
+
}
|
| 186 |
+
handle.write(json.dumps(record, ensure_ascii=False) + '\n')
|
| 187 |
+
sample_id += 1
|
| 188 |
+
|
| 189 |
+
causal_path = DATA_DIR / 'causal_tasks.jsonl'
|
| 190 |
+
with causal_path.open('w', encoding='utf-8') as handle:
|
| 191 |
+
for idx, task in enumerate(CAUSAL_TASKS):
|
| 192 |
+
record = {'id': idx, **task}
|
| 193 |
+
handle.write(json.dumps(record, ensure_ascii=False) + '\n')
|
| 194 |
+
|
| 195 |
+
print(f'Wrote {sample_id} discovery prompts to {prompt_path}')
|
| 196 |
+
print(f'Wrote {len(CAUSAL_TASKS)} causal tasks to {causal_path}')
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
if __name__ == '__main__':
|
| 200 |
+
main()
|
experiments/collect_activations.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import scipy.sparse as sp
|
| 9 |
+
import torch
|
| 10 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 11 |
+
|
| 12 |
+
from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
|
| 13 |
+
from featurelens.config import SETTINGS
|
| 14 |
+
from featurelens.metrics import reconstruction_metrics
|
| 15 |
+
from featurelens.sae import SAEStore
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_args() -> argparse.Namespace:
|
| 19 |
+
parser = argparse.ArgumentParser(description='Collect residual and Qwen-Scope SAE activations.')
|
| 20 |
+
parser.add_argument('--input', type=Path, default=DATA_DIR / 'prompts.jsonl')
|
| 21 |
+
parser.add_argument('--output-dir', type=Path, default=ARTIFACT_DIR / 'activations')
|
| 22 |
+
parser.add_argument('--batch-size', type=int, default=16)
|
| 23 |
+
parser.add_argument('--max-length', type=int, default=192)
|
| 24 |
+
parser.add_argument('--seed', type=int, default=42)
|
| 25 |
+
parser.add_argument('--layers', type=int, nargs='+', default=list(SETTINGS.layers))
|
| 26 |
+
return parser.parse_args()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _build_sparse(encodings: list, n_rows: int, width: int) -> sp.csr_matrix:
|
| 30 |
+
row_ids: list[int] = []
|
| 31 |
+
col_ids: list[int] = []
|
| 32 |
+
values: list[float] = []
|
| 33 |
+
for row, encoding in enumerate(encodings):
|
| 34 |
+
idx = encoding.indices.detach().cpu().numpy().reshape(-1)
|
| 35 |
+
vals = encoding.values.detach().float().cpu().numpy().reshape(-1)
|
| 36 |
+
positive = vals > 0
|
| 37 |
+
row_ids.extend([row] * int(positive.sum()))
|
| 38 |
+
col_ids.extend(idx[positive].astype(int).tolist())
|
| 39 |
+
values.extend(vals[positive].astype(float).tolist())
|
| 40 |
+
return sp.csr_matrix((values, (row_ids, col_ids)), shape=(n_rows, width), dtype=np.float32)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@torch.inference_mode()
|
| 44 |
+
def main() -> None:
|
| 45 |
+
args = parse_args()
|
| 46 |
+
set_seed(args.seed)
|
| 47 |
+
rows = load_jsonl(args.input)
|
| 48 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 49 |
+
|
| 50 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 51 |
+
model_dtype = torch.float16 if device.type == 'cuda' else torch.float32
|
| 52 |
+
tokenizer = AutoTokenizer.from_pretrained(SETTINGS.model_id)
|
| 53 |
+
if tokenizer.pad_token_id is None:
|
| 54 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 55 |
+
tokenizer.padding_side = 'left'
|
| 56 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 57 |
+
SETTINGS.model_id,
|
| 58 |
+
torch_dtype=model_dtype,
|
| 59 |
+
low_cpu_mem_usage=True,
|
| 60 |
+
).to(device)
|
| 61 |
+
model.eval()
|
| 62 |
+
sae_store = SAEStore(
|
| 63 |
+
SETTINGS.sae_repo_id,
|
| 64 |
+
layers=args.layers,
|
| 65 |
+
device=device,
|
| 66 |
+
dtype=torch.float32,
|
| 67 |
+
top_k=SETTINGS.sae_top_k,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
residuals: dict[int, list[np.ndarray]] = {layer: [] for layer in args.layers}
|
| 71 |
+
encodings: dict[int, list] = {layer: [] for layer in args.layers}
|
| 72 |
+
recon_stats: dict[int, list[dict[str, float]]] = {layer: [] for layer in args.layers}
|
| 73 |
+
|
| 74 |
+
for start in range(0, len(rows), args.batch_size):
|
| 75 |
+
batch_rows = rows[start : start + args.batch_size]
|
| 76 |
+
texts = [row['text'] for row in batch_rows]
|
| 77 |
+
batch = tokenizer(
|
| 78 |
+
texts,
|
| 79 |
+
return_tensors='pt',
|
| 80 |
+
padding=True,
|
| 81 |
+
truncation=True,
|
| 82 |
+
max_length=args.max_length,
|
| 83 |
+
)
|
| 84 |
+
batch = {key: value.to(device) for key, value in batch.items()}
|
| 85 |
+
captured: dict[int, torch.Tensor] = {}
|
| 86 |
+
handles = []
|
| 87 |
+
|
| 88 |
+
def make_hook(layer: int):
|
| 89 |
+
def hook(_module, _inputs, output):
|
| 90 |
+
hidden = output[0] if isinstance(output, tuple) else output
|
| 91 |
+
captured[layer] = hidden.detach()
|
| 92 |
+
|
| 93 |
+
return hook
|
| 94 |
+
|
| 95 |
+
for layer in args.layers:
|
| 96 |
+
handles.append(model.model.layers[layer].register_forward_hook(make_hook(layer)))
|
| 97 |
+
model(**batch, use_cache=False)
|
| 98 |
+
for handle in handles:
|
| 99 |
+
handle.remove()
|
| 100 |
+
|
| 101 |
+
for layer in args.layers:
|
| 102 |
+
sae = sae_store.get(layer)
|
| 103 |
+
final_token_residuals = captured[layer][:, -1, :]
|
| 104 |
+
batch_encoding = sae.encode(final_token_residuals)
|
| 105 |
+
for row_idx in range(final_token_residuals.shape[0]):
|
| 106 |
+
residual = final_token_residuals[row_idx]
|
| 107 |
+
from featurelens.sae import SparseEncoding
|
| 108 |
+
|
| 109 |
+
encoding = SparseEncoding(
|
| 110 |
+
indices=batch_encoding.indices[row_idx],
|
| 111 |
+
values=batch_encoding.values[row_idx],
|
| 112 |
+
)
|
| 113 |
+
reconstructed = sae.decode_sparse(encoding)
|
| 114 |
+
residuals[layer].append(residual.detach().float().cpu().numpy().astype(np.float16))
|
| 115 |
+
encodings[layer].append(encoding)
|
| 116 |
+
recon_stats[layer].append(reconstruction_metrics(residual, reconstructed))
|
| 117 |
+
|
| 118 |
+
print(f'Processed {min(start + args.batch_size, len(rows))}/{len(rows)} prompts', flush=True)
|
| 119 |
+
|
| 120 |
+
for layer in args.layers:
|
| 121 |
+
residual_array = np.stack(residuals[layer], axis=0)
|
| 122 |
+
np.save(args.output_dir / f'residuals_layer{layer}.npy', residual_array)
|
| 123 |
+
sparse = _build_sparse(encodings[layer], len(rows), SETTINGS.sae_width)
|
| 124 |
+
sp.save_npz(args.output_dir / f'features_layer{layer}.npz', sparse, compressed=True)
|
| 125 |
+
summary = {
|
| 126 |
+
'layer': layer,
|
| 127 |
+
'n_samples': len(rows),
|
| 128 |
+
'mean_cosine': float(np.mean([x['cosine'] for x in recon_stats[layer]])),
|
| 129 |
+
'mean_nmse': float(np.mean([x['nmse'] for x in recon_stats[layer]])),
|
| 130 |
+
'median_nmse': float(np.median([x['nmse'] for x in recon_stats[layer]])),
|
| 131 |
+
'mean_active_features': float(np.mean(np.diff(sparse.indptr))),
|
| 132 |
+
}
|
| 133 |
+
(args.output_dir / f'reconstruction_layer{layer}.json').write_text(
|
| 134 |
+
json.dumps(summary, indent=2), encoding='utf-8'
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
metadata = {
|
| 138 |
+
'model_id': SETTINGS.model_id,
|
| 139 |
+
'sae_repo_id': SETTINGS.sae_repo_id,
|
| 140 |
+
'layers': args.layers,
|
| 141 |
+
'top_k': SETTINGS.sae_top_k,
|
| 142 |
+
'width': SETTINGS.sae_width,
|
| 143 |
+
'n_samples': len(rows),
|
| 144 |
+
'rows': rows,
|
| 145 |
+
}
|
| 146 |
+
(args.output_dir / 'metadata.json').write_text(json.dumps(metadata, indent=2), encoding='utf-8')
|
| 147 |
+
print(f'Activation artifacts written to {args.output_dir}')
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == '__main__':
|
| 151 |
+
main()
|
experiments/common.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import random
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
DATA_DIR = ROOT / 'data'
|
| 12 |
+
ARTIFACT_DIR = ROOT / 'artifacts'
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_jsonl(path: str | Path) -> list[dict]:
|
| 16 |
+
rows = []
|
| 17 |
+
with Path(path).open(encoding='utf-8') as handle:
|
| 18 |
+
for line in handle:
|
| 19 |
+
line = line.strip()
|
| 20 |
+
if line:
|
| 21 |
+
rows.append(json.loads(line))
|
| 22 |
+
return rows
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def set_seed(seed: int) -> None:
|
| 26 |
+
random.seed(seed)
|
| 27 |
+
np.random.seed(seed)
|
| 28 |
+
torch.manual_seed(seed)
|
| 29 |
+
if torch.cuda.is_available():
|
| 30 |
+
torch.cuda.manual_seed_all(seed)
|
experiments/evaluate_features.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import json
|
| 6 |
+
from collections import defaultdict
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import scipy.sparse as sp
|
| 11 |
+
from sklearn.linear_model import LogisticRegression
|
| 12 |
+
from sklearn.metrics import f1_score, precision_recall_curve, roc_auc_score
|
| 13 |
+
from sklearn.pipeline import make_pipeline
|
| 14 |
+
from sklearn.preprocessing import LabelEncoder, StandardScaler
|
| 15 |
+
|
| 16 |
+
from experiments.common import ARTIFACT_DIR
|
| 17 |
+
from experiments.split import grouped_concept_split
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def parse_args() -> argparse.Namespace:
|
| 21 |
+
parser = argparse.ArgumentParser(description='Evaluate predictive SAE features and residual probes.')
|
| 22 |
+
parser.add_argument('--activation-dir', type=Path, default=ARTIFACT_DIR / 'activations')
|
| 23 |
+
parser.add_argument('--output-dir', type=Path, default=ARTIFACT_DIR)
|
| 24 |
+
parser.add_argument('--top-features', type=int, default=20)
|
| 25 |
+
parser.add_argument('--min-train-fires', type=int, default=3)
|
| 26 |
+
parser.add_argument('--seed', type=int, default=42)
|
| 27 |
+
return parser.parse_args()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _best_threshold(y_true: np.ndarray, scores: np.ndarray) -> float:
|
| 31 |
+
precision, recall, thresholds = precision_recall_curve(y_true, scores)
|
| 32 |
+
if thresholds.size == 0:
|
| 33 |
+
return 0.0
|
| 34 |
+
denom = precision[:-1] + recall[:-1]
|
| 35 |
+
f1 = np.divide(
|
| 36 |
+
2 * precision[:-1] * recall[:-1],
|
| 37 |
+
denom,
|
| 38 |
+
out=np.zeros_like(denom),
|
| 39 |
+
where=denom > 0,
|
| 40 |
+
)
|
| 41 |
+
return float(thresholds[int(np.argmax(f1))])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _evaluate_feature(
|
| 45 |
+
train_scores: np.ndarray,
|
| 46 |
+
test_scores: np.ndarray,
|
| 47 |
+
y_train: np.ndarray,
|
| 48 |
+
y_test: np.ndarray,
|
| 49 |
+
) -> tuple[float, float, float]:
|
| 50 |
+
threshold = _best_threshold(y_train, train_scores)
|
| 51 |
+
train_auc = float(roc_auc_score(y_train, train_scores))
|
| 52 |
+
test_auc = float(roc_auc_score(y_test, test_scores))
|
| 53 |
+
pred = (test_scores >= threshold).astype(int)
|
| 54 |
+
f1 = float(f1_score(y_test, pred, zero_division=0))
|
| 55 |
+
return train_auc, test_auc, f1, threshold
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _sparse_cosine(a: sp.csr_matrix, b: sp.csr_matrix) -> float:
|
| 59 |
+
numerator = float(a.multiply(b).sum())
|
| 60 |
+
denom = float(np.sqrt(a.multiply(a).sum()) * np.sqrt(b.multiply(b).sum()))
|
| 61 |
+
return numerator / denom if denom > 0 else 1.0
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _jaccard(a: sp.csr_matrix, b: sp.csr_matrix) -> float:
|
| 65 |
+
sa = set(a.indices.tolist())
|
| 66 |
+
sb = set(b.indices.tolist())
|
| 67 |
+
union = sa | sb
|
| 68 |
+
return len(sa & sb) / len(union) if union else 1.0
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main() -> None:
|
| 72 |
+
args = parse_args()
|
| 73 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 74 |
+
metadata = json.loads((args.activation_dir / 'metadata.json').read_text(encoding='utf-8'))
|
| 75 |
+
rows = metadata['rows']
|
| 76 |
+
layers = [int(x) for x in metadata['layers']]
|
| 77 |
+
train_idx, test_idx = grouped_concept_split(rows, seed=args.seed)
|
| 78 |
+
labels = np.array([row['concept'] for row in rows])
|
| 79 |
+
concepts = sorted(set(labels.tolist()))
|
| 80 |
+
|
| 81 |
+
feature_rows: list[dict] = []
|
| 82 |
+
layer_rows: list[dict] = []
|
| 83 |
+
stability_rows: list[dict] = []
|
| 84 |
+
|
| 85 |
+
encoder = LabelEncoder().fit(labels)
|
| 86 |
+
y_all = encoder.transform(labels)
|
| 87 |
+
y_train_multi = y_all[train_idx]
|
| 88 |
+
y_test_multi = y_all[test_idx]
|
| 89 |
+
|
| 90 |
+
for layer in layers:
|
| 91 |
+
x = sp.load_npz(args.activation_dir / f'features_layer{layer}.npz').tocsr()
|
| 92 |
+
x_csc = x.tocsc()
|
| 93 |
+
residuals = np.load(args.activation_dir / f'residuals_layer{layer}.npy').astype(np.float32)
|
| 94 |
+
recon = json.loads(
|
| 95 |
+
(args.activation_dir / f'reconstruction_layer{layer}.json').read_text(encoding='utf-8')
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
probe = make_pipeline(
|
| 99 |
+
StandardScaler(),
|
| 100 |
+
LogisticRegression(max_iter=2500, class_weight='balanced', random_state=args.seed),
|
| 101 |
+
)
|
| 102 |
+
probe.fit(residuals[train_idx], y_train_multi)
|
| 103 |
+
pred = probe.predict(residuals[test_idx])
|
| 104 |
+
probs = probe.predict_proba(residuals[test_idx])
|
| 105 |
+
probe_f1 = float(f1_score(y_test_multi, pred, average='macro'))
|
| 106 |
+
probe_auc = float(
|
| 107 |
+
roc_auc_score(y_test_multi, probs, multi_class='ovr', average='macro')
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
layer_rows.append(
|
| 111 |
+
{
|
| 112 |
+
'layer': layer,
|
| 113 |
+
'linear_probe_macro_auroc': probe_auc,
|
| 114 |
+
'linear_probe_macro_f1': probe_f1,
|
| 115 |
+
'reconstruction_cosine': recon['mean_cosine'],
|
| 116 |
+
'reconstruction_nmse': recon['mean_nmse'],
|
| 117 |
+
'mean_active_features': recon['mean_active_features'],
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
train_matrix = x[train_idx]
|
| 122 |
+
candidate_ids, counts = np.unique(train_matrix.indices, return_counts=True)
|
| 123 |
+
candidate_ids = candidate_ids[counts >= args.min_train_fires]
|
| 124 |
+
|
| 125 |
+
for concept in concepts:
|
| 126 |
+
y_train = (labels[train_idx] == concept).astype(int)
|
| 127 |
+
y_test = (labels[test_idx] == concept).astype(int)
|
| 128 |
+
concept_results: list[dict] = []
|
| 129 |
+
for feature_id in candidate_ids.tolist():
|
| 130 |
+
train_scores = x_csc[train_idx, feature_id].toarray().ravel()
|
| 131 |
+
if int((train_scores > 0).sum()) < args.min_train_fires:
|
| 132 |
+
continue
|
| 133 |
+
test_scores = x_csc[test_idx, feature_id].toarray().ravel()
|
| 134 |
+
train_auc, test_auc, f1, threshold = _evaluate_feature(
|
| 135 |
+
train_scores, test_scores, y_train, y_test
|
| 136 |
+
)
|
| 137 |
+
pos_train = train_scores[y_train == 1]
|
| 138 |
+
neg_train = train_scores[y_train == 0]
|
| 139 |
+
result = {
|
| 140 |
+
'layer': layer,
|
| 141 |
+
'concept': concept,
|
| 142 |
+
'feature_id': int(feature_id),
|
| 143 |
+
'train_auroc': train_auc,
|
| 144 |
+
'auroc': test_auc,
|
| 145 |
+
'f1': f1,
|
| 146 |
+
'threshold': threshold,
|
| 147 |
+
'activation_rate_pos': float(np.mean(pos_train > 0)),
|
| 148 |
+
'activation_rate_neg': float(np.mean(neg_train > 0)),
|
| 149 |
+
'mean_activation_pos': float(np.mean(pos_train)),
|
| 150 |
+
'mean_activation_neg': float(np.mean(neg_train)),
|
| 151 |
+
}
|
| 152 |
+
concept_results.append(result)
|
| 153 |
+
concept_results.sort(
|
| 154 |
+
key=lambda item: (item['train_auroc'], item['activation_rate_pos'] - item['activation_rate_neg']),
|
| 155 |
+
reverse=True,
|
| 156 |
+
)
|
| 157 |
+
feature_rows.extend(concept_results[: args.top_features])
|
| 158 |
+
|
| 159 |
+
pair_map: dict[str, list[int]] = defaultdict(list)
|
| 160 |
+
for idx, row in enumerate(rows):
|
| 161 |
+
pair_map[row['pair_id']].append(idx)
|
| 162 |
+
for pair_id, indices in pair_map.items():
|
| 163 |
+
if len(indices) != 2:
|
| 164 |
+
continue
|
| 165 |
+
a, b = indices
|
| 166 |
+
stability_rows.append(
|
| 167 |
+
{
|
| 168 |
+
'layer': layer,
|
| 169 |
+
'pair_id': pair_id,
|
| 170 |
+
'concept': rows[a]['concept'],
|
| 171 |
+
'topk_jaccard': _jaccard(x.getrow(a), x.getrow(b)),
|
| 172 |
+
'sparse_cosine': _sparse_cosine(x.getrow(a), x.getrow(b)),
|
| 173 |
+
}
|
| 174 |
+
)
|
| 175 |
+
print(f'Evaluated layer {layer}', flush=True)
|
| 176 |
+
|
| 177 |
+
with (args.output_dir / 'feature_catalog.csv').open('w', newline='', encoding='utf-8') as handle:
|
| 178 |
+
writer = csv.DictWriter(handle, fieldnames=list(feature_rows[0].keys()))
|
| 179 |
+
writer.writeheader()
|
| 180 |
+
writer.writerows(feature_rows)
|
| 181 |
+
|
| 182 |
+
with (args.output_dir / 'layer_metrics.csv').open('w', newline='', encoding='utf-8') as handle:
|
| 183 |
+
writer = csv.DictWriter(handle, fieldnames=list(layer_rows[0].keys()))
|
| 184 |
+
writer.writeheader()
|
| 185 |
+
writer.writerows(layer_rows)
|
| 186 |
+
|
| 187 |
+
with (args.output_dir / 'stability.csv').open('w', newline='', encoding='utf-8') as handle:
|
| 188 |
+
writer = csv.DictWriter(handle, fieldnames=list(stability_rows[0].keys()))
|
| 189 |
+
writer.writeheader()
|
| 190 |
+
writer.writerows(stability_rows)
|
| 191 |
+
|
| 192 |
+
split_payload = {
|
| 193 |
+
'seed': args.seed,
|
| 194 |
+
'train_indices': train_idx,
|
| 195 |
+
'test_indices': test_idx,
|
| 196 |
+
'n_train': len(train_idx),
|
| 197 |
+
'n_test': len(test_idx),
|
| 198 |
+
}
|
| 199 |
+
(args.output_dir / 'split.json').write_text(json.dumps(split_payload, indent=2), encoding='utf-8')
|
| 200 |
+
print(f'Wrote evaluation artifacts to {args.output_dir}')
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
if __name__ == '__main__':
|
| 204 |
+
main()
|
experiments/make_report.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from experiments.common import ARTIFACT_DIR
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def parse_args() -> argparse.Namespace:
|
| 15 |
+
parser = argparse.ArgumentParser(description='Build a truthful experiment report from saved metrics.')
|
| 16 |
+
parser.add_argument('--artifact-dir', type=Path, default=ARTIFACT_DIR)
|
| 17 |
+
return parser.parse_args()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
|
| 21 |
+
scored = catalog.copy()
|
| 22 |
+
scored['activation_contrast'] = (
|
| 23 |
+
scored['activation_rate_pos'] - scored['activation_rate_neg']
|
| 24 |
+
)
|
| 25 |
+
ordered = scored.sort_values(
|
| 26 |
+
['concept', 'train_auroc', 'activation_contrast'],
|
| 27 |
+
ascending=[True, False, False],
|
| 28 |
+
)
|
| 29 |
+
return ordered.groupby('concept', as_index=False).first()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _save_plots(artifact_dir: Path, selected: pd.DataFrame, layers: pd.DataFrame, causal: pd.DataFrame) -> None:
|
| 33 |
+
fig_dir = artifact_dir / 'figures'
|
| 34 |
+
fig_dir.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
figure = plt.figure(figsize=(7.5, 4.2))
|
| 37 |
+
ax = figure.add_subplot(111)
|
| 38 |
+
ordered = selected.sort_values('auroc')
|
| 39 |
+
ax.barh(ordered['concept'], ordered['auroc'])
|
| 40 |
+
ax.axvline(0.5, linewidth=1, linestyle='--')
|
| 41 |
+
ax.set_xlabel('Held-out AUROC')
|
| 42 |
+
ax.set_title('Selected SAE feature predictiveness')
|
| 43 |
+
figure.tight_layout()
|
| 44 |
+
figure.savefig(fig_dir / 'feature_auroc.png', dpi=160)
|
| 45 |
+
plt.close(figure)
|
| 46 |
+
|
| 47 |
+
figure = plt.figure(figsize=(7.0, 4.2))
|
| 48 |
+
ax = figure.add_subplot(111)
|
| 49 |
+
ax.plot(layers['layer'], layers['linear_probe_macro_auroc'], marker='o', label='Linear probe AUROC')
|
| 50 |
+
ax.plot(layers['layer'], layers['reconstruction_cosine'], marker='o', label='SAE reconstruction cosine')
|
| 51 |
+
ax.set_xlabel('Layer')
|
| 52 |
+
ax.set_ylim(0, 1.05)
|
| 53 |
+
ax.set_title('Layer-wise representation diagnostics')
|
| 54 |
+
ax.legend()
|
| 55 |
+
figure.tight_layout()
|
| 56 |
+
figure.savefig(fig_dir / 'layer_diagnostics.png', dpi=160)
|
| 57 |
+
plt.close(figure)
|
| 58 |
+
|
| 59 |
+
grouped = (
|
| 60 |
+
causal.groupby(['intervention', 'condition'])['target_logprob_delta']
|
| 61 |
+
.apply(lambda values: float(np.mean(np.abs(values))))
|
| 62 |
+
.reset_index(name='mean_abs_delta_logp')
|
| 63 |
+
)
|
| 64 |
+
pivot = grouped.pivot(index='intervention', columns='condition', values='mean_abs_delta_logp')
|
| 65 |
+
figure = plt.figure(figsize=(7.0, 4.2))
|
| 66 |
+
ax = figure.add_subplot(111)
|
| 67 |
+
pivot.plot(kind='bar', ax=ax)
|
| 68 |
+
ax.set_ylabel('Mean |Δ log p(target)|')
|
| 69 |
+
ax.set_title('SAE interventions vs norm-matched random controls')
|
| 70 |
+
ax.tick_params(axis='x', rotation=0)
|
| 71 |
+
figure.tight_layout()
|
| 72 |
+
figure.savefig(fig_dir / 'causal_effects.png', dpi=160)
|
| 73 |
+
plt.close(figure)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def main() -> None:
|
| 77 |
+
args = parse_args()
|
| 78 |
+
catalog = pd.read_csv(args.artifact_dir / 'feature_catalog.csv')
|
| 79 |
+
layers = pd.read_csv(args.artifact_dir / 'layer_metrics.csv')
|
| 80 |
+
stability = pd.read_csv(args.artifact_dir / 'stability.csv')
|
| 81 |
+
causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
|
| 82 |
+
selected = _selected_features(catalog)
|
| 83 |
+
_save_plots(args.artifact_dir, selected, layers, causal)
|
| 84 |
+
|
| 85 |
+
mean_auc = float(selected['auroc'].mean())
|
| 86 |
+
median_auc = float(selected['auroc'].median())
|
| 87 |
+
best_layer_row = layers.sort_values('linear_probe_macro_auroc', ascending=False).iloc[0]
|
| 88 |
+
mean_jaccard = float(stability['topk_jaccard'].mean())
|
| 89 |
+
mean_sparse_cos = float(stability['sparse_cosine'].mean())
|
| 90 |
+
|
| 91 |
+
sae = causal[causal['condition'] == 'sae_feature']
|
| 92 |
+
random = causal[causal['condition'] == 'random_norm_matched']
|
| 93 |
+
sae_abs = float(np.mean(np.abs(sae['target_logprob_delta'])))
|
| 94 |
+
random_abs = float(np.mean(np.abs(random['target_logprob_delta'])))
|
| 95 |
+
ratio = sae_abs / max(random_abs, 1e-12)
|
| 96 |
+
active_rate = float(np.mean(sae['feature_activation'] > 0))
|
| 97 |
+
top1_change = float(sae['top1_changed'].mean())
|
| 98 |
+
|
| 99 |
+
if mean_auc >= 0.8 and sae_abs < 0.08:
|
| 100 |
+
interpretation = (
|
| 101 |
+
'The selected sparse features were strongly predictive on held-out prompts, but their '
|
| 102 |
+
'interventions produced only modest downstream target-probability changes. This supports '
|
| 103 |
+
'the project’s central warning that representation-level correlation need not imply strong '
|
| 104 |
+
'causal control.'
|
| 105 |
+
)
|
| 106 |
+
elif mean_auc >= 0.8 and sae_abs >= 0.08 and ratio >= 1.5:
|
| 107 |
+
interpretation = (
|
| 108 |
+
'The selected sparse features were strongly predictive and their interventions produced '
|
| 109 |
+
'larger target-probability shifts than norm-matched random residual perturbations, providing '
|
| 110 |
+
'evidence that at least some predictive features also have downstream causal influence.'
|
| 111 |
+
)
|
| 112 |
+
elif mean_auc < 0.65:
|
| 113 |
+
interpretation = (
|
| 114 |
+
'Feature/concept predictiveness was limited on held-out prompts, so strong causal claims '
|
| 115 |
+
'would be premature. The main result is diagnostic: the chosen concepts or feature-selection '
|
| 116 |
+
'procedure should be refined before interpreting intervention effects.'
|
| 117 |
+
)
|
| 118 |
+
else:
|
| 119 |
+
interpretation = (
|
| 120 |
+
'The results show mixed predictive and causal evidence. FeatureLens therefore reports the '
|
| 121 |
+
'association and intervention measurements separately rather than collapsing them into a '
|
| 122 |
+
'single interpretability score.'
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
headline = (
|
| 126 |
+
f'Selected SAE features averaged {mean_auc:.3f} held-out AUROC; SAE interventions changed '
|
| 127 |
+
f'target log-probability by {sae_abs:.3f} on average versus {random_abs:.3f} for norm-matched '
|
| 128 |
+
'random residual controls.'
|
| 129 |
+
)
|
| 130 |
+
highlights = [
|
| 131 |
+
f'Median selected-feature held-out AUROC: {median_auc:.3f}.',
|
| 132 |
+
f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
|
| 133 |
+
f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
|
| 134 |
+
f'Selected feature active on {active_rate:.1%} of causal prompts; modified top-1 token on {top1_change:.1%}.',
|
| 135 |
+
f'Mean absolute causal effect / random-control effect ratio: {ratio:.2f}×.',
|
| 136 |
+
]
|
| 137 |
+
summary = {
|
| 138 |
+
'headline': headline,
|
| 139 |
+
'highlights': highlights,
|
| 140 |
+
'interpretation': interpretation,
|
| 141 |
+
'metrics': {
|
| 142 |
+
'mean_selected_feature_test_auroc': mean_auc,
|
| 143 |
+
'median_selected_feature_test_auroc': median_auc,
|
| 144 |
+
'best_linear_probe_layer': int(best_layer_row['layer']),
|
| 145 |
+
'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
|
| 146 |
+
'mean_paraphrase_topk_jaccard': mean_jaccard,
|
| 147 |
+
'mean_paraphrase_sparse_cosine': mean_sparse_cos,
|
| 148 |
+
'mean_abs_sae_target_logprob_delta': sae_abs,
|
| 149 |
+
'mean_abs_random_target_logprob_delta': random_abs,
|
| 150 |
+
'causal_to_random_effect_ratio': ratio,
|
| 151 |
+
'causal_prompt_feature_active_rate': active_rate,
|
| 152 |
+
'sae_top1_change_rate': top1_change,
|
| 153 |
+
},
|
| 154 |
+
}
|
| 155 |
+
(args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
|
| 156 |
+
|
| 157 |
+
lines = [
|
| 158 |
+
'# FeatureLens experiment report',
|
| 159 |
+
'',
|
| 160 |
+
'## Research question',
|
| 161 |
+
'',
|
| 162 |
+
'**Do sparse features that predict a concept also causally influence model behaviour?**',
|
| 163 |
+
'',
|
| 164 |
+
'## Executive summary',
|
| 165 |
+
'',
|
| 166 |
+
headline,
|
| 167 |
+
'',
|
| 168 |
+
interpretation,
|
| 169 |
+
'',
|
| 170 |
+
'## Key measurements',
|
| 171 |
+
'',
|
| 172 |
+
*[f'- {item}' for item in highlights],
|
| 173 |
+
'',
|
| 174 |
+
'## Experimental design',
|
| 175 |
+
'',
|
| 176 |
+
'- Model: Qwen3-1.7B-Base.',
|
| 177 |
+
'- SAEs: Qwen-Scope residual-stream TopK SAEs at the configured early/middle/late layers.',
|
| 178 |
+
'- Discovery set: controlled concept prompts with paired paraphrases.',
|
| 179 |
+
'- Split discipline: paraphrase groups are kept entirely in train or held-out test.',
|
| 180 |
+
'- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
|
| 181 |
+
'- Linear baseline: multinomial logistic regression on the dense residual stream.',
|
| 182 |
+
'- Causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
|
| 183 |
+
'- Negative control: deterministic random residual direction matched to the SAE perturbation L2 norm.',
|
| 184 |
+
'- Behavioural metric: first-token target probability/log-probability, rank, JS divergence, and top-1 changes.',
|
| 185 |
+
'',
|
| 186 |
+
'## Figures',
|
| 187 |
+
'',
|
| 188 |
+
'',
|
| 189 |
+
'',
|
| 190 |
+
'',
|
| 191 |
+
'',
|
| 192 |
+
'',
|
| 193 |
+
'',
|
| 194 |
+
'## Interpretation guardrails',
|
| 195 |
+
'',
|
| 196 |
+
'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.',
|
| 197 |
+
'',
|
| 198 |
+
'## Reproducibility',
|
| 199 |
+
'',
|
| 200 |
+
'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/`.',
|
| 201 |
+
'',
|
| 202 |
+
]
|
| 203 |
+
(args.artifact_dir / 'report.md').write_text('\n'.join(lines), encoding='utf-8')
|
| 204 |
+
print(headline)
|
| 205 |
+
print(f'Wrote {args.artifact_dir / "report.md"}')
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
if __name__ == '__main__':
|
| 209 |
+
main()
|
experiments/run_all.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def run(script: str, *args: str) -> None:
|
| 11 |
+
module = f"experiments.{Path(script).stem}"
|
| 12 |
+
command = [sys.executable, '-m', module, *args]
|
| 13 |
+
print('\n$', ' '.join(command), flush=True)
|
| 14 |
+
subprocess.run(command, cwd=ROOT, check=True)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def main() -> None:
|
| 18 |
+
run('build_dataset.py')
|
| 19 |
+
run('collect_activations.py')
|
| 20 |
+
run('evaluate_features.py')
|
| 21 |
+
run('run_causal.py')
|
| 22 |
+
run('make_report.py')
|
| 23 |
+
print('\nFeatureLens experiment pipeline complete. See artifacts/report.md')
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
if __name__ == '__main__':
|
| 27 |
+
main()
|
experiments/run_causal.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import math
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 10 |
+
|
| 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, safe_log_probability
|
| 15 |
+
from featurelens.sae import SAEStore
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_args() -> argparse.Namespace:
|
| 19 |
+
parser = argparse.ArgumentParser(description='Run held-out causal SAE interventions.')
|
| 20 |
+
parser.add_argument('--tasks', type=Path, default=DATA_DIR / 'causal_tasks.jsonl')
|
| 21 |
+
parser.add_argument('--catalog', type=Path, default=ARTIFACT_DIR / 'feature_catalog.csv')
|
| 22 |
+
parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'causal_results.csv')
|
| 23 |
+
parser.add_argument('--seed', type=int, default=42)
|
| 24 |
+
return parser.parse_args()
|
| 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] = {}
|
| 32 |
+
for row in rows:
|
| 33 |
+
concept = row['concept']
|
| 34 |
+
score = float(row['train_auroc'])
|
| 35 |
+
contrast = float(row['activation_rate_pos']) - float(row['activation_rate_neg'])
|
| 36 |
+
key = (score, contrast)
|
| 37 |
+
if concept not in selected or key > selected[concept]['_key']:
|
| 38 |
+
selected[concept] = {
|
| 39 |
+
'_key': key,
|
| 40 |
+
'layer': int(row['layer']),
|
| 41 |
+
'feature_id': int(row['feature_id']),
|
| 42 |
+
'train_auroc': score,
|
| 43 |
+
'test_auroc': float(row['auroc']),
|
| 44 |
+
'test_f1': float(row['f1']),
|
| 45 |
+
}
|
| 46 |
+
return selected
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def hidden_from_output(output):
|
| 50 |
+
return output[0] if isinstance(output, tuple) else output
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
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()
|
| 60 |
+
set_seed(args.seed)
|
| 61 |
+
tasks = load_jsonl(args.tasks)
|
| 62 |
+
selected = load_selected_features(args.catalog)
|
| 63 |
+
missing = sorted({task['concept'] for task in tasks}.difference(selected))
|
| 64 |
+
if missing:
|
| 65 |
+
raise RuntimeError(f'No selected SAE features for concepts: {missing}')
|
| 66 |
+
|
| 67 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 68 |
+
model_dtype = torch.float16 if device.type == 'cuda' else torch.float32
|
| 69 |
+
tokenizer = AutoTokenizer.from_pretrained(SETTINGS.model_id)
|
| 70 |
+
if tokenizer.pad_token_id is None:
|
| 71 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 72 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 73 |
+
SETTINGS.model_id,
|
| 74 |
+
torch_dtype=model_dtype,
|
| 75 |
+
low_cpu_mem_usage=True,
|
| 76 |
+
).to(device)
|
| 77 |
+
model.eval()
|
| 78 |
+
selected_layers = sorted({item['layer'] for item in selected.values()})
|
| 79 |
+
sae_store = SAEStore(
|
| 80 |
+
SETTINGS.sae_repo_id,
|
| 81 |
+
layers=selected_layers,
|
| 82 |
+
device=device,
|
| 83 |
+
dtype=torch.float32,
|
| 84 |
+
top_k=SETTINGS.sae_top_k,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
results: list[dict] = []
|
| 88 |
+
for task_idx, task in enumerate(tasks):
|
| 89 |
+
concept = task['concept']
|
| 90 |
+
choice = selected[concept]
|
| 91 |
+
layer = int(choice['layer'])
|
| 92 |
+
feature_id = int(choice['feature_id'])
|
| 93 |
+
sae = sae_store.get(layer)
|
| 94 |
+
inputs = tokenizer(task['prompt'], return_tensors='pt', truncation=True, max_length=192)
|
| 95 |
+
inputs = {key: value.to(device) for key, value in inputs.items()}
|
| 96 |
+
capture: dict = {}
|
| 97 |
+
|
| 98 |
+
def capture_hook(_module, _inp, output):
|
| 99 |
+
if 'hidden' not in capture:
|
| 100 |
+
capture['hidden'] = hidden_from_output(output).detach()
|
| 101 |
+
|
| 102 |
+
handle = model.model.layers[layer].register_forward_hook(capture_hook)
|
| 103 |
+
baseline_out = model(**inputs, use_cache=False)
|
| 104 |
+
handle.remove()
|
| 105 |
+
baseline_logits = baseline_out.logits[0, -1]
|
| 106 |
+
residual = capture['hidden'][0, -1]
|
| 107 |
+
encoding = sae.encode(residual)
|
| 108 |
+
original_activation = encoding.activation_for(feature_id)
|
| 109 |
+
|
| 110 |
+
target_ids = tokenizer(task['target'], add_special_tokens=False)['input_ids']
|
| 111 |
+
if not target_ids:
|
| 112 |
+
raise RuntimeError(f"Target tokenization empty for task {task['id']}")
|
| 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 |
+
for intervention_name, spec in specs:
|
| 123 |
+
delta = residual_delta(sae.decoder_direction(feature_id), original_activation, spec)
|
| 124 |
+
control_delta = normalized_random_control(delta, seed=args.seed + task_idx * 17 + len(results))
|
| 125 |
+
|
| 126 |
+
for condition, applied_delta in (
|
| 127 |
+
('sae_feature', delta),
|
| 128 |
+
('random_norm_matched', control_delta),
|
| 129 |
+
):
|
| 130 |
+
applied = {'done': False}
|
| 131 |
+
|
| 132 |
+
def edit_hook(_module, _inp, output, *, edit=applied_delta, state=applied):
|
| 133 |
+
if state['done']:
|
| 134 |
+
return output
|
| 135 |
+
hidden = hidden_from_output(output)
|
| 136 |
+
modified = hidden.clone()
|
| 137 |
+
modified[:, -1, :] = modified[:, -1, :] + edit.to(hidden.device, hidden.dtype)
|
| 138 |
+
state['done'] = True
|
| 139 |
+
return replace_hidden(output, modified)
|
| 140 |
+
|
| 141 |
+
hook = model.model.layers[layer].register_forward_hook(edit_hook)
|
| 142 |
+
modified_out = model(**inputs, use_cache=False)
|
| 143 |
+
hook.remove()
|
| 144 |
+
modified_logits = modified_out.logits[0, -1]
|
| 145 |
+
modified_prob = float(
|
| 146 |
+
torch.softmax(modified_logits.float(), dim=-1)[target_id].item()
|
| 147 |
+
)
|
| 148 |
+
modified_rank = int((modified_logits > modified_logits[target_id]).sum().item()) + 1
|
| 149 |
+
modified_top1 = int(torch.argmax(modified_logits).item())
|
| 150 |
+
results.append(
|
| 151 |
+
{
|
| 152 |
+
'task_id': task['id'],
|
| 153 |
+
'concept': concept,
|
| 154 |
+
'prompt': task['prompt'],
|
| 155 |
+
'target_text': task['target'],
|
| 156 |
+
'target_first_token': tokenizer.decode([target_id]),
|
| 157 |
+
'target_token_count': len(target_ids),
|
| 158 |
+
'layer': layer,
|
| 159 |
+
'feature_id': feature_id,
|
| 160 |
+
'feature_train_auroc': choice['train_auroc'],
|
| 161 |
+
'feature_test_auroc': choice['test_auroc'],
|
| 162 |
+
'feature_test_f1': choice['test_f1'],
|
| 163 |
+
'feature_activation': original_activation,
|
| 164 |
+
'intervention': intervention_name,
|
| 165 |
+
'condition': condition,
|
| 166 |
+
'delta_activation': spec.delta_activation(original_activation)
|
| 167 |
+
if condition == 'sae_feature'
|
| 168 |
+
else math.nan,
|
| 169 |
+
'perturbation_l2': float(torch.linalg.vector_norm(applied_delta.float()).item()),
|
| 170 |
+
'baseline_target_prob': baseline_prob,
|
| 171 |
+
'modified_target_prob': modified_prob,
|
| 172 |
+
'target_prob_delta': modified_prob - baseline_prob,
|
| 173 |
+
'target_logprob_delta': safe_log_probability(modified_prob)
|
| 174 |
+
- safe_log_probability(baseline_prob),
|
| 175 |
+
'baseline_target_rank': baseline_rank,
|
| 176 |
+
'modified_target_rank': modified_rank,
|
| 177 |
+
'target_rank_delta': modified_rank - baseline_rank,
|
| 178 |
+
'js_divergence': js_divergence_from_logits(
|
| 179 |
+
baseline_logits, modified_logits
|
| 180 |
+
),
|
| 181 |
+
'top1_changed': int(modified_top1 != baseline_top1),
|
| 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)
|
| 187 |
+
with args.output.open('w', newline='', encoding='utf-8') as handle:
|
| 188 |
+
writer = csv.DictWriter(handle, fieldnames=list(results[0].keys()))
|
| 189 |
+
writer.writeheader()
|
| 190 |
+
writer.writerows(results)
|
| 191 |
+
print(f'Wrote {len(results)} causal intervention rows to {args.output}')
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
if __name__ == '__main__':
|
| 195 |
+
main()
|
experiments/split.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def grouped_concept_split(rows: list[dict], test_fraction: float = 0.25, seed: int = 42):
|
| 7 |
+
"""Split paraphrase groups within each concept so paired prompts never leak across splits."""
|
| 8 |
+
rng = random.Random(seed)
|
| 9 |
+
train_ids: list[int] = []
|
| 10 |
+
test_ids: list[int] = []
|
| 11 |
+
concepts = sorted({row['concept'] for row in rows})
|
| 12 |
+
for concept in concepts:
|
| 13 |
+
concept_rows = [(idx, row) for idx, row in enumerate(rows) if row['concept'] == concept]
|
| 14 |
+
pair_ids = sorted({row['pair_id'] for _, row in concept_rows})
|
| 15 |
+
rng.shuffle(pair_ids)
|
| 16 |
+
n_test = max(1, round(len(pair_ids) * test_fraction))
|
| 17 |
+
test_pairs = set(pair_ids[:n_test])
|
| 18 |
+
for idx, row in concept_rows:
|
| 19 |
+
(test_ids if row['pair_id'] in test_pairs else train_ids).append(idx)
|
| 20 |
+
return sorted(train_ids), sorted(test_ids)
|
featurelens/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FeatureLens: causal sparse-feature interpretability for Qwen3."""
|
| 2 |
+
|
| 3 |
+
from .config import SETTINGS, Settings
|
| 4 |
+
from .interventions import InterventionSpec
|
| 5 |
+
from .sae import SAEStore, SAEWeights, SparseEncoding
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
'SETTINGS',
|
| 9 |
+
'Settings',
|
| 10 |
+
'InterventionSpec',
|
| 11 |
+
'SAEStore',
|
| 12 |
+
'SAEWeights',
|
| 13 |
+
'SparseEncoding',
|
| 14 |
+
]
|
featurelens/catalog.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import csv
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class FeatureCatalog:
|
| 9 |
+
def __init__(self, artifact_dir: str | Path = 'artifacts') -> None:
|
| 10 |
+
self.artifact_dir = Path(artifact_dir)
|
| 11 |
+
self.rows: list[dict] = []
|
| 12 |
+
path = self.artifact_dir / 'feature_catalog.csv'
|
| 13 |
+
if path.exists():
|
| 14 |
+
with path.open(newline='', encoding='utf-8') as handle:
|
| 15 |
+
self.rows = list(csv.DictReader(handle))
|
| 16 |
+
|
| 17 |
+
def hint(self, layer: int, feature_id: int) -> str:
|
| 18 |
+
matches = [
|
| 19 |
+
row
|
| 20 |
+
for row in self.rows
|
| 21 |
+
if int(row.get('layer', -1)) == int(layer)
|
| 22 |
+
and int(row.get('feature_id', -1)) == int(feature_id)
|
| 23 |
+
]
|
| 24 |
+
if not matches:
|
| 25 |
+
return 'unlabeled'
|
| 26 |
+
best = max(
|
| 27 |
+
matches,
|
| 28 |
+
key=lambda row: float(row.get('train_auroc', row.get('auroc', 0.0)) or 0.0),
|
| 29 |
+
)
|
| 30 |
+
concept = best.get('concept', 'unlabeled')
|
| 31 |
+
auc = float(best.get('auroc', 0.0) or 0.0)
|
| 32 |
+
return f'{concept} (AUROC {auc:.2f})'
|
| 33 |
+
|
| 34 |
+
def benchmark_markdown(self) -> str:
|
| 35 |
+
summary_path = self.artifact_dir / 'summary.json'
|
| 36 |
+
if not summary_path.exists():
|
| 37 |
+
return (
|
| 38 |
+
'### Offline benchmark\n\n'
|
| 39 |
+
'No benchmark artifacts are committed yet. Run `python experiments/run_all.py` '
|
| 40 |
+
'on a CUDA machine, then commit `artifacts/summary.json`, `feature_catalog.csv`, '
|
| 41 |
+
'and `report.md`. The live workbench is fully usable without them.'
|
| 42 |
+
)
|
| 43 |
+
data = json.loads(summary_path.read_text(encoding='utf-8'))
|
| 44 |
+
headline = data.get('headline', 'Benchmark completed.')
|
| 45 |
+
bullets = data.get('highlights', [])
|
| 46 |
+
body = '\n'.join(f'- {item}' for item in bullets)
|
| 47 |
+
return f'### Offline benchmark\n\n{headline}\n\n{body}'
|
featurelens/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _parse_layers(raw: str | None) -> tuple[int, ...]:
|
| 8 |
+
if not raw:
|
| 9 |
+
return (4, 14, 26)
|
| 10 |
+
layers = tuple(sorted({int(x.strip()) for x in raw.split(',') if x.strip()}))
|
| 11 |
+
if not layers:
|
| 12 |
+
raise ValueError('FEATURELENS_LAYERS must contain at least one layer.')
|
| 13 |
+
if any(layer < 0 or layer > 27 for layer in layers):
|
| 14 |
+
raise ValueError('Qwen3-1.7B has residual-stream SAE layers 0-27.')
|
| 15 |
+
return layers
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class Settings:
|
| 20 |
+
model_id: str = os.getenv('FEATURELENS_MODEL_ID', 'Qwen/Qwen3-1.7B-Base')
|
| 21 |
+
sae_repo_id: str = os.getenv(
|
| 22 |
+
'FEATURELENS_SAE_REPO', 'Qwen/SAE-Res-Qwen3-1.7B-Base-W32K-L0_50'
|
| 23 |
+
)
|
| 24 |
+
layers: tuple[int, ...] = _parse_layers(os.getenv('FEATURELENS_LAYERS'))
|
| 25 |
+
sae_top_k: int = int(os.getenv('FEATURELENS_SAE_TOP_K', '50'))
|
| 26 |
+
sae_width: int = 32_768
|
| 27 |
+
d_model: int = 2_048
|
| 28 |
+
max_prompt_tokens: int = int(os.getenv('FEATURELENS_MAX_PROMPT_TOKENS', '256'))
|
| 29 |
+
max_new_tokens: int = int(os.getenv('FEATURELENS_MAX_NEW_TOKENS', '32'))
|
| 30 |
+
eager_load: bool = os.getenv(
|
| 31 |
+
'FEATURELENS_EAGER_LOAD', '1' if os.getenv('SPACE_ID') else '0'
|
| 32 |
+
).lower() in {'1', 'true', 'yes', 'on'}
|
| 33 |
+
sae_dtype: str = os.getenv(
|
| 34 |
+
'FEATURELENS_SAE_DTYPE', 'float16' if os.getenv('SPACE_ID') else 'float32'
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
SETTINGS = Settings()
|
featurelens/hf_runtime.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
try:
|
| 4 |
+
import spaces # type: ignore
|
| 5 |
+
except ImportError: # Local development and unit tests.
|
| 6 |
+
spaces = None
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def gpu(duration: int = 60):
|
| 10 |
+
if spaces is not None and hasattr(spaces, "GPU"):
|
| 11 |
+
return spaces.GPU(duration=duration)
|
| 12 |
+
|
| 13 |
+
def decorator(fn):
|
| 14 |
+
return fn
|
| 15 |
+
|
| 16 |
+
return decorator
|
featurelens/interventions.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class InterventionSpec:
|
| 10 |
+
mode: str
|
| 11 |
+
coefficient: float
|
| 12 |
+
|
| 13 |
+
def delta_activation(self, original_activation: float) -> float:
|
| 14 |
+
mode = self.mode.lower().strip()
|
| 15 |
+
original = float(original_activation)
|
| 16 |
+
coefficient = float(self.coefficient)
|
| 17 |
+
if mode == 'ablate':
|
| 18 |
+
return -original
|
| 19 |
+
if mode == 'scale':
|
| 20 |
+
return (coefficient - 1.0) * original
|
| 21 |
+
if mode == 'inject':
|
| 22 |
+
return coefficient
|
| 23 |
+
raise ValueError("mode must be one of: 'ablate', 'scale', 'inject'.")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def residual_delta(
|
| 27 |
+
decoder_direction: torch.Tensor,
|
| 28 |
+
original_activation: float,
|
| 29 |
+
spec: InterventionSpec,
|
| 30 |
+
) -> torch.Tensor:
|
| 31 |
+
"""Return the reconstruction-preserving SAE delta applied to the original residual."""
|
| 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())
|
| 38 |
+
if float(norm.item()) == 0.0:
|
| 39 |
+
return torch.zeros_like(delta)
|
| 40 |
+
generator = torch.Generator(device='cpu').manual_seed(int(seed))
|
| 41 |
+
random_vec = torch.randn(delta.shape, generator=generator, dtype=torch.float32)
|
| 42 |
+
random_vec = random_vec / torch.linalg.vector_norm(random_vec)
|
| 43 |
+
random_vec = random_vec * norm.cpu()
|
| 44 |
+
return random_vec.to(device=delta.device, dtype=delta.dtype)
|
featurelens/metrics.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def reconstruction_metrics(original: torch.Tensor, reconstructed: torch.Tensor) -> dict[str, float]:
|
| 10 |
+
x = original.float().reshape(-1)
|
| 11 |
+
x_hat = reconstructed.float().reshape(-1)
|
| 12 |
+
mse = torch.mean((x - x_hat) ** 2)
|
| 13 |
+
denom = torch.mean(x**2).clamp_min(1e-12)
|
| 14 |
+
nmse = mse / denom
|
| 15 |
+
cosine = torch.nn.functional.cosine_similarity(x.unsqueeze(0), x_hat.unsqueeze(0)).item()
|
| 16 |
+
return {
|
| 17 |
+
'mse': float(mse.item()),
|
| 18 |
+
'nmse': float(nmse.item()),
|
| 19 |
+
'cosine': float(cosine),
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def js_divergence_from_logits(logits_a: torch.Tensor, logits_b: torch.Tensor) -> float:
|
| 24 |
+
p = torch.softmax(logits_a.float(), dim=-1)
|
| 25 |
+
q = torch.softmax(logits_b.float(), dim=-1)
|
| 26 |
+
m = 0.5 * (p + q)
|
| 27 |
+
eps = 1e-12
|
| 28 |
+
kl_pm = torch.sum(p * (torch.log(p + eps) - torch.log(m + eps)))
|
| 29 |
+
kl_qm = torch.sum(q * (torch.log(q + eps) - torch.log(m + eps)))
|
| 30 |
+
return float((0.5 * (kl_pm + kl_qm)).item())
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def safe_log_probability(probability: float) -> float:
|
| 34 |
+
return math.log(max(float(probability), 1e-12))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def sparse_jaccard(indices_a: np.ndarray, values_a: np.ndarray, indices_b: np.ndarray, values_b: np.ndarray) -> float:
|
| 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)
|
featurelens/runtime.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
import os
|
| 5 |
+
from collections.abc import Iterator
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 11 |
+
|
| 12 |
+
from .catalog import FeatureCatalog
|
| 13 |
+
from .config import SETTINGS, Settings
|
| 14 |
+
from .interventions import InterventionSpec, residual_delta
|
| 15 |
+
from .metrics import js_divergence_from_logits, reconstruction_metrics, safe_log_probability
|
| 16 |
+
from .sae import SAEStore, SparseEncoding
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _dtype_from_name(name: str) -> torch.dtype:
|
| 20 |
+
name = name.lower().strip()
|
| 21 |
+
if name in {'float16', 'fp16', 'half'}:
|
| 22 |
+
return torch.float16
|
| 23 |
+
if name in {'bfloat16', 'bf16'}:
|
| 24 |
+
return torch.bfloat16
|
| 25 |
+
if name in {'float32', 'fp32'}:
|
| 26 |
+
return torch.float32
|
| 27 |
+
raise ValueError(f'Unsupported dtype: {name}')
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _default_device() -> torch.device:
|
| 31 |
+
# ZeroGPU exposes CUDA emulation at module load time. Force the recommended
|
| 32 |
+
# CUDA placement on Spaces even if a local availability probe is conservative.
|
| 33 |
+
if os.getenv('SPACE_ID'):
|
| 34 |
+
return torch.device('cuda')
|
| 35 |
+
if torch.cuda.is_available():
|
| 36 |
+
return torch.device('cuda')
|
| 37 |
+
return torch.device('cpu')
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class AnalysisResult:
|
| 42 |
+
tokens: list[str]
|
| 43 |
+
token_index: int
|
| 44 |
+
layer: int
|
| 45 |
+
features: SparseEncoding
|
| 46 |
+
rows: list[list[object]]
|
| 47 |
+
metrics: dict[str, float]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class InterventionResult:
|
| 52 |
+
baseline_text: str
|
| 53 |
+
modified_text: str
|
| 54 |
+
feature_activation: float
|
| 55 |
+
delta_activation: float
|
| 56 |
+
perturbation_norm: float
|
| 57 |
+
js_divergence: float
|
| 58 |
+
target_text: str
|
| 59 |
+
target_token: str
|
| 60 |
+
target_token_count: int
|
| 61 |
+
baseline_target_prob: float | None
|
| 62 |
+
modified_target_prob: float | None
|
| 63 |
+
target_logprob_delta: float | None
|
| 64 |
+
top_token_rows: list[list[object]]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class FeatureLensRuntime:
|
| 68 |
+
def __init__(self, settings: Settings = SETTINGS) -> None:
|
| 69 |
+
self.settings = settings
|
| 70 |
+
self.device = _default_device()
|
| 71 |
+
self.model_dtype = torch.float16 if self.device.type == 'cuda' else torch.float32
|
| 72 |
+
self.sae_dtype = _dtype_from_name(settings.sae_dtype)
|
| 73 |
+
if self.device.type == 'cpu' and self.sae_dtype != torch.float32:
|
| 74 |
+
self.sae_dtype = torch.float32
|
| 75 |
+
self.model = None
|
| 76 |
+
self.tokenizer = None
|
| 77 |
+
self.sae_store: SAEStore | None = None
|
| 78 |
+
self.catalog = FeatureCatalog()
|
| 79 |
+
self.load_error: str | None = None
|
| 80 |
+
|
| 81 |
+
def ensure_ready(self, preload_saes: bool = False) -> None:
|
| 82 |
+
if self.model is not None and self.tokenizer is not None and self.sae_store is not None:
|
| 83 |
+
if preload_saes:
|
| 84 |
+
self.sae_store.preload()
|
| 85 |
+
return
|
| 86 |
+
self.tokenizer = AutoTokenizer.from_pretrained(self.settings.model_id)
|
| 87 |
+
if self.tokenizer.pad_token_id is None:
|
| 88 |
+
self.tokenizer.pad_token = self.tokenizer.eos_token
|
| 89 |
+
self.tokenizer.padding_side = 'left'
|
| 90 |
+
self.model = AutoModelForCausalLM.from_pretrained(
|
| 91 |
+
self.settings.model_id,
|
| 92 |
+
torch_dtype=self.model_dtype,
|
| 93 |
+
low_cpu_mem_usage=True,
|
| 94 |
+
)
|
| 95 |
+
self.model.to(self.device)
|
| 96 |
+
self.model.eval()
|
| 97 |
+
self.sae_store = SAEStore(
|
| 98 |
+
repo_id=self.settings.sae_repo_id,
|
| 99 |
+
layers=self.settings.layers,
|
| 100 |
+
device=self.device,
|
| 101 |
+
dtype=self.sae_dtype,
|
| 102 |
+
top_k=self.settings.sae_top_k,
|
| 103 |
+
)
|
| 104 |
+
if preload_saes:
|
| 105 |
+
self.sae_store.preload()
|
| 106 |
+
|
| 107 |
+
def token_choices(self, text: str) -> list[tuple[str, int]]:
|
| 108 |
+
self.ensure_ready(preload_saes=False)
|
| 109 |
+
assert self.tokenizer is not None
|
| 110 |
+
ids = self.tokenizer(text, add_special_tokens=True)['input_ids']
|
| 111 |
+
tokens = [self.tokenizer.decode([token_id]) for token_id in ids]
|
| 112 |
+
return [(f'{idx}: {token!r}', idx) for idx, token in enumerate(tokens)]
|
| 113 |
+
|
| 114 |
+
def _inputs(self, text: str) -> dict[str, torch.Tensor]:
|
| 115 |
+
assert self.tokenizer is not None
|
| 116 |
+
batch = self.tokenizer(
|
| 117 |
+
text,
|
| 118 |
+
return_tensors='pt',
|
| 119 |
+
truncation=True,
|
| 120 |
+
max_length=self.settings.max_prompt_tokens,
|
| 121 |
+
)
|
| 122 |
+
return {key: value.to(self.device) for key, value in batch.items()}
|
| 123 |
+
|
| 124 |
+
@staticmethod
|
| 125 |
+
def _hidden_from_output(output):
|
| 126 |
+
return output[0] if isinstance(output, tuple) else output
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
def _replace_hidden_in_output(output, hidden: torch.Tensor):
|
| 130 |
+
if isinstance(output, tuple):
|
| 131 |
+
return (hidden, *output[1:])
|
| 132 |
+
return hidden
|
| 133 |
+
|
| 134 |
+
@contextmanager
|
| 135 |
+
def _capture_hook(self, layer: int, bucket: dict) -> Iterator[None]:
|
| 136 |
+
assert self.model is not None
|
| 137 |
+
|
| 138 |
+
def hook(_module, _inputs, output):
|
| 139 |
+
hidden = self._hidden_from_output(output)
|
| 140 |
+
if 'hidden' not in bucket:
|
| 141 |
+
bucket['hidden'] = hidden.detach()
|
| 142 |
+
|
| 143 |
+
handle = self.model.model.layers[int(layer)].register_forward_hook(hook)
|
| 144 |
+
try:
|
| 145 |
+
yield
|
| 146 |
+
finally:
|
| 147 |
+
handle.remove()
|
| 148 |
+
|
| 149 |
+
@contextmanager
|
| 150 |
+
def _delta_hook(self, layer: int, token_index: int, delta: torch.Tensor) -> Iterator[None]:
|
| 151 |
+
assert self.model is not None
|
| 152 |
+
applied = {'done': False}
|
| 153 |
+
|
| 154 |
+
def hook(_module, _inputs, output):
|
| 155 |
+
if applied['done']:
|
| 156 |
+
return output
|
| 157 |
+
hidden = self._hidden_from_output(output)
|
| 158 |
+
if hidden.ndim != 3:
|
| 159 |
+
return output
|
| 160 |
+
seq_len = hidden.shape[1]
|
| 161 |
+
idx = int(token_index)
|
| 162 |
+
if idx < 0:
|
| 163 |
+
idx = seq_len + idx
|
| 164 |
+
if idx < 0 or idx >= seq_len:
|
| 165 |
+
raise IndexError(f'Token index {token_index} outside prompt length {seq_len}.')
|
| 166 |
+
modified = hidden.clone()
|
| 167 |
+
modified[:, idx, :] = modified[:, idx, :] + delta.to(hidden.device, hidden.dtype)
|
| 168 |
+
applied['done'] = True
|
| 169 |
+
return self._replace_hidden_in_output(output, modified)
|
| 170 |
+
|
| 171 |
+
handle = self.model.model.layers[int(layer)].register_forward_hook(hook)
|
| 172 |
+
try:
|
| 173 |
+
yield
|
| 174 |
+
finally:
|
| 175 |
+
handle.remove()
|
| 176 |
+
|
| 177 |
+
@torch.inference_mode()
|
| 178 |
+
def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
|
| 179 |
+
self.ensure_ready(preload_saes=False)
|
| 180 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 181 |
+
if int(layer) not in self.settings.layers:
|
| 182 |
+
raise ValueError(f'Layer must be one of {self.settings.layers}.')
|
| 183 |
+
inputs = self._inputs(text)
|
| 184 |
+
bucket: dict = {}
|
| 185 |
+
with self._capture_hook(int(layer), bucket):
|
| 186 |
+
self.model(**inputs, use_cache=False)
|
| 187 |
+
hidden = bucket['hidden'][0]
|
| 188 |
+
seq_len = hidden.shape[0]
|
| 189 |
+
idx = int(token_index)
|
| 190 |
+
if idx < 0:
|
| 191 |
+
idx = seq_len + idx
|
| 192 |
+
if idx < 0 or idx >= seq_len:
|
| 193 |
+
raise IndexError(f'Token index {token_index} outside prompt length {seq_len}.')
|
| 194 |
+
residual = hidden[idx]
|
| 195 |
+
sae = self.sae_store.get(int(layer))
|
| 196 |
+
encoding = sae.encode(residual)
|
| 197 |
+
reconstruction = sae.decode_sparse(encoding)
|
| 198 |
+
metrics = reconstruction_metrics(residual, reconstruction)
|
| 199 |
+
metrics['active_features'] = float(encoding.active_count)
|
| 200 |
+
ids = inputs['input_ids'][0].tolist()
|
| 201 |
+
tokens = [self.tokenizer.decode([token_id]) for token_id in ids]
|
| 202 |
+
rows: list[list[object]] = []
|
| 203 |
+
rank_count = min(int(top_n), encoding.indices.numel())
|
| 204 |
+
for rank in range(rank_count):
|
| 205 |
+
feature_id = int(encoding.indices[rank].item())
|
| 206 |
+
activation = float(encoding.values[rank].item())
|
| 207 |
+
rows.append(
|
| 208 |
+
[rank + 1, feature_id, activation, self.catalog.hint(int(layer), feature_id)]
|
| 209 |
+
)
|
| 210 |
+
return AnalysisResult(
|
| 211 |
+
tokens=tokens,
|
| 212 |
+
token_index=idx,
|
| 213 |
+
layer=int(layer),
|
| 214 |
+
features=encoding,
|
| 215 |
+
rows=rows,
|
| 216 |
+
metrics=metrics,
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
def token_html(self, tokens: list[str], selected_index: int) -> str:
|
| 220 |
+
chips = []
|
| 221 |
+
for idx, token in enumerate(tokens):
|
| 222 |
+
safe = html.escape(token if token.strip() else repr(token))
|
| 223 |
+
selected = idx == int(selected_index)
|
| 224 |
+
cls = 'token selected' if selected else 'token'
|
| 225 |
+
chips.append(f'<span class="{cls}"><sup>{idx}</sup>{safe}</span>')
|
| 226 |
+
return '<div class="token-wrap">' + ''.join(chips) + '</div>'
|
| 227 |
+
|
| 228 |
+
@staticmethod
|
| 229 |
+
def _top_token_rows(tokenizer, baseline_logits: torch.Tensor, modified_logits: torch.Tensor, k: int = 8):
|
| 230 |
+
p = torch.softmax(baseline_logits.float(), dim=-1)
|
| 231 |
+
q = torch.softmax(modified_logits.float(), dim=-1)
|
| 232 |
+
union_ids = torch.unique(torch.cat([torch.topk(p, k).indices, torch.topk(q, k).indices]))
|
| 233 |
+
rows = []
|
| 234 |
+
for token_id in union_ids.tolist():
|
| 235 |
+
token = tokenizer.decode([int(token_id)])
|
| 236 |
+
bp = float(p[token_id].item())
|
| 237 |
+
mp = float(q[token_id].item())
|
| 238 |
+
rows.append([repr(token), bp, mp, mp - bp])
|
| 239 |
+
rows.sort(key=lambda row: max(row[1], row[2]), reverse=True)
|
| 240 |
+
return rows[: min(len(rows), 12)]
|
| 241 |
+
|
| 242 |
+
@torch.inference_mode()
|
| 243 |
+
def intervene(
|
| 244 |
+
self,
|
| 245 |
+
text: str,
|
| 246 |
+
layer: int,
|
| 247 |
+
token_index: int,
|
| 248 |
+
feature_id: int,
|
| 249 |
+
mode: str,
|
| 250 |
+
coefficient: float,
|
| 251 |
+
target_text: str = '',
|
| 252 |
+
max_new_tokens: int = 24,
|
| 253 |
+
) -> InterventionResult:
|
| 254 |
+
self.ensure_ready(preload_saes=False)
|
| 255 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 256 |
+
inputs = self._inputs(text)
|
| 257 |
+
prompt_len = int(inputs['input_ids'].shape[1])
|
| 258 |
+
idx = int(token_index)
|
| 259 |
+
if idx < 0:
|
| 260 |
+
idx = prompt_len + idx
|
| 261 |
+
if idx < 0 or idx >= prompt_len:
|
| 262 |
+
raise IndexError(f'Token index {token_index} outside prompt length {prompt_len}.')
|
| 263 |
+
|
| 264 |
+
sae = self.sae_store.get(int(layer))
|
| 265 |
+
capture: dict = {}
|
| 266 |
+
generation_kwargs = {
|
| 267 |
+
'max_new_tokens': min(int(max_new_tokens), self.settings.max_new_tokens),
|
| 268 |
+
'do_sample': False,
|
| 269 |
+
'return_dict_in_generate': True,
|
| 270 |
+
'output_scores': True,
|
| 271 |
+
'pad_token_id': self.tokenizer.eos_token_id,
|
| 272 |
+
}
|
| 273 |
+
with self._capture_hook(int(layer), capture):
|
| 274 |
+
baseline = self.model.generate(**inputs, **generation_kwargs)
|
| 275 |
+
residual = capture['hidden'][0, idx]
|
| 276 |
+
encoding = sae.encode(residual)
|
| 277 |
+
original_activation = encoding.activation_for(int(feature_id))
|
| 278 |
+
spec = InterventionSpec(mode=mode, coefficient=float(coefficient))
|
| 279 |
+
delta = residual_delta(sae.decoder_direction(int(feature_id)), original_activation, spec)
|
| 280 |
+
|
| 281 |
+
with self._delta_hook(int(layer), idx, delta):
|
| 282 |
+
modified = self.model.generate(**inputs, **generation_kwargs)
|
| 283 |
+
|
| 284 |
+
baseline_ids = baseline.sequences[0, prompt_len:]
|
| 285 |
+
modified_ids = modified.sequences[0, prompt_len:]
|
| 286 |
+
baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
|
| 287 |
+
modified_text = self.tokenizer.decode(modified_ids, skip_special_tokens=True)
|
| 288 |
+
|
| 289 |
+
if not baseline.scores or not modified.scores:
|
| 290 |
+
raise RuntimeError('Generation returned no score tensors.')
|
| 291 |
+
baseline_logits = baseline.scores[0][0]
|
| 292 |
+
modified_logits = modified.scores[0][0]
|
| 293 |
+
js = js_divergence_from_logits(baseline_logits, modified_logits)
|
| 294 |
+
|
| 295 |
+
target_token = ''
|
| 296 |
+
target_count = 0
|
| 297 |
+
bp = mp = log_delta = None
|
| 298 |
+
if target_text.strip():
|
| 299 |
+
target_ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
|
| 300 |
+
target_count = len(target_ids)
|
| 301 |
+
if target_ids:
|
| 302 |
+
target_id = int(target_ids[0])
|
| 303 |
+
target_token = self.tokenizer.decode([target_id])
|
| 304 |
+
p = torch.softmax(baseline_logits.float(), dim=-1)
|
| 305 |
+
q = torch.softmax(modified_logits.float(), dim=-1)
|
| 306 |
+
bp = float(p[target_id].item())
|
| 307 |
+
mp = float(q[target_id].item())
|
| 308 |
+
log_delta = safe_log_probability(mp) - safe_log_probability(bp)
|
| 309 |
+
|
| 310 |
+
return InterventionResult(
|
| 311 |
+
baseline_text=baseline_text,
|
| 312 |
+
modified_text=modified_text,
|
| 313 |
+
feature_activation=float(original_activation),
|
| 314 |
+
delta_activation=float(spec.delta_activation(original_activation)),
|
| 315 |
+
perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
|
| 316 |
+
js_divergence=float(js),
|
| 317 |
+
target_text=target_text,
|
| 318 |
+
target_token=target_token,
|
| 319 |
+
target_token_count=target_count,
|
| 320 |
+
baseline_target_prob=bp,
|
| 321 |
+
modified_target_prob=mp,
|
| 322 |
+
target_logprob_delta=log_delta,
|
| 323 |
+
top_token_rows=self._top_token_rows(
|
| 324 |
+
self.tokenizer, baseline_logits, modified_logits, k=8
|
| 325 |
+
),
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
RUNTIME = FeatureLensRuntime()
|
| 330 |
+
|
| 331 |
+
if SETTINGS.eager_load:
|
| 332 |
+
try:
|
| 333 |
+
RUNTIME.ensure_ready(preload_saes=True)
|
| 334 |
+
except Exception as exc: # Keep the UI alive and surface a useful error on first call.
|
| 335 |
+
RUNTIME.load_error = f'{type(exc).__name__}: {exc}'
|
| 336 |
+
print(f'FeatureLens eager load failed: {RUNTIME.load_error}')
|
featurelens/sae.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Iterable
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from huggingface_hub import hf_hub_download
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class SparseEncoding:
|
| 13 |
+
indices: torch.Tensor
|
| 14 |
+
values: torch.Tensor
|
| 15 |
+
pre_activations: torch.Tensor | None = None
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def active_count(self) -> int:
|
| 19 |
+
return int((self.values > 0).sum().item())
|
| 20 |
+
|
| 21 |
+
def activation_for(self, feature_id: int) -> float:
|
| 22 |
+
mask = self.indices == int(feature_id)
|
| 23 |
+
if not bool(mask.any()):
|
| 24 |
+
return 0.0
|
| 25 |
+
return float(self.values[mask][0].item())
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class SAEWeights:
|
| 30 |
+
layer: int
|
| 31 |
+
w_enc_t: torch.Tensor # [d_model, d_sae]
|
| 32 |
+
w_dec: torch.Tensor # [d_model, d_sae]
|
| 33 |
+
b_enc: torch.Tensor # [d_sae]
|
| 34 |
+
b_dec: torch.Tensor # [d_model]
|
| 35 |
+
top_k: int = 50
|
| 36 |
+
|
| 37 |
+
@torch.inference_mode()
|
| 38 |
+
def encode(self, hidden: torch.Tensor, return_pre: bool = False) -> SparseEncoding:
|
| 39 |
+
"""Encode one or more residual vectors without materializing dense sparse acts."""
|
| 40 |
+
if hidden.shape[-1] != self.w_enc_t.shape[0]:
|
| 41 |
+
raise ValueError(
|
| 42 |
+
f'Expected hidden dim {self.w_enc_t.shape[0]}, got {hidden.shape[-1]}.'
|
| 43 |
+
)
|
| 44 |
+
compute_hidden = hidden.to(device=self.w_enc_t.device, dtype=self.w_enc_t.dtype)
|
| 45 |
+
pre = compute_hidden @ self.w_enc_t + self.b_enc
|
| 46 |
+
relu = torch.relu(pre)
|
| 47 |
+
values, indices = torch.topk(relu, k=self.top_k, dim=-1)
|
| 48 |
+
return SparseEncoding(indices=indices, values=values, pre_activations=pre if return_pre else None)
|
| 49 |
+
|
| 50 |
+
@torch.inference_mode()
|
| 51 |
+
def decode_sparse(self, encoding: SparseEncoding) -> torch.Tensor:
|
| 52 |
+
"""Decode TopK features efficiently using only selected decoder columns."""
|
| 53 |
+
indices = encoding.indices
|
| 54 |
+
values = encoding.values.to(device=self.w_dec.device, dtype=self.w_dec.dtype)
|
| 55 |
+
if indices.ndim == 1:
|
| 56 |
+
cols = self.w_dec[:, indices] # [d_model, k]
|
| 57 |
+
return self.b_dec + cols @ values
|
| 58 |
+
flat_idx = indices.reshape(-1, indices.shape[-1])
|
| 59 |
+
flat_vals = values.reshape(-1, values.shape[-1])
|
| 60 |
+
outputs = []
|
| 61 |
+
for row_idx, row_vals in zip(flat_idx, flat_vals, strict=True):
|
| 62 |
+
cols = self.w_dec[:, row_idx]
|
| 63 |
+
outputs.append(self.b_dec + cols @ row_vals)
|
| 64 |
+
return torch.stack(outputs).reshape(*indices.shape[:-1], self.w_dec.shape[0])
|
| 65 |
+
|
| 66 |
+
def decoder_direction(self, feature_id: int) -> torch.Tensor:
|
| 67 |
+
if feature_id < 0 or feature_id >= self.w_dec.shape[1]:
|
| 68 |
+
raise ValueError(f'Feature id must be in [0, {self.w_dec.shape[1] - 1}].')
|
| 69 |
+
return self.w_dec[:, int(feature_id)]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class SAEStore:
|
| 73 |
+
def __init__(
|
| 74 |
+
self,
|
| 75 |
+
repo_id: str,
|
| 76 |
+
layers: Iterable[int],
|
| 77 |
+
device: torch.device,
|
| 78 |
+
dtype: torch.dtype,
|
| 79 |
+
top_k: int = 50,
|
| 80 |
+
cache_dir: str | Path | None = None,
|
| 81 |
+
) -> None:
|
| 82 |
+
self.repo_id = repo_id
|
| 83 |
+
self.layers = tuple(int(x) for x in layers)
|
| 84 |
+
self.device = device
|
| 85 |
+
self.dtype = dtype
|
| 86 |
+
self.top_k = int(top_k)
|
| 87 |
+
self.cache_dir = str(cache_dir) if cache_dir else None
|
| 88 |
+
self._cache: dict[int, SAEWeights] = {}
|
| 89 |
+
|
| 90 |
+
def get(self, layer: int) -> SAEWeights:
|
| 91 |
+
layer = int(layer)
|
| 92 |
+
if layer not in self.layers:
|
| 93 |
+
raise ValueError(f'Layer {layer} is not configured. Available: {self.layers}.')
|
| 94 |
+
if layer in self._cache:
|
| 95 |
+
return self._cache[layer]
|
| 96 |
+
|
| 97 |
+
path = hf_hub_download(
|
| 98 |
+
repo_id=self.repo_id,
|
| 99 |
+
filename=f'layer{layer}.sae.pt',
|
| 100 |
+
cache_dir=self.cache_dir,
|
| 101 |
+
)
|
| 102 |
+
try:
|
| 103 |
+
raw = torch.load(path, map_location='cpu', weights_only=True)
|
| 104 |
+
except TypeError: # pragma: no cover - old torch fallback
|
| 105 |
+
raw = torch.load(path, map_location='cpu')
|
| 106 |
+
|
| 107 |
+
required = {'W_enc', 'W_dec', 'b_enc', 'b_dec'}
|
| 108 |
+
missing = required.difference(raw)
|
| 109 |
+
if missing:
|
| 110 |
+
raise KeyError(f'SAE checkpoint layer {layer} missing keys: {sorted(missing)}')
|
| 111 |
+
|
| 112 |
+
sae = SAEWeights(
|
| 113 |
+
layer=layer,
|
| 114 |
+
w_enc_t=raw['W_enc'].T.contiguous().to(self.device, dtype=self.dtype),
|
| 115 |
+
w_dec=raw['W_dec'].contiguous().to(self.device, dtype=self.dtype),
|
| 116 |
+
b_enc=raw['b_enc'].contiguous().to(self.device, dtype=self.dtype),
|
| 117 |
+
b_dec=raw['b_dec'].contiguous().to(self.device, dtype=self.dtype),
|
| 118 |
+
top_k=self.top_k,
|
| 119 |
+
)
|
| 120 |
+
self._cache[layer] = sae
|
| 121 |
+
return sae
|
| 122 |
+
|
| 123 |
+
def preload(self) -> None:
|
| 124 |
+
for layer in self.layers:
|
| 125 |
+
self.get(layer)
|
pyproject.toml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "featurelens"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
|
| 5 |
+
requires-python = ">=3.10"
|
| 6 |
+
|
| 7 |
+
[tool.pytest.ini_options]
|
| 8 |
+
testpaths = ["tests"]
|
| 9 |
+
addopts = "-q"
|
| 10 |
+
|
| 11 |
+
[tool.ruff]
|
| 12 |
+
line-length = 100
|
| 13 |
+
target-version = "py310"
|
| 14 |
+
exclude = ["artifacts"]
|
| 15 |
+
|
| 16 |
+
[tool.ruff.lint]
|
| 17 |
+
select = ["E", "F", "I", "B", "UP"]
|
| 18 |
+
ignore = ["E501"]
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest>=8.3,<10
|
| 3 |
+
ruff>=0.9,<1
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.8.0,<2.12.0
|
| 2 |
+
transformers>=4.51.0,<6.0.0
|
| 3 |
+
huggingface_hub>=0.34.0,<2.0.0
|
| 4 |
+
gradio>=6.0.0,<7.0.0
|
| 5 |
+
numpy>=2.0.0,<3.0.0
|
| 6 |
+
scipy>=1.14.0,<2.0.0
|
| 7 |
+
scikit-learn>=1.6.0,<2.0.0
|
| 8 |
+
pandas>=2.2.0,<3.0.0
|
| 9 |
+
matplotlib>=3.9.0,<4.0.0
|
research_config.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"research_question": "Do sparse features that predict a concept also causally influence model behaviour?",
|
| 3 |
+
"model_id": "Qwen/Qwen3-1.7B-Base",
|
| 4 |
+
"sae_repo_id": "Qwen/SAE-Res-Qwen3-1.7B-Base-W32K-L0_50",
|
| 5 |
+
"layers": [4, 14, 26],
|
| 6 |
+
"sae_top_k": 50,
|
| 7 |
+
"sae_width": 32768,
|
| 8 |
+
"concepts": [
|
| 9 |
+
"code",
|
| 10 |
+
"mathematics",
|
| 11 |
+
"positive_sentiment",
|
| 12 |
+
"negative_sentiment",
|
| 13 |
+
"french_language",
|
| 14 |
+
"factual_entities",
|
| 15 |
+
"uncertainty"
|
| 16 |
+
],
|
| 17 |
+
"discovery_prompts": 224,
|
| 18 |
+
"paraphrase_pairs_per_concept": 16,
|
| 19 |
+
"causal_tasks": 28,
|
| 20 |
+
"split_seed": 42,
|
| 21 |
+
"feature_selection": "training-split AUROC with activation-rate contrast tie-break",
|
| 22 |
+
"held_out_metrics": ["AUROC", "F1"],
|
| 23 |
+
"causal_interventions": ["ablate", "scale_2x"],
|
| 24 |
+
"negative_control": "norm-matched random residual direction",
|
| 25 |
+
"primary_causal_metric": "target first-token log-probability delta"
|
| 26 |
+
}
|
scripts/release_check.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from collections import Counter
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
+
|
| 9 |
+
REQUIRED = [
|
| 10 |
+
'README.md',
|
| 11 |
+
'app.py',
|
| 12 |
+
'requirements.txt',
|
| 13 |
+
'research_config.json',
|
| 14 |
+
'featurelens/runtime.py',
|
| 15 |
+
'featurelens/sae.py',
|
| 16 |
+
'featurelens/interventions.py',
|
| 17 |
+
'experiments/run_all.py',
|
| 18 |
+
'data/prompts.jsonl',
|
| 19 |
+
'data/causal_tasks.jsonl',
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def load_jsonl(path: Path) -> list[dict]:
|
| 24 |
+
return [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def main() -> None:
|
| 28 |
+
missing = [path for path in REQUIRED if not (ROOT / path).exists()]
|
| 29 |
+
if missing:
|
| 30 |
+
raise SystemExit(f'Missing required files: {missing}')
|
| 31 |
+
|
| 32 |
+
config = json.loads((ROOT / 'research_config.json').read_text(encoding='utf-8'))
|
| 33 |
+
assert config['layers'] == [4, 14, 26]
|
| 34 |
+
assert config['model_id'] == 'Qwen/Qwen3-1.7B-Base'
|
| 35 |
+
assert config['sae_width'] == 32768
|
| 36 |
+
|
| 37 |
+
prompts = load_jsonl(ROOT / 'data' / 'prompts.jsonl')
|
| 38 |
+
causal = load_jsonl(ROOT / 'data' / 'causal_tasks.jsonl')
|
| 39 |
+
assert len(prompts) == config['discovery_prompts']
|
| 40 |
+
assert len(causal) == config['causal_tasks']
|
| 41 |
+
concept_counts = Counter(row['concept'] for row in prompts)
|
| 42 |
+
assert len(concept_counts) == len(config['concepts'])
|
| 43 |
+
assert len(set(concept_counts.values())) == 1
|
| 44 |
+
|
| 45 |
+
oversized = []
|
| 46 |
+
for path in ROOT.rglob('*'):
|
| 47 |
+
if path.is_file() and '.git' not in path.parts and path.stat().st_size > 5_000_000:
|
| 48 |
+
oversized.append(str(path.relative_to(ROOT)))
|
| 49 |
+
if oversized:
|
| 50 |
+
raise SystemExit(f'Repository contains unexpectedly large tracked candidates: {oversized}')
|
| 51 |
+
|
| 52 |
+
readme = (ROOT / 'README.md').read_text(encoding='utf-8')
|
| 53 |
+
assert 'sdk: gradio' in readme
|
| 54 |
+
assert 'Qwen/Qwen3-1.7B-Base' in readme
|
| 55 |
+
assert 'norm-matched random' in readme.lower()
|
| 56 |
+
|
| 57 |
+
print('FeatureLens release check: PASS')
|
| 58 |
+
print(f' discovery prompts: {len(prompts)}')
|
| 59 |
+
print(f' causal tasks: {len(causal)}')
|
| 60 |
+
print(f' layers: {config["layers"]}')
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
if __name__ == '__main__':
|
| 64 |
+
main()
|
tests/test_catalog.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from featurelens.catalog import FeatureCatalog
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_catalog_returns_best_hint(tmp_path: Path) -> None:
|
| 9 |
+
(tmp_path / 'feature_catalog.csv').write_text(
|
| 10 |
+
'layer,concept,feature_id,auroc\n14,math,123,0.80\n14,code,123,0.65\n',
|
| 11 |
+
encoding='utf-8',
|
| 12 |
+
)
|
| 13 |
+
catalog = FeatureCatalog(tmp_path)
|
| 14 |
+
assert catalog.hint(14, 123) == 'math (AUROC 0.80)'
|
| 15 |
+
assert catalog.hint(4, 123) == 'unlabeled'
|
tests/test_data.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from collections import Counter
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def read_jsonl(path: Path):
|
| 11 |
+
return [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_discovery_dataset_is_balanced_and_paired() -> None:
|
| 15 |
+
rows = read_jsonl(ROOT / 'data' / 'prompts.jsonl')
|
| 16 |
+
assert len(rows) == 224
|
| 17 |
+
concept_counts = Counter(row['concept'] for row in rows)
|
| 18 |
+
assert set(concept_counts.values()) == {32}
|
| 19 |
+
pair_counts = Counter(row['pair_id'] for row in rows)
|
| 20 |
+
assert set(pair_counts.values()) == {2}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_causal_dataset_covers_every_discovery_concept() -> None:
|
| 24 |
+
discovery = read_jsonl(ROOT / 'data' / 'prompts.jsonl')
|
| 25 |
+
causal = read_jsonl(ROOT / 'data' / 'causal_tasks.jsonl')
|
| 26 |
+
assert len(causal) == 28
|
| 27 |
+
assert {row['concept'] for row in causal} == {row['concept'] for row in discovery}
|
tests/test_interventions.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
|
| 5 |
+
from featurelens.interventions import InterventionSpec, normalized_random_control, residual_delta
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_ablation_delta() -> None:
|
| 9 |
+
spec = InterventionSpec('ablate', 0.0)
|
| 10 |
+
assert spec.delta_activation(3.5) == -3.5
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_scale_delta() -> None:
|
| 14 |
+
spec = InterventionSpec('scale', 2.0)
|
| 15 |
+
assert spec.delta_activation(3.5) == 3.5
|
| 16 |
+
assert InterventionSpec('scale', 0.0).delta_activation(3.5) == -3.5
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_injection_delta() -> None:
|
| 20 |
+
spec = InterventionSpec('inject', -4.0)
|
| 21 |
+
assert spec.delta_activation(123.0) == -4.0
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_residual_delta_is_decoder_direction_times_coefficient_delta() -> None:
|
| 25 |
+
direction = torch.tensor([1.0, 2.0, -1.0])
|
| 26 |
+
delta = residual_delta(direction, 3.0, InterventionSpec('ablate', 0.0))
|
| 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)
|
| 33 |
+
b = normalized_random_control(delta, seed=7)
|
| 34 |
+
assert torch.allclose(a, b)
|
| 35 |
+
assert torch.allclose(torch.linalg.vector_norm(a), torch.linalg.vector_norm(delta), atol=1e-5)
|
tests/test_metrics.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
|
| 5 |
+
from featurelens.metrics import js_divergence_from_logits, reconstruction_metrics, safe_log_probability
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_reconstruction_metrics_perfect_match() -> None:
|
| 9 |
+
x = torch.tensor([1.0, 2.0, 3.0])
|
| 10 |
+
metrics = reconstruction_metrics(x, x.clone())
|
| 11 |
+
assert metrics['mse'] == 0.0
|
| 12 |
+
assert metrics['nmse'] == 0.0
|
| 13 |
+
assert abs(metrics['cosine'] - 1.0) < 1e-6
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_js_divergence_zero_for_identical_logits() -> None:
|
| 17 |
+
logits = torch.tensor([1.0, 2.0, -1.0])
|
| 18 |
+
assert abs(js_divergence_from_logits(logits, logits)) < 1e-8
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_safe_log_probability_is_finite_at_zero() -> None:
|
| 22 |
+
assert safe_log_probability(0.0) < 0.0
|
tests/test_sae.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
|
| 5 |
+
from featurelens.sae import SAEWeights
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def make_sae() -> SAEWeights:
|
| 9 |
+
w_enc_t = torch.tensor(
|
| 10 |
+
[
|
| 11 |
+
[1.0, 0.0, -1.0, 0.5, 0.0],
|
| 12 |
+
[0.0, 1.0, 0.0, 0.5, -1.0],
|
| 13 |
+
[0.0, 0.0, 1.0, 0.0, 1.0],
|
| 14 |
+
]
|
| 15 |
+
)
|
| 16 |
+
w_dec = torch.tensor(
|
| 17 |
+
[
|
| 18 |
+
[1.0, 0.0, 0.0, 0.5, 0.0],
|
| 19 |
+
[0.0, 1.0, 0.0, 0.5, 0.0],
|
| 20 |
+
[0.0, 0.0, 1.0, 0.0, 1.0],
|
| 21 |
+
]
|
| 22 |
+
)
|
| 23 |
+
return SAEWeights(
|
| 24 |
+
layer=0,
|
| 25 |
+
w_enc_t=w_enc_t,
|
| 26 |
+
w_dec=w_dec,
|
| 27 |
+
b_enc=torch.zeros(5),
|
| 28 |
+
b_dec=torch.zeros(3),
|
| 29 |
+
top_k=2,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_encode_keeps_topk_relu_features() -> None:
|
| 34 |
+
sae = make_sae()
|
| 35 |
+
encoding = sae.encode(torch.tensor([2.0, 1.0, -1.0]))
|
| 36 |
+
assert encoding.indices.shape == (2,)
|
| 37 |
+
assert set(encoding.indices.tolist()) == {0, 3}
|
| 38 |
+
assert encoding.active_count == 2
|
| 39 |
+
assert encoding.activation_for(0) == 2.0
|
| 40 |
+
assert encoding.activation_for(4) == 0.0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_decode_sparse_uses_selected_decoder_columns() -> None:
|
| 44 |
+
sae = make_sae()
|
| 45 |
+
encoding = sae.encode(torch.tensor([2.0, 1.0, -1.0]))
|
| 46 |
+
reconstructed = sae.decode_sparse(encoding)
|
| 47 |
+
assert reconstructed.shape == (3,)
|
| 48 |
+
assert torch.isfinite(reconstructed).all()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_batched_encode_and_decode() -> None:
|
| 52 |
+
sae = make_sae()
|
| 53 |
+
hidden = torch.tensor([[2.0, 1.0, -1.0], [0.0, 2.0, 2.0]])
|
| 54 |
+
encoding = sae.encode(hidden)
|
| 55 |
+
decoded = sae.decode_sparse(encoding)
|
| 56 |
+
assert encoding.indices.shape == (2, 2)
|
| 57 |
+
assert decoded.shape == hidden.shape
|
tests/test_split.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from experiments.split import grouped_concept_split
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_grouped_split_keeps_paraphrase_pairs_together() -> None:
|
| 7 |
+
rows = []
|
| 8 |
+
for concept in ('a', 'b'):
|
| 9 |
+
for pair in range(4):
|
| 10 |
+
for variant in range(2):
|
| 11 |
+
rows.append(
|
| 12 |
+
{
|
| 13 |
+
'concept': concept,
|
| 14 |
+
'pair_id': f'{concept}-{pair}',
|
| 15 |
+
'variant': variant,
|
| 16 |
+
}
|
| 17 |
+
)
|
| 18 |
+
train, test = grouped_concept_split(rows, test_fraction=0.25, seed=42)
|
| 19 |
+
train_pairs = {rows[idx]['pair_id'] for idx in train}
|
| 20 |
+
test_pairs = {rows[idx]['pair_id'] for idx in test}
|
| 21 |
+
assert train_pairs.isdisjoint(test_pairs)
|
| 22 |
+
assert {rows[idx]['concept'] for idx in train} == {'a', 'b'}
|
| 23 |
+
assert {rows[idx]['concept'] for idx in test} == {'a', 'b'}
|