Spaces:
Running on Zero
Running on Zero
Commit ·
4a79e5b
1
Parent(s): 2082e2f
Release FeatureLens v0.5.0
Browse files- .github/workflows/ci.yml +5 -1
- CHANGELOG.md +26 -0
- README.md +41 -13
- app.py +458 -123
- docs/HF_DEPLOY.md +26 -9
- docs/METHODOLOGY.md +60 -8
- docs/VALIDATION.md +171 -518
- featurelens/metrics.py +34 -0
- featurelens/runtime.py +325 -16
- pyproject.toml +1 -1
- research_config.json +16 -4
- scripts/release_check.py +26 -4
- scripts/ui_smoke.py +60 -0
- tests/test_live_runtime_helpers.py +216 -0
- tests/test_metrics.py +37 -0
- tests/test_ui_helpers.py +44 -0
.github/workflows/ci.yml
CHANGED
|
@@ -15,12 +15,16 @@ jobs:
|
|
| 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"
|
|
|
|
|
|
|
| 19 |
- name: Ruff
|
| 20 |
run: python -m ruff check app.py featurelens experiments tests scripts
|
| 21 |
- name: Unit tests
|
| 22 |
run: python -m pytest -q
|
| 23 |
- name: Compile
|
| 24 |
run: python -m compileall -q app.py featurelens experiments scripts
|
|
|
|
|
|
|
| 25 |
- name: Release check
|
| 26 |
run: python scripts/release_check.py
|
|
|
|
| 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" \
|
| 19 |
+
"pandas>=2.2,<3" "gradio==6.24.0" "transformers>=4.51,<6" \
|
| 20 |
+
"pytest>=8.3,<10" "ruff>=0.9,<1"
|
| 21 |
- name: Ruff
|
| 22 |
run: python -m ruff check app.py featurelens experiments tests scripts
|
| 23 |
- name: Unit tests
|
| 24 |
run: python -m pytest -q
|
| 25 |
- name: Compile
|
| 26 |
run: python -m compileall -q app.py featurelens experiments scripts
|
| 27 |
+
- name: UI launch smoke
|
| 28 |
+
run: python scripts/ui_smoke.py
|
| 29 |
- name: Release check
|
| 30 |
run: python scripts/release_check.py
|
CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
| 1 |
# Changelog
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## v0.4.0
|
| 4 |
|
| 5 |
### Causal correctness
|
|
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
+
## v0.5.0
|
| 4 |
+
|
| 5 |
+
### Causal specificity
|
| 6 |
+
- Added a **contrastive continuation preference test** that scores two exact continuations under the same single-feature intervention and 8-direction norm-matched control ensemble.
|
| 7 |
+
- Reports baseline/edited sequence log-odds A−B, causal log-odds shift, token-normalized preference shift, random-control magnitude statistics, and an exploratory empirical tail probability.
|
| 8 |
+
- Keeps this distinct from absolute target probability so broad distributional disruption is not mistaken for selective behavioral control.
|
| 9 |
+
|
| 10 |
+
### Feature evidence and geometry
|
| 11 |
+
- Added a **feature-token activation trace** over every token in the current Workbench prompt.
|
| 12 |
+
- Fixed the controlled concept scan to use **prompt-wide max activation over non-padding tokens** instead of only the final token.
|
| 13 |
+
- All-zero concept batches now report `inactive in every sampled prompt` and do not invent a leading concept.
|
| 14 |
+
- Added **feature-set decoder geometry** for 2–8 selected features: pairwise decoder cosine, mean/max absolute cosine, activation-weighted joint-ablation norm, independent-direction reference norm, and alignment/cancellation ratio.
|
| 15 |
+
|
| 16 |
+
### Interface
|
| 17 |
+
- Widened and explicitly centered the application canvas (up to 1600 px) and enabled `fill_width=True` to use desktop space more effectively.
|
| 18 |
+
- Normalized serif typography, labels, controls, table font sizes, and action-button styling.
|
| 19 |
+
- Added bounded Dataframe heights to reduce excessive dynamic page growth.
|
| 20 |
+
- Added copy-button visual acknowledgement (`✓ Copied with headers`).
|
| 21 |
+
- Added a browser-side resize/mutation observer to request layout reflow when dynamic output height changes inside an embedded Space.
|
| 22 |
+
- Retained the safe Gradio theme configuration without string font tuples; serif typography is applied in CSS.
|
| 23 |
+
|
| 24 |
+
### Validation
|
| 25 |
+
- Expanded automated coverage from 29 to **38 tests**, including contrastive log-odds, decoder geometry, copy/export helpers, and toy-runtime end-to-end checks.
|
| 26 |
+
- Added `scripts/ui_smoke.py` so the actual Gradio `launch()` path is part of the release procedure instead of only constructing the component tree.
|
| 27 |
+
- Reworked v0.5 acceptance tests around the new concept-scan semantics, feature-token trace, contrastive preference, geometry, copy feedback, and embedded-page reflow.
|
| 28 |
+
|
| 29 |
## v0.4.0
|
| 30 |
|
| 31 |
### Causal correctness
|
README.md
CHANGED
|
@@ -13,7 +13,7 @@ license: mit
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
-
> **v0.
|
| 17 |
|
| 18 |
**Research question:**
|
| 19 |
|
|
@@ -34,14 +34,16 @@ FeatureLens is independent of thesis code and thesis datasets.
|
|
| 34 |
7. **Feature-set causality** — do small sparse subspaces matter more than one feature?
|
| 35 |
8. **Non-additivity** — does a joint edit differ from the sum of individual effects?
|
| 36 |
9. **Specificity** — are targeted effects larger than norm-matched random residual perturbations?
|
|
|
|
|
|
|
| 37 |
|
| 38 |
A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
|
| 39 |
|
| 40 |
-
##
|
| 41 |
|
| 42 |
v0.3 batched multiple residual edits for efficiency, but compared them with a **separately executed baseline**. On real Qwen3 inference this produced a small numerical discrepancy in the nominal `1×` no-edit dose-response row.
|
| 43 |
|
| 44 |
-
|
| 45 |
|
| 46 |
For the scale dose-response, the `1×` row itself is the no-edit reference:
|
| 47 |
|
|
@@ -58,7 +60,7 @@ The UI also reports single-forward → batched-null execution drift as a diagnos
|
|
| 58 |
|
| 59 |
## Random-control ensemble
|
| 60 |
|
| 61 |
-
A single random residual direction can be unusually weak or unusually disruptive by chance.
|
| 62 |
|
| 63 |
For a targeted residual delta `Δh`, each control satisfies:
|
| 64 |
|
|
@@ -123,7 +125,10 @@ The difference is reported as an **interaction excess**. A non-zero excess means
|
|
| 123 |
|
| 124 |
### Feature evidence
|
| 125 |
|
| 126 |
-
The **
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
- code;
|
| 129 |
- mathematics;
|
|
@@ -133,13 +138,27 @@ The **Controlled concept contrast** takes a selected feature and evaluates it on
|
|
| 133 |
- factual entities;
|
| 134 |
- uncertainty.
|
| 135 |
|
| 136 |
-
One wording per paraphrase pair is used to reduce near-duplicate inflation. The live scan reports mean
|
| 137 |
|
| 138 |
This is an exploratory live feature-evidence tool, **not an automatic semantic label**. The held-out offline AUROC/F1 benchmark remains the stronger claim.
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
### Paraphrase robustness
|
| 141 |
|
| 142 |
-
|
| 143 |
|
| 144 |
1. **Selected-token robustness** — TopK Jaccard and sparse cosine at the two manually chosen token positions.
|
| 145 |
2. **Prompt-wide robustness** — for each SAE feature, take its maximum activation across all prompt tokens, then compare the two sparse prompt profiles.
|
|
@@ -150,11 +169,17 @@ The prompt-wide view reduces sensitivity to accidentally comparing semantically
|
|
| 150 |
|
| 151 |
The trajectory view compares SAE reconstruction and sparsity/concentration diagnostics at the same prompt token across layers 4, 14 and 26. It does **not** equate feature IDs across layer-specific SAE dictionaries.
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
## Copy tables with headers
|
| 154 |
|
| 155 |
Every result table that is intended for analysis has an explicit **Copy table with headers** button. The app serializes the displayed data as TSV before copying, so column names are preserved.
|
| 156 |
|
| 157 |
-
The native Dataframe fullscreen control is retained, while the custom copy action avoids relying on browser text selection.
|
| 158 |
|
| 159 |
## Reconstruction-preserving edits
|
| 160 |
|
|
@@ -266,7 +291,7 @@ FEATURELENS_CONTRAST_PROMPTS_PER_CONCEPT=4
|
|
| 266 |
|
| 267 |
## UI design
|
| 268 |
|
| 269 |
-
v0.
|
| 270 |
|
| 271 |
- Times / Liberation Serif-style formal typography;
|
| 272 |
- restrained teal, umber, stone, plum, and muted red chart accents;
|
|
@@ -275,7 +300,7 @@ v0.4 intentionally moves away from a rounded, saturated dashboard aesthetic:
|
|
| 275 |
- explicit section rules instead of pill badges;
|
| 276 |
- extra bottom padding and a visible end-of-workbench footer to avoid an abrupt embedded-page cutoff.
|
| 277 |
|
| 278 |
-
The
|
| 279 |
|
| 280 |
## Repository layout
|
| 281 |
|
|
@@ -301,7 +326,9 @@ FeatureLens/
|
|
| 301 |
│ └── run_all.py
|
| 302 |
├── data/
|
| 303 |
├── tests/
|
| 304 |
-
├── scripts/
|
|
|
|
|
|
|
| 305 |
├── docs/
|
| 306 |
└── research_config.json
|
| 307 |
```
|
|
@@ -312,10 +339,11 @@ FeatureLens/
|
|
| 312 |
python -m ruff check app.py featurelens experiments tests scripts
|
| 313 |
python -m pytest -q
|
| 314 |
python -m compileall -q app.py featurelens experiments scripts
|
|
|
|
| 315 |
python scripts/release_check.py
|
| 316 |
```
|
| 317 |
|
| 318 |
-
See [`docs/VALIDATION.md`](docs/VALIDATION.md). The validation guide uses the **exact labels shown in the
|
| 319 |
|
| 320 |
## Limitations
|
| 321 |
|
|
@@ -330,7 +358,7 @@ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The validation guide uses the **
|
|
| 330 |
## Resume-ready description
|
| 331 |
|
| 332 |
> **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
|
| 333 |
-
> Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery,
|
| 334 |
|
| 335 |
## Acknowledgements
|
| 336 |
|
|
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
+
> **v0.5:** contrastive continuation preference tests, feature-token activation traces, SAE decoder-geometry diagnostics, prompt-wide concept contrast, a wider centered research UI, copy confirmation, and dynamic-height reflow safeguards—while retaining v0.4's batched null references and 8-direction random ensembles.
|
| 17 |
|
| 18 |
**Research question:**
|
| 19 |
|
|
|
|
| 34 |
7. **Feature-set causality** — do small sparse subspaces matter more than one feature?
|
| 35 |
8. **Non-additivity** — does a joint edit differ from the sum of individual effects?
|
| 36 |
9. **Specificity** — are targeted effects larger than norm-matched random residual perturbations?
|
| 37 |
+
10. **Contrastive specificity** — does an edit change preference between a desired and comparison continuation?
|
| 38 |
+
11. **Geometry** — are selected SAE decoder directions aligned, orthogonal, or cancelling before downstream model non-linearity?
|
| 39 |
|
| 40 |
A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
|
| 41 |
|
| 42 |
+
## Causal baseline discipline
|
| 43 |
|
| 44 |
v0.3 batched multiple residual edits for efficiency, but compared them with a **separately executed baseline**. On real Qwen3 inference this produced a small numerical discrepancy in the nominal `1×` no-edit dose-response row.
|
| 45 |
|
| 46 |
+
FeatureLens therefore includes an explicit **batched zero-edit reference** in causal batches. Every effect inside that batch is measured against the zero-edit row from the **same execution context**.
|
| 47 |
|
| 48 |
For the scale dose-response, the `1×` row itself is the no-edit reference:
|
| 49 |
|
|
|
|
| 60 |
|
| 61 |
## Random-control ensemble
|
| 62 |
|
| 63 |
+
A single random residual direction can be unusually weak or unusually disruptive by chance. FeatureLens uses with **8 deterministic norm-matched random directions**.
|
| 64 |
|
| 65 |
For a targeted residual delta `Δh`, each control satisfies:
|
| 66 |
|
|
|
|
| 125 |
|
| 126 |
### Feature evidence
|
| 127 |
|
| 128 |
+
The **Feature evidence** tab now provides two complementary live views:
|
| 129 |
+
|
| 130 |
+
1. **Feature-token activation trace** — shows where the selected feature enters the SAE TopK support across every token of the current Workbench prompt.
|
| 131 |
+
2. **Prompt-wide controlled concept contrast** — evaluates the selected feature on a small balanced batch drawn from the repository's seven controlled concept groups:
|
| 132 |
|
| 133 |
- code;
|
| 134 |
- mathematics;
|
|
|
|
| 138 |
- factual entities;
|
| 139 |
- uncertainty.
|
| 140 |
|
| 141 |
+
One wording per paraphrase pair is used to reduce near-duplicate inflation. Each prompt is summarized by the feature's **maximum activation across non-padding prompt tokens**. The live scan reports mean/median prompt-wide maxima, prompt activation rate, mean activation when active, and maximum activation by concept. If the feature is inactive in every sampled prompt, the UI reports that explicitly and does not fabricate a leading concept.
|
| 142 |
|
| 143 |
This is an exploratory live feature-evidence tool, **not an automatic semantic label**. The held-out offline AUROC/F1 benchmark remains the stronger claim.
|
| 144 |
|
| 145 |
+
### Contrastive continuation preference
|
| 146 |
+
|
| 147 |
+
A probability increase for one target can reflect a broad distribution shift rather than selective behavioral control. v0.5 adds a stricter live test with two exact continuations, A and B. FeatureLens scores both teacher-forced under the same SAE intervention and random-control ensemble, then reports the causal change in:
|
| 148 |
+
|
| 149 |
+
```text
|
| 150 |
+
log P(A) - log P(B)
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
The app also reports a token-normalized preference shift, targeted/random magnitude ratio, and exploratory empirical tail probability. This lets the workbench distinguish **general distributional influence** from a more selective change in relative continuation preference.
|
| 154 |
+
|
| 155 |
+
### Feature-set decoder geometry
|
| 156 |
+
|
| 157 |
+
The Feature Sets tab can inspect 2–8 selected decoder directions without an additional generation experiment. It reports pairwise decoder cosine similarities and compares the L2 norm of the activation-weighted joint ablation with the root-sum-square norm expected for independent directions. A ratio below 1 indicates net geometric cancellation; above 1 indicates net alignment. This is a geometric diagnostic, not by itself evidence of downstream causal interaction.
|
| 158 |
+
|
| 159 |
### Paraphrase robustness
|
| 160 |
|
| 161 |
+
FeatureLens reports two complementary views:
|
| 162 |
|
| 163 |
1. **Selected-token robustness** — TopK Jaccard and sparse cosine at the two manually chosen token positions.
|
| 164 |
2. **Prompt-wide robustness** — for each SAE feature, take its maximum activation across all prompt tokens, then compare the two sparse prompt profiles.
|
|
|
|
| 169 |
|
| 170 |
The trajectory view compares SAE reconstruction and sparsity/concentration diagnostics at the same prompt token across layers 4, 14 and 26. It does **not** equate feature IDs across layer-specific SAE dictionaries.
|
| 171 |
|
| 172 |
+
## v0.5 interface and embedded-Space behavior
|
| 173 |
+
|
| 174 |
+
The app uses `gr.Blocks(fill_width=True)` together with a centered 1600 px research canvas so wide result tables make better use of desktop screens. Result tables use bounded heights to reduce runaway page growth in the Hugging Face iframe. A lightweight browser-side resize/mutation observer requests a layout reflow when dynamic outputs change height, addressing the intermittent case where the embedded Space stopped scrolling until the user switched tabs.
|
| 175 |
+
|
| 176 |
+
All explicit action/copy buttons share the same muted teal treatment. Copy actions now briefly change their own label to **✓ Copied with headers** before returning to the normal label. Typography and control sizes were normalized around a serif research-document style.
|
| 177 |
+
|
| 178 |
## Copy tables with headers
|
| 179 |
|
| 180 |
Every result table that is intended for analysis has an explicit **Copy table with headers** button. The app serializes the displayed data as TSV before copying, so column names are preserved.
|
| 181 |
|
| 182 |
+
The native Dataframe fullscreen control is retained, while the custom copy action avoids relying on browser text selection and gives immediate visual confirmation when clipboard writing succeeds.
|
| 183 |
|
| 184 |
## Reconstruction-preserving edits
|
| 185 |
|
|
|
|
| 291 |
|
| 292 |
## UI design
|
| 293 |
|
| 294 |
+
v0.5 keeps the interface deliberately closer to a conventional research instrument than a rounded dashboard:
|
| 295 |
|
| 296 |
- Times / Liberation Serif-style formal typography;
|
| 297 |
- restrained teal, umber, stone, plum, and muted red chart accents;
|
|
|
|
| 300 |
- explicit section rules instead of pill badges;
|
| 301 |
- extra bottom padding and a visible end-of-workbench footer to avoid an abrupt embedded-page cutoff.
|
| 302 |
|
| 303 |
+
The app also installs a lightweight browser-side reflow observer for dynamic output-height changes. Hugging Face still owns the outer embedding frame, so the direct Space URL remains a useful diagnostic if embedded-page scrolling ever behaves differently.
|
| 304 |
|
| 305 |
## Repository layout
|
| 306 |
|
|
|
|
| 326 |
│ └── run_all.py
|
| 327 |
├── data/
|
| 328 |
├── tests/
|
| 329 |
+
├── scripts/
|
| 330 |
+
│ ├── release_check.py
|
| 331 |
+
│ └── ui_smoke.py
|
| 332 |
├── docs/
|
| 333 |
└── research_config.json
|
| 334 |
```
|
|
|
|
| 339 |
python -m ruff check app.py featurelens experiments tests scripts
|
| 340 |
python -m pytest -q
|
| 341 |
python -m compileall -q app.py featurelens experiments scripts
|
| 342 |
+
python scripts/ui_smoke.py
|
| 343 |
python scripts/release_check.py
|
| 344 |
```
|
| 345 |
|
| 346 |
+
See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.5 validation guide uses the **exact labels shown in the UI**, focuses on new-version acceptance/regression tests, and intentionally defers the comprehensive adversarial suite to the final release.
|
| 347 |
|
| 348 |
## Limitations
|
| 349 |
|
|
|
|
| 358 |
## Resume-ready description
|
| 359 |
|
| 360 |
> **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
|
| 361 |
+
> Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, token/prompt-wide feature evidence, reconstruction-preserving single and multi-feature interventions, full-continuation and contrastive preference scoring, dose-response analysis, decoder-geometry/non-additivity diagnostics, and norm-matched random-control ensembles.
|
| 362 |
|
| 363 |
## Acknowledgements
|
| 364 |
|
app.py
CHANGED
|
@@ -17,116 +17,107 @@ INK_BLUEGREY = "#71808A"
|
|
| 17 |
|
| 18 |
CSS = r"""
|
| 19 |
.gradio-container {
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
|
| 23 |
-
|
|
|
|
| 24 |
}
|
| 25 |
.gradio-container input,
|
| 26 |
.gradio-container textarea,
|
| 27 |
.gradio-container button,
|
| 28 |
.gradio-container select,
|
| 29 |
.gradio-container label,
|
| 30 |
-
.gradio-container table
|
|
|
|
| 31 |
font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
|
| 32 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
.hero {
|
| 34 |
-
padding:
|
| 35 |
border-bottom: 1px solid var(--border-color-primary);
|
| 36 |
-
margin-bottom:
|
| 37 |
}
|
| 38 |
.hero h1 {
|
| 39 |
margin: 0;
|
| 40 |
-
font-size: 2.
|
| 41 |
font-weight: 600;
|
| 42 |
letter-spacing: 0;
|
| 43 |
}
|
| 44 |
-
.hero .subtitle {
|
| 45 |
-
|
| 46 |
-
font-size: 1.02rem;
|
| 47 |
-
opacity: .76;
|
| 48 |
-
}
|
| 49 |
-
.hero .metadata {
|
| 50 |
-
margin-top: 8px;
|
| 51 |
-
font-size: .82rem;
|
| 52 |
-
opacity: .62;
|
| 53 |
-
letter-spacing: .015em;
|
| 54 |
-
}
|
| 55 |
.research-q {
|
| 56 |
-
margin:
|
| 57 |
-
padding:
|
| 58 |
border-left: 3px solid #708B86;
|
| 59 |
border-top: 1px solid var(--border-color-primary);
|
| 60 |
border-bottom: 1px solid var(--border-color-primary);
|
| 61 |
background: var(--background-fill-secondary);
|
| 62 |
}
|
| 63 |
.section-rule {
|
| 64 |
-
margin:
|
| 65 |
-
padding-top:
|
| 66 |
border-top: 1px solid var(--border-color-primary);
|
| 67 |
font-variant: small-caps;
|
| 68 |
-
letter-spacing: .
|
| 69 |
-
font-size: .
|
| 70 |
-
opacity: .
|
| 71 |
-
}
|
| 72 |
-
.token-wrap {
|
| 73 |
-
display: flex;
|
| 74 |
-
flex-wrap: wrap;
|
| 75 |
-
gap: 5px;
|
| 76 |
-
padding: 7px 2px 13px;
|
| 77 |
-
line-height: 1.9;
|
| 78 |
}
|
|
|
|
| 79 |
.token {
|
| 80 |
background: var(--background-fill-secondary);
|
| 81 |
border: 1px solid var(--border-color-primary);
|
| 82 |
-
border-radius:
|
| 83 |
-
padding:
|
| 84 |
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
|
| 85 |
-
font-size:
|
| 86 |
}
|
| 87 |
-
.token.selected {
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
}
|
| 91 |
-
.token sup { opacity: .55; margin-right: 4px; }
|
| 92 |
-
.small-note { opacity: .70; font-size: 12px; }
|
| 93 |
.instrument-note {
|
| 94 |
border: 1px solid var(--border-color-primary);
|
| 95 |
-
border-radius:
|
| 96 |
-
padding:
|
| 97 |
background: var(--background-fill-secondary);
|
| 98 |
-
margin:
|
| 99 |
-
}
|
| 100 |
-
.copy-row { margin-top: -5px; }
|
| 101 |
-
.copy-row button { min-height: 31px !important; }
|
| 102 |
-
.gradio-container button {
|
| 103 |
-
border-radius: 4px !important;
|
| 104 |
-
font-weight: 600 !important;
|
| 105 |
-
}
|
| 106 |
-
.gradio-container .form,
|
| 107 |
-
.gradio-container .block {
|
| 108 |
-
border-radius: 4px !important;
|
| 109 |
-
}
|
| 110 |
-
.gradio-container textarea,
|
| 111 |
-
.gradio-container input {
|
| 112 |
-
border-radius: 3px !important;
|
| 113 |
}
|
| 114 |
-
.
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
| 119 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
.classic-footer {
|
| 121 |
-
margin-top:
|
| 122 |
-
padding:
|
| 123 |
border-top: 1px solid var(--border-color-primary);
|
| 124 |
-
opacity: .
|
| 125 |
-
font-size: .
|
| 126 |
-
min-height:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
}
|
| 128 |
-
/* Add breathing room below tabs in embedded Hugging Face iframes. */
|
| 129 |
-
.tabs, .tabitem { padding-bottom: 18px !important; }
|
| 130 |
"""
|
| 131 |
|
| 132 |
THEME = gr.themes.Base(
|
|
@@ -139,6 +130,18 @@ THEME = gr.themes.Base(
|
|
| 139 |
COPY_JS = r"""
|
| 140 |
(text) => {
|
| 141 |
const value = text || "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
const fallback = () => {
|
| 143 |
const node = document.createElement("textarea");
|
| 144 |
node.value = value;
|
|
@@ -149,12 +152,38 @@ COPY_JS = r"""
|
|
| 149 |
node.select();
|
| 150 |
document.execCommand("copy");
|
| 151 |
document.body.removeChild(node);
|
|
|
|
| 152 |
};
|
| 153 |
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 154 |
-
navigator.clipboard.writeText(value).catch(fallback);
|
| 155 |
} else {
|
| 156 |
fallback();
|
| 157 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
return [];
|
| 159 |
}
|
| 160 |
"""
|
|
@@ -171,11 +200,15 @@ def _tsv(frame: pd.DataFrame) -> str:
|
|
| 171 |
|
| 172 |
|
| 173 |
def _copy_button(label: str = "Copy table with headers") -> gr.Button:
|
| 174 |
-
return gr.Button(label, size="sm", variant="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
|
| 177 |
def _bind_copy(button: gr.Button, source: gr.Textbox) -> None:
|
| 178 |
-
button.click(fn=
|
| 179 |
|
| 180 |
|
| 181 |
def _analysis_metrics_markdown(result) -> str:
|
|
@@ -305,14 +338,74 @@ def _paraphrase_metrics_markdown(result) -> str:
|
|
| 305 |
|
| 306 |
|
| 307 |
def _concept_metrics_markdown(result) -> str:
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
return (
|
| 310 |
f"Feature **{result.feature_id}**, layer **{result.layer}** · "
|
| 311 |
-
f"{result.prompts_per_concept} controlled prompts/concept. \n"
|
| 312 |
-
f"
|
| 313 |
-
|
| 314 |
-
"This is an
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
)
|
| 317 |
|
| 318 |
|
|
@@ -657,19 +750,109 @@ def run_concept_contrast(feature_id: str, layer: int, prompts_per_concept: int):
|
|
| 657 |
columns = [
|
| 658 |
"Concept",
|
| 659 |
"Prompts",
|
| 660 |
-
"Mean
|
| 661 |
-
"Median
|
| 662 |
-
"
|
|
|
|
| 663 |
"Max activation",
|
| 664 |
]
|
| 665 |
table = pd.DataFrame(result.rows, columns=columns)
|
| 666 |
-
chart = pd.DataFrame(result.chart_rows, columns=["Concept", "Mean
|
| 667 |
-
chart["Series"] = "
|
| 668 |
return _concept_metrics_markdown(result), table, chart, _tsv(table)
|
| 669 |
except Exception as exc:
|
| 670 |
_raise_ui_error(exc)
|
| 671 |
|
| 672 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 673 |
def mode_help(mode: str):
|
| 674 |
if mode == "ablate":
|
| 675 |
return gr.update(value=0.0, interactive=False, label="Coefficient (unused for ablation)")
|
|
@@ -684,10 +867,10 @@ def set_mode_help(mode: str):
|
|
| 684 |
return gr.update(value=2.0, interactive=True, label="Shared feature multiplier")
|
| 685 |
|
| 686 |
|
| 687 |
-
with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as demo:
|
| 688 |
gr.HTML(
|
| 689 |
'<header class="hero">'
|
| 690 |
-
'<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.
|
| 691 |
'<div class="subtitle">Causal Interpretability Workbench</div>'
|
| 692 |
'<div class="metadata">Qwen3-1.7B-Base · Qwen-Scope residual SAEs · layers 4 / 14 / 26 · '
|
| 693 |
'32,768 features · TopK=50 · ZeroGPU</div>'
|
|
@@ -729,7 +912,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 729 |
info="-1 = final prompt token.",
|
| 730 |
)
|
| 731 |
top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
|
| 732 |
-
analyze_btn = gr.Button("Inspect sparse features", variant="primary")
|
| 733 |
|
| 734 |
gr.Markdown("#### Prompt tokens\nThe selected prompt token is outlined more strongly.")
|
| 735 |
token_view = gr.HTML(
|
|
@@ -743,7 +926,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 743 |
datatype=["number", "number", "number", "str"],
|
| 744 |
interactive=False,
|
| 745 |
label="Strongest active SAE features",
|
| 746 |
-
wrap=
|
|
|
|
| 747 |
buttons=["fullscreen"],
|
| 748 |
)
|
| 749 |
feature_tsv = gr.Textbox(visible="hidden")
|
|
@@ -795,7 +979,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 795 |
step=1,
|
| 796 |
label="Greedy generation length",
|
| 797 |
)
|
| 798 |
-
intervene_btn = gr.Button("Run single-feature causal test", variant="primary")
|
| 799 |
intervention_metrics = gr.Markdown()
|
| 800 |
with gr.Column(scale=3):
|
| 801 |
with gr.Row():
|
|
@@ -805,7 +989,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 805 |
interactive=False,
|
| 806 |
label="Next-token distribution shift",
|
| 807 |
buttons=["fullscreen"],
|
| 808 |
-
wrap=
|
|
|
|
| 809 |
)
|
| 810 |
token_prob_tsv = gr.Textbox(visible="hidden")
|
| 811 |
token_prob_copy = _copy_button()
|
|
@@ -813,7 +998,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 813 |
interactive=False,
|
| 814 |
label="Target continuation token-by-token score",
|
| 815 |
buttons=["fullscreen"],
|
| 816 |
-
wrap=
|
|
|
|
| 817 |
)
|
| 818 |
target_token_tsv = gr.Textbox(visible="hidden")
|
| 819 |
target_token_copy = _copy_button()
|
|
@@ -825,7 +1011,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 825 |
"the native coefficient. The six conditions are evaluated together, and all deltas are measured "
|
| 826 |
"against the 1× row from that same batch."
|
| 827 |
)
|
| 828 |
-
dose_btn = gr.Button("Run scale dose-response")
|
| 829 |
dose_metrics = gr.Markdown()
|
| 830 |
with gr.Row(equal_height=False):
|
| 831 |
with gr.Column(scale=3):
|
|
@@ -833,7 +1019,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 833 |
interactive=False,
|
| 834 |
label="Scale dose-response measurements",
|
| 835 |
buttons=["fullscreen"],
|
| 836 |
-
wrap=
|
|
|
|
| 837 |
)
|
| 838 |
dose_tsv = gr.Textbox(visible="hidden")
|
| 839 |
dose_copy = _copy_button()
|
|
@@ -849,6 +1036,56 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 849 |
height=330,
|
| 850 |
)
|
| 851 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
with gr.Tab("Feature sets"):
|
| 853 |
gr.Markdown(
|
| 854 |
"### Distributed sparse representations\n"
|
|
@@ -890,7 +1127,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 890 |
lines=1,
|
| 891 |
scale=2,
|
| 892 |
)
|
| 893 |
-
set_btn = gr.Button("Run joint feature-set causal test", variant="primary")
|
| 894 |
set_metrics = gr.Markdown()
|
| 895 |
with gr.Row(equal_height=False):
|
| 896 |
with gr.Column(scale=2):
|
|
@@ -898,7 +1135,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 898 |
interactive=False,
|
| 899 |
label="Joint intervention features",
|
| 900 |
buttons=["fullscreen"],
|
| 901 |
-
wrap=
|
|
|
|
| 902 |
)
|
| 903 |
set_feature_tsv = gr.Textbox(visible="hidden")
|
| 904 |
set_feature_copy = _copy_button()
|
|
@@ -907,7 +1145,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 907 |
interactive=False,
|
| 908 |
label="Target continuation token-by-token score",
|
| 909 |
buttons=["fullscreen"],
|
| 910 |
-
wrap=
|
|
|
|
| 911 |
)
|
| 912 |
set_target_tsv = gr.Textbox(visible="hidden")
|
| 913 |
set_target_copy = _copy_button()
|
|
@@ -918,7 +1157,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 918 |
"norm-matched random directions, all sharing one batched no-edit baseline."
|
| 919 |
)
|
| 920 |
set_sweep_target = gr.Textbox(label="Target continuation for set-size sweep", value="2x")
|
| 921 |
-
set_sweep_btn = gr.Button("Run 1/3/5-feature ablation sweep")
|
| 922 |
set_sweep_note = gr.Markdown()
|
| 923 |
with gr.Row(equal_height=False):
|
| 924 |
with gr.Column(scale=3):
|
|
@@ -926,7 +1165,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 926 |
interactive=False,
|
| 927 |
label="Feature-set size measurements",
|
| 928 |
buttons=["fullscreen"],
|
| 929 |
-
wrap=
|
|
|
|
| 930 |
)
|
| 931 |
set_sweep_tsv = gr.Textbox(visible="hidden")
|
| 932 |
set_sweep_copy = _copy_button()
|
|
@@ -952,7 +1192,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 952 |
"non-additivity without claiming that the features form a direct circuit."
|
| 953 |
)
|
| 954 |
interaction_target = gr.Textbox(label="Target continuation for interaction test", value="2x")
|
| 955 |
-
interaction_btn = gr.Button("Run individual-vs-joint decomposition")
|
| 956 |
interaction_metrics = gr.Markdown()
|
| 957 |
with gr.Row(equal_height=False):
|
| 958 |
with gr.Column(scale=3):
|
|
@@ -960,7 +1200,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 960 |
interactive=False,
|
| 961 |
label="Individual and joint ablation measurements",
|
| 962 |
buttons=["fullscreen"],
|
| 963 |
-
wrap=
|
|
|
|
| 964 |
)
|
| 965 |
interaction_tsv = gr.Textbox(visible="hidden")
|
| 966 |
interaction_copy = _copy_button()
|
|
@@ -977,12 +1218,48 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 977 |
height=330,
|
| 978 |
)
|
| 979 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 980 |
with gr.Tab("Feature evidence"):
|
| 981 |
gr.Markdown(
|
| 982 |
-
"###
|
| 983 |
-
"
|
| 984 |
-
"FeatureLens's seven controlled concept groups.
|
| 985 |
-
"
|
| 986 |
)
|
| 987 |
contrast_location = gr.Markdown("No Workbench inspection has been run yet.")
|
| 988 |
with gr.Row(equal_height=True):
|
|
@@ -1006,7 +1283,40 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 1006 |
label="Prompts per concept",
|
| 1007 |
scale=2,
|
| 1008 |
)
|
| 1009 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1010 |
contrast_metrics = gr.Markdown()
|
| 1011 |
with gr.Row(equal_height=False):
|
| 1012 |
with gr.Column(scale=3):
|
|
@@ -1014,19 +1324,20 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 1014 |
interactive=False,
|
| 1015 |
label="Feature activation by controlled concept",
|
| 1016 |
buttons=["fullscreen"],
|
| 1017 |
-
wrap=
|
|
|
|
| 1018 |
)
|
| 1019 |
contrast_tsv = gr.Textbox(visible="hidden")
|
| 1020 |
contrast_copy = _copy_button()
|
| 1021 |
with gr.Column(scale=2):
|
| 1022 |
contrast_plot = gr.BarPlot(
|
| 1023 |
x="Concept",
|
| 1024 |
-
y="Mean
|
| 1025 |
color="Series",
|
| 1026 |
-
color_map={"
|
| 1027 |
-
title="
|
| 1028 |
x_title="Concept",
|
| 1029 |
-
y_title="Mean
|
| 1030 |
x_label_angle=-25,
|
| 1031 |
height=330,
|
| 1032 |
)
|
|
@@ -1035,7 +1346,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 1035 |
gr.Markdown(
|
| 1036 |
"### Local and prompt-wide robustness\n"
|
| 1037 |
"The selected-token comparison is strict but can be misleading if the two chosen tokens play different "
|
| 1038 |
-
"semantic roles. v0.
|
| 1039 |
"feature, take its maximum activation across all prompt tokens, then compare the two sparse profiles."
|
| 1040 |
)
|
| 1041 |
with gr.Row():
|
|
@@ -1058,7 +1369,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 1058 |
para_idx_a = gr.Number(value=-1, precision=0, label="Original prompt token index")
|
| 1059 |
para_idx_b = gr.Number(value=-1, precision=0, label="Paraphrase token index")
|
| 1060 |
para_top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
|
| 1061 |
-
para_btn = gr.Button("Compare paraphrase representations", variant="primary")
|
| 1062 |
with gr.Row():
|
| 1063 |
with gr.Column():
|
| 1064 |
gr.Markdown("#### Original prompt tokens")
|
|
@@ -1073,7 +1384,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 1073 |
interactive=False,
|
| 1074 |
label="Top-feature overlap at selected tokens",
|
| 1075 |
buttons=["fullscreen"],
|
| 1076 |
-
wrap=
|
|
|
|
| 1077 |
)
|
| 1078 |
para_tsv = gr.Textbox(visible="hidden")
|
| 1079 |
para_copy = _copy_button()
|
|
@@ -1109,7 +1421,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 1109 |
info="-1 = final token",
|
| 1110 |
scale=1,
|
| 1111 |
)
|
| 1112 |
-
trajectory_btn = gr.Button("Compare layers", variant="primary")
|
| 1113 |
gr.Markdown("#### Prompt tokens")
|
| 1114 |
trajectory_tokens = gr.HTML()
|
| 1115 |
with gr.Row(equal_height=False):
|
|
@@ -1118,7 +1430,8 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 1118 |
interactive=False,
|
| 1119 |
label="Layer diagnostics",
|
| 1120 |
buttons=["fullscreen"],
|
| 1121 |
-
wrap=
|
|
|
|
| 1122 |
)
|
| 1123 |
trajectory_tsv = gr.Textbox(visible="hidden")
|
| 1124 |
trajectory_copy = _copy_button()
|
|
@@ -1143,7 +1456,7 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as dem
|
|
| 1143 |
gr.Markdown(
|
| 1144 |
"The offline pipeline remains the source of held-out feature/concept AUROC + F1, reconstruction "
|
| 1145 |
"quality, paraphrase stability, dense residual linear probes, and causal intervention conclusions. "
|
| 1146 |
-
"v0.
|
| 1147 |
"the saved report should be generated only from real experiment artifacts."
|
| 1148 |
)
|
| 1149 |
|
|
@@ -1164,7 +1477,7 @@ $$h' = h + \sum_{i \in S}\Delta z_i d_i.$$
|
|
| 1164 |
|
| 1165 |
FeatureLens patches the delta into the **original residual**; it never replaces the residual with the complete SAE reconstruction.
|
| 1166 |
|
| 1167 |
-
### v0.
|
| 1168 |
|
| 1169 |
Batched experiments include an explicit **zero-edit row**. Causal effects are measured against that row rather than a separately executed baseline, which removes batch-vs-single floating-point drift from the measured effect. Random specificity uses an ensemble of norm-matched residual directions rather than one arbitrary seed.
|
| 1170 |
|
|
@@ -1172,14 +1485,15 @@ Batched experiments include an explicit **zero-edit row**. Causal effects are me
|
|
| 1172 |
|
| 1173 |
1. SAE reconstruction quality.
|
| 1174 |
2. Held-out feature/concept prediction.
|
| 1175 |
-
3.
|
| 1176 |
-
4.
|
| 1177 |
5. Single-feature causal intervention and dose-response.
|
| 1178 |
-
6.
|
| 1179 |
-
7.
|
| 1180 |
-
8.
|
|
|
|
| 1181 |
|
| 1182 |
-
Association, robustness, and intervention evidence remain separate claims.
|
| 1183 |
"""
|
| 1184 |
)
|
| 1185 |
|
|
@@ -1189,6 +1503,8 @@ Association, robustness, and intervention evidence remain separate claims.
|
|
| 1189 |
'End of workbench.</footer>'
|
| 1190 |
)
|
| 1191 |
|
|
|
|
|
|
|
| 1192 |
# Event wiring.
|
| 1193 |
analyze_btn.click(
|
| 1194 |
analyze_prompt,
|
|
@@ -1226,6 +1542,12 @@ Association, robustness, and intervention evidence remain separate claims.
|
|
| 1226 |
inputs=[prompt, layer, token_index, feature_id, target_text],
|
| 1227 |
outputs=[dose_table, dose_plot, dose_metrics, dose_tsv],
|
| 1228 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1229 |
set_mode.change(set_mode_help, inputs=[set_mode], outputs=[set_coefficient])
|
| 1230 |
set_btn.click(
|
| 1231 |
run_feature_set,
|
|
@@ -1242,6 +1564,16 @@ Association, robustness, and intervention evidence remain separate claims.
|
|
| 1242 |
inputs=[prompt, layer, token_index, feature_set_ids, interaction_target],
|
| 1243 |
outputs=[interaction_table, interaction_metrics, interaction_plot, interaction_tsv],
|
| 1244 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1245 |
contrast_btn.click(
|
| 1246 |
run_concept_contrast,
|
| 1247 |
inputs=[contrast_feature_id, contrast_layer, contrast_n],
|
|
@@ -1263,10 +1595,13 @@ Association, robustness, and intervention evidence remain separate claims.
|
|
| 1263 |
(token_prob_copy, token_prob_tsv),
|
| 1264 |
(target_token_copy, target_token_tsv),
|
| 1265 |
(dose_copy, dose_tsv),
|
|
|
|
| 1266 |
(set_feature_copy, set_feature_tsv),
|
| 1267 |
(set_target_copy, set_target_tsv),
|
| 1268 |
(set_sweep_copy, set_sweep_tsv),
|
| 1269 |
(interaction_copy, interaction_tsv),
|
|
|
|
|
|
|
| 1270 |
(contrast_copy, contrast_tsv),
|
| 1271 |
(para_copy, para_tsv),
|
| 1272 |
(trajectory_copy, trajectory_tsv),
|
|
|
|
| 17 |
|
| 18 |
CSS = r"""
|
| 19 |
.gradio-container {
|
| 20 |
+
width: min(96vw, 1600px) !important;
|
| 21 |
+
max-width: 1600px !important;
|
| 22 |
+
margin-left: auto !important;
|
| 23 |
+
margin-right: auto !important;
|
| 24 |
+
padding: 0 22px 128px !important;
|
| 25 |
font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
|
| 26 |
+
font-size: 16px !important;
|
| 27 |
+
line-height: 1.52;
|
| 28 |
}
|
| 29 |
.gradio-container input,
|
| 30 |
.gradio-container textarea,
|
| 31 |
.gradio-container button,
|
| 32 |
.gradio-container select,
|
| 33 |
.gradio-container label,
|
| 34 |
+
.gradio-container table,
|
| 35 |
+
.gradio-container .prose {
|
| 36 |
font-family: "Times New Roman", Times, "Liberation Serif", Georgia, serif !important;
|
| 37 |
}
|
| 38 |
+
.gradio-container input,
|
| 39 |
+
.gradio-container textarea,
|
| 40 |
+
.gradio-container select { font-size: 15.5px !important; }
|
| 41 |
+
.gradio-container table { font-size: 14.5px !important; }
|
| 42 |
+
.gradio-container label, .gradio-container .label-wrap { font-size: 15px !important; }
|
| 43 |
+
.gradio-container p, .gradio-container li { font-size: 16px; }
|
| 44 |
+
.gradio-container h2 { font-size: 1.55rem; }
|
| 45 |
+
.gradio-container h3 { font-size: 1.30rem; }
|
| 46 |
+
.gradio-container h4 { font-size: 1.08rem; }
|
| 47 |
.hero {
|
| 48 |
+
padding: 15px 2px 7px;
|
| 49 |
border-bottom: 1px solid var(--border-color-primary);
|
| 50 |
+
margin-bottom: 16px;
|
| 51 |
}
|
| 52 |
.hero h1 {
|
| 53 |
margin: 0;
|
| 54 |
+
font-size: 2.25rem;
|
| 55 |
font-weight: 600;
|
| 56 |
letter-spacing: 0;
|
| 57 |
}
|
| 58 |
+
.hero .subtitle { margin-top: 4px; font-size: 1.08rem; opacity: .78; }
|
| 59 |
+
.hero .metadata { margin-top: 8px; font-size: .92rem; opacity: .68; letter-spacing: .01em; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
.research-q {
|
| 61 |
+
margin: 14px 0 18px;
|
| 62 |
+
padding: 11px 15px;
|
| 63 |
border-left: 3px solid #708B86;
|
| 64 |
border-top: 1px solid var(--border-color-primary);
|
| 65 |
border-bottom: 1px solid var(--border-color-primary);
|
| 66 |
background: var(--background-fill-secondary);
|
| 67 |
}
|
| 68 |
.section-rule {
|
| 69 |
+
margin: 20px 0 11px;
|
| 70 |
+
padding-top: 9px;
|
| 71 |
border-top: 1px solid var(--border-color-primary);
|
| 72 |
font-variant: small-caps;
|
| 73 |
+
letter-spacing: .045em;
|
| 74 |
+
font-size: .98rem;
|
| 75 |
+
opacity: .78;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
+
.token-wrap { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 2px 14px; line-height: 1.9; }
|
| 78 |
.token {
|
| 79 |
background: var(--background-fill-secondary);
|
| 80 |
border: 1px solid var(--border-color-primary);
|
| 81 |
+
border-radius: 2px;
|
| 82 |
+
padding: 3px 7px;
|
| 83 |
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
|
| 84 |
+
font-size: 12.5px;
|
| 85 |
}
|
| 86 |
+
.token.selected { border: 2px solid #708B86; font-weight: 700; }
|
| 87 |
+
.token sup { opacity: .58; margin-right: 4px; }
|
| 88 |
+
.small-note { opacity: .74; font-size: 14px; }
|
|
|
|
|
|
|
|
|
|
| 89 |
.instrument-note {
|
| 90 |
border: 1px solid var(--border-color-primary);
|
| 91 |
+
border-radius: 2px;
|
| 92 |
+
padding: 10px 12px;
|
| 93 |
background: var(--background-fill-secondary);
|
| 94 |
+
margin: 6px 0 11px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
+
.copy-row { margin-top: -4px; }
|
| 97 |
+
.copy-row button { min-height: 34px !important; }
|
| 98 |
+
.gradio-container button { border-radius: 3px !important; font-weight: 600 !important; font-size: 15px !important; }
|
| 99 |
+
.action-btn button, .copy-btn button {
|
| 100 |
+
background: #5f817b !important;
|
| 101 |
+
color: #fff !important;
|
| 102 |
+
border: 1px solid #5f817b !important;
|
| 103 |
}
|
| 104 |
+
.action-btn button:hover, .copy-btn button:hover { background: #536f6a !important; border-color: #536f6a !important; }
|
| 105 |
+
.gradio-container .form, .gradio-container .block { border-radius: 3px !important; }
|
| 106 |
+
.gradio-container textarea, .gradio-container input { border-radius: 2px !important; }
|
| 107 |
+
.form-note { margin-top: -3px; margin-bottom: 9px; font-size: .92rem; opacity: .72; }
|
| 108 |
+
.wide-table { width: 100% !important; }
|
| 109 |
.classic-footer {
|
| 110 |
+
margin-top: 44px;
|
| 111 |
+
padding: 20px 2px 58px;
|
| 112 |
border-top: 1px solid var(--border-color-primary);
|
| 113 |
+
opacity: .70;
|
| 114 |
+
font-size: .92rem;
|
| 115 |
+
min-height: 110px;
|
| 116 |
+
}
|
| 117 |
+
.tabs, .tabitem { padding-bottom: 26px !important; }
|
| 118 |
+
@media (max-width: 900px) {
|
| 119 |
+
.gradio-container { width: 100% !important; padding-left: 12px !important; padding-right: 12px !important; }
|
| 120 |
}
|
|
|
|
|
|
|
| 121 |
"""
|
| 122 |
|
| 123 |
THEME = gr.themes.Base(
|
|
|
|
| 130 |
COPY_JS = r"""
|
| 131 |
(text) => {
|
| 132 |
const value = text || "";
|
| 133 |
+
const button = document.activeElement && document.activeElement.tagName === "BUTTON"
|
| 134 |
+
? document.activeElement : null;
|
| 135 |
+
const oldLabel = button ? button.innerText : null;
|
| 136 |
+
const signal = () => {
|
| 137 |
+
if (!button) return;
|
| 138 |
+
button.innerText = "✓ Copied with headers";
|
| 139 |
+
button.disabled = true;
|
| 140 |
+
window.setTimeout(() => {
|
| 141 |
+
button.innerText = oldLabel || "Copy table with headers";
|
| 142 |
+
button.disabled = false;
|
| 143 |
+
}, 1200);
|
| 144 |
+
};
|
| 145 |
const fallback = () => {
|
| 146 |
const node = document.createElement("textarea");
|
| 147 |
node.value = value;
|
|
|
|
| 152 |
node.select();
|
| 153 |
document.execCommand("copy");
|
| 154 |
document.body.removeChild(node);
|
| 155 |
+
signal();
|
| 156 |
};
|
| 157 |
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 158 |
+
navigator.clipboard.writeText(value).then(signal).catch(fallback);
|
| 159 |
} else {
|
| 160 |
fallback();
|
| 161 |
}
|
| 162 |
+
return [value];
|
| 163 |
+
}
|
| 164 |
+
"""
|
| 165 |
+
|
| 166 |
+
INSTALL_REFLOW_JS = r"""
|
| 167 |
+
() => {
|
| 168 |
+
if (window.__featurelens_reflow_installed) return [];
|
| 169 |
+
window.__featurelens_reflow_installed = true;
|
| 170 |
+
let timer = null;
|
| 171 |
+
const kick = () => {
|
| 172 |
+
window.clearTimeout(timer);
|
| 173 |
+
timer = window.setTimeout(() => {
|
| 174 |
+
window.dispatchEvent(new Event("resize"));
|
| 175 |
+
}, 80);
|
| 176 |
+
};
|
| 177 |
+
const root = document.querySelector(".gradio-container") || document.body;
|
| 178 |
+
if (window.ResizeObserver) {
|
| 179 |
+
const observer = new ResizeObserver(kick);
|
| 180 |
+
observer.observe(root);
|
| 181 |
+
window.__featurelens_reflow_observer = observer;
|
| 182 |
+
}
|
| 183 |
+
const mutation = new MutationObserver(kick);
|
| 184 |
+
mutation.observe(root, {subtree: true, childList: true});
|
| 185 |
+
window.__featurelens_mutation_observer = mutation;
|
| 186 |
+
kick();
|
| 187 |
return [];
|
| 188 |
}
|
| 189 |
"""
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
def _copy_button(label: str = "Copy table with headers") -> gr.Button:
|
| 203 |
+
return gr.Button(label, size="sm", variant="primary", elem_classes=["copy-btn"])
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _copy_ack(_text: str) -> None:
|
| 207 |
+
gr.Info("Copied table with headers.", duration=1.2)
|
| 208 |
|
| 209 |
|
| 210 |
def _bind_copy(button: gr.Button, source: gr.Textbox) -> None:
|
| 211 |
+
button.click(fn=_copy_ack, inputs=[source], outputs=None, js=COPY_JS, queue=False)
|
| 212 |
|
| 213 |
|
| 214 |
def _analysis_metrics_markdown(result) -> str:
|
|
|
|
| 338 |
|
| 339 |
|
| 340 |
def _concept_metrics_markdown(result) -> str:
|
| 341 |
+
coverage = f"{result.active_prompt_count}/{result.total_prompt_count}"
|
| 342 |
+
if result.leading_concept is None:
|
| 343 |
+
leader = (
|
| 344 |
+
"The feature was **inactive in every sampled prompt**. No concept leader is reported; "
|
| 345 |
+
"increase the sample or inspect a different feature."
|
| 346 |
+
)
|
| 347 |
+
elif result.leading_ratio is None:
|
| 348 |
+
leader = (
|
| 349 |
+
f"Highest prompt-wide mean activation: **{result.leading_concept}**; the runner-up mean was zero."
|
| 350 |
+
)
|
| 351 |
+
else:
|
| 352 |
+
leader = (
|
| 353 |
+
f"Highest prompt-wide mean activation: **{result.leading_concept}** "
|
| 354 |
+
f"(**{result.leading_ratio:.2f}×** the runner-up mean)."
|
| 355 |
+
)
|
| 356 |
return (
|
| 357 |
f"Feature **{result.feature_id}**, layer **{result.layer}** · "
|
| 358 |
+
f"{result.prompts_per_concept} controlled prompts/concept · active in **{coverage}** sampled prompts. \n"
|
| 359 |
+
f"{leader} \n\n"
|
| 360 |
+
"Each prompt is summarized by the feature's **maximum activation across its non-padding tokens**. "
|
| 361 |
+
"This is an exploratory contrast scan, not an automatic semantic label; held-out AUROC/F1 remains the stronger evidence."
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _trace_metrics_markdown(result) -> str:
|
| 366 |
+
if result.max_token_index is None:
|
| 367 |
+
peak = "Feature is inactive at every prompt token."
|
| 368 |
+
else:
|
| 369 |
+
peak = (
|
| 370 |
+
f"Peak activation **{result.max_activation:.4f}** at token **{result.max_token_index}** "
|
| 371 |
+
f"({result.tokens[result.max_token_index]!r})."
|
| 372 |
+
)
|
| 373 |
+
return (
|
| 374 |
+
f"Feature **{result.feature_id}**, layer **{result.layer}** · active at "
|
| 375 |
+
f"**{result.active_token_count}/{result.token_count}** prompt tokens. \n{peak}"
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def _geometry_metrics_markdown(result) -> str:
|
| 380 |
+
if result.alignment_ratio > 1.05:
|
| 381 |
+
geometry = "The activation-weighted ablation directions are net **aligned**."
|
| 382 |
+
elif result.alignment_ratio < 0.95:
|
| 383 |
+
geometry = "The activation-weighted ablation directions show net **cancellation**."
|
| 384 |
+
else:
|
| 385 |
+
geometry = "The joint norm is close to the orthogonal/independent reference."
|
| 386 |
+
return (
|
| 387 |
+
f"Selected features: **{', '.join(str(x) for x in result.feature_ids)}** · layer **{result.layer}** \n"
|
| 388 |
+
f"Mean |decoder cosine|: **{result.mean_abs_decoder_cosine:.3f}** · "
|
| 389 |
+
f"max |decoder cosine|: **{result.max_abs_decoder_cosine:.3f}** \n"
|
| 390 |
+
f"Joint ablation L2: **{result.joint_ablation_norm:.4f}** · independent-direction reference: "
|
| 391 |
+
f"**{result.independent_norm:.4f}** · geometry ratio: **{result.alignment_ratio:.3f}×** \n"
|
| 392 |
+
f"{geometry} This diagnoses SAE decoder geometry; it does not by itself establish downstream causal interaction."
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def _contrastive_metrics_markdown(result) -> str:
|
| 397 |
+
direction = "toward A" if result.delta_log_odds > 0 else ("toward B" if result.delta_log_odds < 0 else "neither way")
|
| 398 |
+
return (
|
| 399 |
+
f"Feature **{result.feature_id}** · activation **{result.feature_activation:.4f}** · perturbation L2 "
|
| 400 |
+
f"**{result.perturbation_norm:.4f}** \n"
|
| 401 |
+
f"Exact-sequence log-odds A−B — baseline: **{result.baseline_log_odds:+.4f}** · "
|
| 402 |
+
f"SAE edit: **{result.modified_log_odds:+.4f}** · causal shift: **{result.delta_log_odds:+.4f}** ({direction}). \n"
|
| 403 |
+
f"Token-normalized preference shift: **{result.delta_normalized_preference:+.4f}**. \n"
|
| 404 |
+
f"Random ensemble ({result.random_control_count}) |Δ log-odds| mean: **{result.random_abs_mean_delta:.4f}** "
|
| 405 |
+
f"± **{result.random_delta_std:.4f}** · SAE/random ratio: **{result.specificity_ratio:.2f}×** · "
|
| 406 |
+
f"empirical tail p: **{result.empirical_p:.3f}**. \n\n"
|
| 407 |
+
"This asks whether the intervention changes the model's **relative preference between two specified continuations**, "
|
| 408 |
+
"which is stricter than showing that one continuation's probability moved in isolation."
|
| 409 |
)
|
| 410 |
|
| 411 |
|
|
|
|
| 750 |
columns = [
|
| 751 |
"Concept",
|
| 752 |
"Prompts",
|
| 753 |
+
"Mean prompt-wide max",
|
| 754 |
+
"Median prompt-wide max",
|
| 755 |
+
"Prompt activation rate",
|
| 756 |
+
"Mean when active",
|
| 757 |
"Max activation",
|
| 758 |
]
|
| 759 |
table = pd.DataFrame(result.rows, columns=columns)
|
| 760 |
+
chart = pd.DataFrame(result.chart_rows, columns=["Concept", "Mean prompt-wide max"])
|
| 761 |
+
chart["Series"] = "Prompt-wide max"
|
| 762 |
return _concept_metrics_markdown(result), table, chart, _tsv(table)
|
| 763 |
except Exception as exc:
|
| 764 |
_raise_ui_error(exc)
|
| 765 |
|
| 766 |
|
| 767 |
+
@gpu(duration=30)
|
| 768 |
+
def run_feature_trace(prompt: str, layer: int, feature_id: str):
|
| 769 |
+
try:
|
| 770 |
+
if not prompt.strip():
|
| 771 |
+
raise ValueError("Enter a prompt first.")
|
| 772 |
+
if feature_id is None or str(feature_id).strip() == "":
|
| 773 |
+
raise ValueError("Choose a feature id first.")
|
| 774 |
+
result = RUNTIME.feature_token_trace(
|
| 775 |
+
text=prompt,
|
| 776 |
+
layer=int(layer),
|
| 777 |
+
feature_id=int(float(feature_id)),
|
| 778 |
+
)
|
| 779 |
+
columns = ["Token position", "Token", "Activation", "Active in TopK"]
|
| 780 |
+
table = pd.DataFrame(result.rows, columns=columns)
|
| 781 |
+
chart = pd.DataFrame(result.chart_rows, columns=["Token", "Activation"])
|
| 782 |
+
chart["Series"] = "Feature activation"
|
| 783 |
+
return _trace_metrics_markdown(result), table, chart, _tsv(table)
|
| 784 |
+
except Exception as exc:
|
| 785 |
+
_raise_ui_error(exc)
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
@gpu(duration=30)
|
| 789 |
+
def run_feature_geometry(prompt: str, layer: int, token_index: int, feature_ids: list[str] | None):
|
| 790 |
+
try:
|
| 791 |
+
selected = [int(float(value)) for value in (feature_ids or [])]
|
| 792 |
+
result = RUNTIME.feature_geometry(
|
| 793 |
+
text=prompt,
|
| 794 |
+
layer=int(layer),
|
| 795 |
+
token_index=int(token_index),
|
| 796 |
+
feature_ids=selected,
|
| 797 |
+
)
|
| 798 |
+
columns = ["Feature A", "Feature B", "Activation A", "Activation B", "Decoder cosine"]
|
| 799 |
+
table = pd.DataFrame(result.rows, columns=columns)
|
| 800 |
+
chart = pd.DataFrame(result.chart_rows, columns=["Feature pair", "Decoder cosine"])
|
| 801 |
+
chart["Series"] = "Decoder cosine"
|
| 802 |
+
return _geometry_metrics_markdown(result), table, chart, _tsv(table)
|
| 803 |
+
except Exception as exc:
|
| 804 |
+
_raise_ui_error(exc)
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
@gpu(duration=40)
|
| 808 |
+
def run_contrastive_causal(
|
| 809 |
+
prompt: str,
|
| 810 |
+
layer: int,
|
| 811 |
+
token_index: int,
|
| 812 |
+
feature_id: str,
|
| 813 |
+
mode: str,
|
| 814 |
+
coefficient: float,
|
| 815 |
+
target_a: str,
|
| 816 |
+
target_b: str,
|
| 817 |
+
):
|
| 818 |
+
try:
|
| 819 |
+
if feature_id is None or str(feature_id).strip() == "":
|
| 820 |
+
raise ValueError("Choose a feature id first.")
|
| 821 |
+
result = RUNTIME.contrastive_intervention(
|
| 822 |
+
text=prompt,
|
| 823 |
+
layer=int(layer),
|
| 824 |
+
token_index=int(token_index),
|
| 825 |
+
feature_id=int(float(feature_id)),
|
| 826 |
+
mode=mode,
|
| 827 |
+
coefficient=float(coefficient),
|
| 828 |
+
target_a=target_a,
|
| 829 |
+
target_b=target_b,
|
| 830 |
+
)
|
| 831 |
+
columns = [
|
| 832 |
+
"Continuation",
|
| 833 |
+
"Text",
|
| 834 |
+
"Tokens",
|
| 835 |
+
"Baseline sequence log p",
|
| 836 |
+
"SAE-edit sequence log p",
|
| 837 |
+
"Δ sequence log p",
|
| 838 |
+
"Baseline mean log p/token",
|
| 839 |
+
"SAE-edit mean log p/token",
|
| 840 |
+
"Δ mean log p/token",
|
| 841 |
+
]
|
| 842 |
+
table = pd.DataFrame(result.rows, columns=columns)
|
| 843 |
+
chart = pd.DataFrame(
|
| 844 |
+
[
|
| 845 |
+
["Baseline", result.baseline_log_odds],
|
| 846 |
+
["SAE edit", result.modified_log_odds],
|
| 847 |
+
],
|
| 848 |
+
columns=["Condition", "A−B sequence log-odds"],
|
| 849 |
+
)
|
| 850 |
+
chart["Series"] = "Contrastive preference"
|
| 851 |
+
return _contrastive_metrics_markdown(result), table, chart, _tsv(table)
|
| 852 |
+
except Exception as exc:
|
| 853 |
+
_raise_ui_error(exc)
|
| 854 |
+
|
| 855 |
+
|
| 856 |
def mode_help(mode: str):
|
| 857 |
if mode == "ablate":
|
| 858 |
return gr.update(value=0.0, interactive=False, label="Coefficient (unused for ablation)")
|
|
|
|
| 867 |
return gr.update(value=2.0, interactive=True, label="Shared feature multiplier")
|
| 868 |
|
| 869 |
|
| 870 |
+
with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
|
| 871 |
gr.HTML(
|
| 872 |
'<header class="hero">'
|
| 873 |
+
'<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.5</span></h1>'
|
| 874 |
'<div class="subtitle">Causal Interpretability Workbench</div>'
|
| 875 |
'<div class="metadata">Qwen3-1.7B-Base · Qwen-Scope residual SAEs · layers 4 / 14 / 26 · '
|
| 876 |
'32,768 features · TopK=50 · ZeroGPU</div>'
|
|
|
|
| 912 |
info="-1 = final prompt token.",
|
| 913 |
)
|
| 914 |
top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
|
| 915 |
+
analyze_btn = gr.Button("Inspect sparse features", variant="primary", elem_classes=["action-btn"])
|
| 916 |
|
| 917 |
gr.Markdown("#### Prompt tokens\nThe selected prompt token is outlined more strongly.")
|
| 918 |
token_view = gr.HTML(
|
|
|
|
| 926 |
datatype=["number", "number", "number", "str"],
|
| 927 |
interactive=False,
|
| 928 |
label="Strongest active SAE features",
|
| 929 |
+
wrap=False,
|
| 930 |
+
max_height=380,
|
| 931 |
buttons=["fullscreen"],
|
| 932 |
)
|
| 933 |
feature_tsv = gr.Textbox(visible="hidden")
|
|
|
|
| 979 |
step=1,
|
| 980 |
label="Greedy generation length",
|
| 981 |
)
|
| 982 |
+
intervene_btn = gr.Button("Run single-feature causal test", variant="primary", elem_classes=["action-btn"])
|
| 983 |
intervention_metrics = gr.Markdown()
|
| 984 |
with gr.Column(scale=3):
|
| 985 |
with gr.Row():
|
|
|
|
| 989 |
interactive=False,
|
| 990 |
label="Next-token distribution shift",
|
| 991 |
buttons=["fullscreen"],
|
| 992 |
+
wrap=False,
|
| 993 |
+
max_height=380,
|
| 994 |
)
|
| 995 |
token_prob_tsv = gr.Textbox(visible="hidden")
|
| 996 |
token_prob_copy = _copy_button()
|
|
|
|
| 998 |
interactive=False,
|
| 999 |
label="Target continuation token-by-token score",
|
| 1000 |
buttons=["fullscreen"],
|
| 1001 |
+
wrap=False,
|
| 1002 |
+
max_height=380,
|
| 1003 |
)
|
| 1004 |
target_token_tsv = gr.Textbox(visible="hidden")
|
| 1005 |
target_token_copy = _copy_button()
|
|
|
|
| 1011 |
"the native coefficient. The six conditions are evaluated together, and all deltas are measured "
|
| 1012 |
"against the 1× row from that same batch."
|
| 1013 |
)
|
| 1014 |
+
dose_btn = gr.Button("Run scale dose-response", variant="primary", elem_classes=["action-btn"])
|
| 1015 |
dose_metrics = gr.Markdown()
|
| 1016 |
with gr.Row(equal_height=False):
|
| 1017 |
with gr.Column(scale=3):
|
|
|
|
| 1019 |
interactive=False,
|
| 1020 |
label="Scale dose-response measurements",
|
| 1021 |
buttons=["fullscreen"],
|
| 1022 |
+
wrap=False,
|
| 1023 |
+
max_height=380,
|
| 1024 |
)
|
| 1025 |
dose_tsv = gr.Textbox(visible="hidden")
|
| 1026 |
dose_copy = _copy_button()
|
|
|
|
| 1036 |
height=330,
|
| 1037 |
)
|
| 1038 |
|
| 1039 |
+
gr.HTML('<div class="section-rule">IV. Contrastive continuation preference</div>')
|
| 1040 |
+
with gr.Accordion("Contrastive causal preference test", open=False):
|
| 1041 |
+
gr.Markdown(
|
| 1042 |
+
"Absolute target probability can move because an intervention broadly perturbs the distribution. "
|
| 1043 |
+
"This test asks the stricter question: **does the edit shift preference between two exact continuations?** "
|
| 1044 |
+
"Both continuations are teacher-forced and compared with the same 8-direction norm-matched random ensemble."
|
| 1045 |
+
)
|
| 1046 |
+
with gr.Row(equal_height=True):
|
| 1047 |
+
contrastive_a = gr.Textbox(label="Continuation A (preferred)", value="2x", scale=2)
|
| 1048 |
+
contrastive_b = gr.Textbox(label="Continuation B (comparison)", value="x", scale=2)
|
| 1049 |
+
with gr.Row(equal_height=True):
|
| 1050 |
+
contrastive_mode = gr.Dropdown(
|
| 1051 |
+
choices=["ablate", "scale", "inject"],
|
| 1052 |
+
value="ablate",
|
| 1053 |
+
label="Contrastive intervention",
|
| 1054 |
+
scale=1,
|
| 1055 |
+
)
|
| 1056 |
+
contrastive_coefficient = gr.Number(
|
| 1057 |
+
value=0.0,
|
| 1058 |
+
interactive=False,
|
| 1059 |
+
label="Coefficient (unused for ablation)",
|
| 1060 |
+
scale=1,
|
| 1061 |
+
)
|
| 1062 |
+
contrastive_btn = gr.Button(
|
| 1063 |
+
"Run contrastive preference test", variant="primary", elem_classes=["action-btn"]
|
| 1064 |
+
)
|
| 1065 |
+
contrastive_metrics = gr.Markdown()
|
| 1066 |
+
with gr.Row(equal_height=False):
|
| 1067 |
+
with gr.Column(scale=3):
|
| 1068 |
+
contrastive_table = gr.Dataframe(
|
| 1069 |
+
interactive=False,
|
| 1070 |
+
label="Contrastive continuation scores",
|
| 1071 |
+
buttons=["fullscreen"],
|
| 1072 |
+
wrap=False,
|
| 1073 |
+
max_height=320,
|
| 1074 |
+
)
|
| 1075 |
+
contrastive_tsv = gr.Textbox(visible="hidden")
|
| 1076 |
+
contrastive_copy = _copy_button()
|
| 1077 |
+
with gr.Column(scale=2):
|
| 1078 |
+
contrastive_plot = gr.BarPlot(
|
| 1079 |
+
x="Condition",
|
| 1080 |
+
y="A−B sequence log-odds",
|
| 1081 |
+
color="Series",
|
| 1082 |
+
color_map={"Contrastive preference": INK_TEAL},
|
| 1083 |
+
title="Preference between exact continuations",
|
| 1084 |
+
x_title="Execution condition",
|
| 1085 |
+
y_title="Sequence log-odds A−B",
|
| 1086 |
+
height=320,
|
| 1087 |
+
)
|
| 1088 |
+
|
| 1089 |
with gr.Tab("Feature sets"):
|
| 1090 |
gr.Markdown(
|
| 1091 |
"### Distributed sparse representations\n"
|
|
|
|
| 1127 |
lines=1,
|
| 1128 |
scale=2,
|
| 1129 |
)
|
| 1130 |
+
set_btn = gr.Button("Run joint feature-set causal test", variant="primary", elem_classes=["action-btn"])
|
| 1131 |
set_metrics = gr.Markdown()
|
| 1132 |
with gr.Row(equal_height=False):
|
| 1133 |
with gr.Column(scale=2):
|
|
|
|
| 1135 |
interactive=False,
|
| 1136 |
label="Joint intervention features",
|
| 1137 |
buttons=["fullscreen"],
|
| 1138 |
+
wrap=False,
|
| 1139 |
+
max_height=380,
|
| 1140 |
)
|
| 1141 |
set_feature_tsv = gr.Textbox(visible="hidden")
|
| 1142 |
set_feature_copy = _copy_button()
|
|
|
|
| 1145 |
interactive=False,
|
| 1146 |
label="Target continuation token-by-token score",
|
| 1147 |
buttons=["fullscreen"],
|
| 1148 |
+
wrap=False,
|
| 1149 |
+
max_height=380,
|
| 1150 |
)
|
| 1151 |
set_target_tsv = gr.Textbox(visible="hidden")
|
| 1152 |
set_target_copy = _copy_button()
|
|
|
|
| 1157 |
"norm-matched random directions, all sharing one batched no-edit baseline."
|
| 1158 |
)
|
| 1159 |
set_sweep_target = gr.Textbox(label="Target continuation for set-size sweep", value="2x")
|
| 1160 |
+
set_sweep_btn = gr.Button("Run 1/3/5-feature ablation sweep", variant="primary", elem_classes=["action-btn"])
|
| 1161 |
set_sweep_note = gr.Markdown()
|
| 1162 |
with gr.Row(equal_height=False):
|
| 1163 |
with gr.Column(scale=3):
|
|
|
|
| 1165 |
interactive=False,
|
| 1166 |
label="Feature-set size measurements",
|
| 1167 |
buttons=["fullscreen"],
|
| 1168 |
+
wrap=False,
|
| 1169 |
+
max_height=380,
|
| 1170 |
)
|
| 1171 |
set_sweep_tsv = gr.Textbox(visible="hidden")
|
| 1172 |
set_sweep_copy = _copy_button()
|
|
|
|
| 1192 |
"non-additivity without claiming that the features form a direct circuit."
|
| 1193 |
)
|
| 1194 |
interaction_target = gr.Textbox(label="Target continuation for interaction test", value="2x")
|
| 1195 |
+
interaction_btn = gr.Button("Run individual-vs-joint decomposition", variant="primary", elem_classes=["action-btn"])
|
| 1196 |
interaction_metrics = gr.Markdown()
|
| 1197 |
with gr.Row(equal_height=False):
|
| 1198 |
with gr.Column(scale=3):
|
|
|
|
| 1200 |
interactive=False,
|
| 1201 |
label="Individual and joint ablation measurements",
|
| 1202 |
buttons=["fullscreen"],
|
| 1203 |
+
wrap=False,
|
| 1204 |
+
max_height=380,
|
| 1205 |
)
|
| 1206 |
interaction_tsv = gr.Textbox(visible="hidden")
|
| 1207 |
interaction_copy = _copy_button()
|
|
|
|
| 1218 |
height=330,
|
| 1219 |
)
|
| 1220 |
|
| 1221 |
+
|
| 1222 |
+
gr.HTML('<div class="section-rule">Decoder geometry</div>')
|
| 1223 |
+
with gr.Accordion("Feature-set decoder geometry", open=False):
|
| 1224 |
+
gr.Markdown(
|
| 1225 |
+
"Joint causal effects can reflect both **decoder-direction geometry** and downstream non-linearity. "
|
| 1226 |
+
"This zero-extra-generation diagnostic measures pairwise decoder cosines and compares the actual "
|
| 1227 |
+
"activation-weighted joint-ablation norm with the norm expected for independent directions."
|
| 1228 |
+
)
|
| 1229 |
+
geometry_btn = gr.Button(
|
| 1230 |
+
"Inspect selected-feature geometry", variant="primary", elem_classes=["action-btn"]
|
| 1231 |
+
)
|
| 1232 |
+
geometry_metrics = gr.Markdown()
|
| 1233 |
+
with gr.Row(equal_height=False):
|
| 1234 |
+
with gr.Column(scale=3):
|
| 1235 |
+
geometry_table = gr.Dataframe(
|
| 1236 |
+
interactive=False,
|
| 1237 |
+
label="Pairwise decoder geometry",
|
| 1238 |
+
buttons=["fullscreen"],
|
| 1239 |
+
wrap=False,
|
| 1240 |
+
max_height=340,
|
| 1241 |
+
)
|
| 1242 |
+
geometry_tsv = gr.Textbox(visible="hidden")
|
| 1243 |
+
geometry_copy = _copy_button()
|
| 1244 |
+
with gr.Column(scale=2):
|
| 1245 |
+
geometry_plot = gr.BarPlot(
|
| 1246 |
+
x="Feature pair",
|
| 1247 |
+
y="Decoder cosine",
|
| 1248 |
+
color="Series",
|
| 1249 |
+
color_map={"Decoder cosine": INK_PLUM},
|
| 1250 |
+
title="Pairwise SAE decoder cosine",
|
| 1251 |
+
x_title="Feature pair",
|
| 1252 |
+
y_title="Cosine similarity",
|
| 1253 |
+
x_label_angle=-30,
|
| 1254 |
+
height=320,
|
| 1255 |
+
)
|
| 1256 |
+
|
| 1257 |
with gr.Tab("Feature evidence"):
|
| 1258 |
gr.Markdown(
|
| 1259 |
+
"### Feature activation evidence\n"
|
| 1260 |
+
"Inspect **where a feature fires inside the current prompt**, then test whether it activates preferentially "
|
| 1261 |
+
"across FeatureLens's seven controlled concept groups. The concept scan uses the feature's **maximum "
|
| 1262 |
+
"activation across non-padding tokens in each prompt**, avoiding arbitrary final-token comparisons."
|
| 1263 |
)
|
| 1264 |
contrast_location = gr.Markdown("No Workbench inspection has been run yet.")
|
| 1265 |
with gr.Row(equal_height=True):
|
|
|
|
| 1283 |
label="Prompts per concept",
|
| 1284 |
scale=2,
|
| 1285 |
)
|
| 1286 |
+
|
| 1287 |
+
gr.Markdown("#### Activation trace across the current Workbench prompt")
|
| 1288 |
+
gr.Markdown(
|
| 1289 |
+
"Uses the prompt currently entered in **Workbench** and shows exactly which prompt tokens include the "
|
| 1290 |
+
"selected feature in the SAE TopK support."
|
| 1291 |
+
)
|
| 1292 |
+
trace_btn = gr.Button("Trace feature across prompt tokens", variant="primary", elem_classes=["action-btn"])
|
| 1293 |
+
trace_metrics = gr.Markdown()
|
| 1294 |
+
with gr.Row(equal_height=False):
|
| 1295 |
+
with gr.Column(scale=3):
|
| 1296 |
+
trace_table = gr.Dataframe(
|
| 1297 |
+
interactive=False,
|
| 1298 |
+
label="Feature activation by prompt token",
|
| 1299 |
+
buttons=["fullscreen"],
|
| 1300 |
+
wrap=False,
|
| 1301 |
+
max_height=340,
|
| 1302 |
+
)
|
| 1303 |
+
trace_tsv = gr.Textbox(visible="hidden")
|
| 1304 |
+
trace_copy = _copy_button()
|
| 1305 |
+
with gr.Column(scale=2):
|
| 1306 |
+
trace_plot = gr.BarPlot(
|
| 1307 |
+
x="Token",
|
| 1308 |
+
y="Activation",
|
| 1309 |
+
color="Series",
|
| 1310 |
+
color_map={"Feature activation": INK_TEAL},
|
| 1311 |
+
title="Feature activation across prompt tokens",
|
| 1312 |
+
x_title="Prompt token",
|
| 1313 |
+
y_title="Activation",
|
| 1314 |
+
x_label_angle=-35,
|
| 1315 |
+
height=320,
|
| 1316 |
+
)
|
| 1317 |
+
|
| 1318 |
+
gr.Markdown("#### Prompt-wide controlled concept contrast")
|
| 1319 |
+
contrast_btn = gr.Button("Run controlled concept contrast", variant="primary", elem_classes=["action-btn"])
|
| 1320 |
contrast_metrics = gr.Markdown()
|
| 1321 |
with gr.Row(equal_height=False):
|
| 1322 |
with gr.Column(scale=3):
|
|
|
|
| 1324 |
interactive=False,
|
| 1325 |
label="Feature activation by controlled concept",
|
| 1326 |
buttons=["fullscreen"],
|
| 1327 |
+
wrap=False,
|
| 1328 |
+
max_height=380,
|
| 1329 |
)
|
| 1330 |
contrast_tsv = gr.Textbox(visible="hidden")
|
| 1331 |
contrast_copy = _copy_button()
|
| 1332 |
with gr.Column(scale=2):
|
| 1333 |
contrast_plot = gr.BarPlot(
|
| 1334 |
x="Concept",
|
| 1335 |
+
y="Mean prompt-wide max",
|
| 1336 |
color="Series",
|
| 1337 |
+
color_map={"Prompt-wide max": INK_BLUEGREY},
|
| 1338 |
+
title="Prompt-wide controlled concept contrast",
|
| 1339 |
x_title="Concept",
|
| 1340 |
+
y_title="Mean max activation",
|
| 1341 |
x_label_angle=-25,
|
| 1342 |
height=330,
|
| 1343 |
)
|
|
|
|
| 1346 |
gr.Markdown(
|
| 1347 |
"### Local and prompt-wide robustness\n"
|
| 1348 |
"The selected-token comparison is strict but can be misleading if the two chosen tokens play different "
|
| 1349 |
+
"semantic roles. v0.5 reports a **prompt-wide max-pooled feature profile**: for every SAE "
|
| 1350 |
"feature, take its maximum activation across all prompt tokens, then compare the two sparse profiles."
|
| 1351 |
)
|
| 1352 |
with gr.Row():
|
|
|
|
| 1369 |
para_idx_a = gr.Number(value=-1, precision=0, label="Original prompt token index")
|
| 1370 |
para_idx_b = gr.Number(value=-1, precision=0, label="Paraphrase token index")
|
| 1371 |
para_top_n = gr.Slider(5, 20, value=12, step=1, label="Displayed active features")
|
| 1372 |
+
para_btn = gr.Button("Compare paraphrase representations", variant="primary", elem_classes=["action-btn"])
|
| 1373 |
with gr.Row():
|
| 1374 |
with gr.Column():
|
| 1375 |
gr.Markdown("#### Original prompt tokens")
|
|
|
|
| 1384 |
interactive=False,
|
| 1385 |
label="Top-feature overlap at selected tokens",
|
| 1386 |
buttons=["fullscreen"],
|
| 1387 |
+
wrap=False,
|
| 1388 |
+
max_height=380,
|
| 1389 |
)
|
| 1390 |
para_tsv = gr.Textbox(visible="hidden")
|
| 1391 |
para_copy = _copy_button()
|
|
|
|
| 1421 |
info="-1 = final token",
|
| 1422 |
scale=1,
|
| 1423 |
)
|
| 1424 |
+
trajectory_btn = gr.Button("Compare layers", variant="primary", elem_classes=["action-btn"])
|
| 1425 |
gr.Markdown("#### Prompt tokens")
|
| 1426 |
trajectory_tokens = gr.HTML()
|
| 1427 |
with gr.Row(equal_height=False):
|
|
|
|
| 1430 |
interactive=False,
|
| 1431 |
label="Layer diagnostics",
|
| 1432 |
buttons=["fullscreen"],
|
| 1433 |
+
wrap=False,
|
| 1434 |
+
max_height=380,
|
| 1435 |
)
|
| 1436 |
trajectory_tsv = gr.Textbox(visible="hidden")
|
| 1437 |
trajectory_copy = _copy_button()
|
|
|
|
| 1456 |
gr.Markdown(
|
| 1457 |
"The offline pipeline remains the source of held-out feature/concept AUROC + F1, reconstruction "
|
| 1458 |
"quality, paraphrase stability, dense residual linear probes, and causal intervention conclusions. "
|
| 1459 |
+
"v0.5 strengthens the live workbench with batch-context null references and random-control ensembles; "
|
| 1460 |
"the saved report should be generated only from real experiment artifacts."
|
| 1461 |
)
|
| 1462 |
|
|
|
|
| 1477 |
|
| 1478 |
FeatureLens patches the delta into the **original residual**; it never replaces the residual with the complete SAE reconstruction.
|
| 1479 |
|
| 1480 |
+
### v0.5 control discipline
|
| 1481 |
|
| 1482 |
Batched experiments include an explicit **zero-edit row**. Causal effects are measured against that row rather than a separately executed baseline, which removes batch-vs-single floating-point drift from the measured effect. Random specificity uses an ensemble of norm-matched residual directions rather than one arbitrary seed.
|
| 1483 |
|
|
|
|
| 1485 |
|
| 1486 |
1. SAE reconstruction quality.
|
| 1487 |
2. Held-out feature/concept prediction.
|
| 1488 |
+
3. Token-local activation traces and prompt-wide feature evidence.
|
| 1489 |
+
4. Local and prompt-wide paraphrase robustness.
|
| 1490 |
5. Single-feature causal intervention and dose-response.
|
| 1491 |
+
6. Contrastive continuation preference under intervention.
|
| 1492 |
+
7. Joint feature-set intervention and set-size sensitivity.
|
| 1493 |
+
8. Decoder-direction geometry and individual-vs-joint non-additivity.
|
| 1494 |
+
9. Specificity relative to norm-matched random controls.
|
| 1495 |
|
| 1496 |
+
Association, robustness, geometry, and intervention evidence remain separate claims.
|
| 1497 |
"""
|
| 1498 |
)
|
| 1499 |
|
|
|
|
| 1503 |
'End of workbench.</footer>'
|
| 1504 |
)
|
| 1505 |
|
| 1506 |
+
demo.load(fn=None, js=INSTALL_REFLOW_JS, queue=False)
|
| 1507 |
+
|
| 1508 |
# Event wiring.
|
| 1509 |
analyze_btn.click(
|
| 1510 |
analyze_prompt,
|
|
|
|
| 1542 |
inputs=[prompt, layer, token_index, feature_id, target_text],
|
| 1543 |
outputs=[dose_table, dose_plot, dose_metrics, dose_tsv],
|
| 1544 |
)
|
| 1545 |
+
contrastive_mode.change(mode_help, inputs=[contrastive_mode], outputs=[contrastive_coefficient])
|
| 1546 |
+
contrastive_btn.click(
|
| 1547 |
+
run_contrastive_causal,
|
| 1548 |
+
inputs=[prompt, layer, token_index, feature_id, contrastive_mode, contrastive_coefficient, contrastive_a, contrastive_b],
|
| 1549 |
+
outputs=[contrastive_metrics, contrastive_table, contrastive_plot, contrastive_tsv],
|
| 1550 |
+
)
|
| 1551 |
set_mode.change(set_mode_help, inputs=[set_mode], outputs=[set_coefficient])
|
| 1552 |
set_btn.click(
|
| 1553 |
run_feature_set,
|
|
|
|
| 1564 |
inputs=[prompt, layer, token_index, feature_set_ids, interaction_target],
|
| 1565 |
outputs=[interaction_table, interaction_metrics, interaction_plot, interaction_tsv],
|
| 1566 |
)
|
| 1567 |
+
geometry_btn.click(
|
| 1568 |
+
run_feature_geometry,
|
| 1569 |
+
inputs=[prompt, layer, token_index, feature_set_ids],
|
| 1570 |
+
outputs=[geometry_metrics, geometry_table, geometry_plot, geometry_tsv],
|
| 1571 |
+
)
|
| 1572 |
+
trace_btn.click(
|
| 1573 |
+
run_feature_trace,
|
| 1574 |
+
inputs=[prompt, contrast_layer, contrast_feature_id],
|
| 1575 |
+
outputs=[trace_metrics, trace_table, trace_plot, trace_tsv],
|
| 1576 |
+
)
|
| 1577 |
contrast_btn.click(
|
| 1578 |
run_concept_contrast,
|
| 1579 |
inputs=[contrast_feature_id, contrast_layer, contrast_n],
|
|
|
|
| 1595 |
(token_prob_copy, token_prob_tsv),
|
| 1596 |
(target_token_copy, target_token_tsv),
|
| 1597 |
(dose_copy, dose_tsv),
|
| 1598 |
+
(contrastive_copy, contrastive_tsv),
|
| 1599 |
(set_feature_copy, set_feature_tsv),
|
| 1600 |
(set_target_copy, set_target_tsv),
|
| 1601 |
(set_sweep_copy, set_sweep_tsv),
|
| 1602 |
(interaction_copy, interaction_tsv),
|
| 1603 |
+
(geometry_copy, geometry_tsv),
|
| 1604 |
+
(trace_copy, trace_tsv),
|
| 1605 |
(contrast_copy, contrast_tsv),
|
| 1606 |
(para_copy, para_tsv),
|
| 1607 |
(trajectory_copy, trajectory_tsv),
|
docs/HF_DEPLOY.md
CHANGED
|
@@ -15,14 +15,17 @@ The live app does not need every SAE layer from the full repository.
|
|
| 15 |
|
| 16 |
## GPU-decorated actions
|
| 17 |
|
| 18 |
-
Current v0.
|
| 19 |
|
| 20 |
- **Inspect sparse features**;
|
| 21 |
- **Run single-feature causal test**;
|
| 22 |
- **Run scale dose-response**;
|
|
|
|
| 23 |
- **Run joint feature-set causal test**;
|
| 24 |
- **Run 1/3/5-feature ablation sweep**;
|
| 25 |
- **Run individual-vs-joint decomposition**;
|
|
|
|
|
|
|
| 26 |
- **Run controlled concept contrast**;
|
| 27 |
- **Compare paraphrase representations**;
|
| 28 |
- **Compare layers**.
|
|
@@ -31,7 +34,7 @@ Allocation durations in `app.py` are ceilings requested from ZeroGPU, not expect
|
|
| 31 |
|
| 32 |
## Batch-first causal execution
|
| 33 |
|
| 34 |
-
|
| 35 |
|
| 36 |
Examples:
|
| 37 |
|
|
@@ -39,7 +42,8 @@ Examples:
|
|
| 39 |
- single-feature causal tests stack zero edit, one targeted SAE edit, and eight norm-matched random controls;
|
| 40 |
- 1/3/5 feature-set sensitivity batches targeted edits and control ensembles;
|
| 41 |
- individual-vs-joint decomposition batches all individual ablations plus the joint ablation;
|
| 42 |
-
- controlled concept contrast evaluates its balanced prompt batch together
|
|
|
|
| 43 |
|
| 44 |
The primary causal reference inside each experiment is a **batched zero-edit row**. This prevents batch-vs-single floating-point drift from being mistaken for an intervention effect.
|
| 45 |
|
|
@@ -55,18 +59,21 @@ The heavier feature-set, set-size, interaction, and contrast panels avoid unnece
|
|
| 55 |
|
| 56 |
Major output tables include a dedicated **Copy table with headers** action. The app serializes the result as tab-separated text before invoking the browser clipboard API. This makes pasted output self-describing and spreadsheet-friendly.
|
| 57 |
|
| 58 |
-
If browser clipboard permission is unavailable, the frontend uses a temporary-textarea fallback.
|
| 59 |
|
| 60 |
## Embedded-Space layout
|
| 61 |
|
| 62 |
The app uses:
|
| 63 |
|
| 64 |
-
-
|
| 65 |
-
-
|
| 66 |
-
-
|
| 67 |
-
-
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
These changes
|
| 70 |
|
| 71 |
## Offline benchmark
|
| 72 |
|
|
@@ -86,3 +93,13 @@ python3 -m experiments.run_feature_sets --random-controls 16
|
|
| 86 |
```
|
| 87 |
|
| 88 |
Then commit only the small report/catalog/CSV/figure artifacts intended for presentation. Large activation arrays remain gitignored.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
## GPU-decorated actions
|
| 17 |
|
| 18 |
+
Current v0.5 live actions include:
|
| 19 |
|
| 20 |
- **Inspect sparse features**;
|
| 21 |
- **Run single-feature causal test**;
|
| 22 |
- **Run scale dose-response**;
|
| 23 |
+
- **Run contrastive preference test**;
|
| 24 |
- **Run joint feature-set causal test**;
|
| 25 |
- **Run 1/3/5-feature ablation sweep**;
|
| 26 |
- **Run individual-vs-joint decomposition**;
|
| 27 |
+
- **Inspect selected-feature geometry**;
|
| 28 |
+
- **Trace feature across prompt tokens**;
|
| 29 |
- **Run controlled concept contrast**;
|
| 30 |
- **Compare paraphrase representations**;
|
| 31 |
- **Compare layers**.
|
|
|
|
| 34 |
|
| 35 |
## Batch-first causal execution
|
| 36 |
|
| 37 |
+
FeatureLens deliberately batches related conditions so stronger diagnostics do not require a separate GPU callback for every condition.
|
| 38 |
|
| 39 |
Examples:
|
| 40 |
|
|
|
|
| 42 |
- single-feature causal tests stack zero edit, one targeted SAE edit, and eight norm-matched random controls;
|
| 43 |
- 1/3/5 feature-set sensitivity batches targeted edits and control ensembles;
|
| 44 |
- individual-vs-joint decomposition batches all individual ablations plus the joint ablation;
|
| 45 |
+
- controlled concept contrast evaluates its balanced prompt batch together;
|
| 46 |
+
- contrastive preference reuses one targeted delta/control ensemble while scoring the two exact continuations in two compact batched forwards.
|
| 47 |
|
| 48 |
The primary causal reference inside each experiment is a **batched zero-edit row**. This prevents batch-vs-single floating-point drift from being mistaken for an intervention effect.
|
| 49 |
|
|
|
|
| 59 |
|
| 60 |
Major output tables include a dedicated **Copy table with headers** action. The app serializes the result as tab-separated text before invoking the browser clipboard API. This makes pasted output self-describing and spreadsheet-friendly.
|
| 61 |
|
| 62 |
+
On successful clipboard write, the clicked button briefly changes to **✓ Copied with headers**. If browser clipboard permission is unavailable, the frontend uses a temporary-textarea fallback.
|
| 63 |
|
| 64 |
## Embedded-Space layout
|
| 65 |
|
| 66 |
The app uses:
|
| 67 |
|
| 68 |
+
- `gr.Blocks(fill_width=True)`;
|
| 69 |
+
- an explicitly centered desktop canvas up to 1600 px wide;
|
| 70 |
+
- restrained serif typography with normalized control/table sizes;
|
| 71 |
+
- consistent muted-teal action and copy buttons;
|
| 72 |
+
- bounded result-table heights;
|
| 73 |
+
- explicit bottom padding and a visible `End of workbench.` footer;
|
| 74 |
+
- a browser-side ResizeObserver/MutationObserver that requests a resize reflow after dynamic result-height changes.
|
| 75 |
|
| 76 |
+
These changes reduce wasted horizontal space and mitigate the embedded-Space case where the outer page stopped extending after a large dynamic result. Hugging Face still owns the outer embedding frame, so compare with the direct `*.hf.space` URL if the parent page ever behaves differently.
|
| 77 |
|
| 78 |
## Offline benchmark
|
| 79 |
|
|
|
|
| 93 |
```
|
| 94 |
|
| 95 |
Then commit only the small report/catalog/CSV/figure artifacts intended for presentation. Large activation arrays remain gitignored.
|
| 96 |
+
|
| 97 |
+
## Local UI launch smoke
|
| 98 |
+
|
| 99 |
+
Before pushing a release, run:
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
python3 scripts/ui_smoke.py
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
This opens the real Gradio `launch()` path on a temporary localhost port and immediately closes it. It exists specifically so theme/launch integration errors are caught before Hugging Face rebuilds the Space.
|
docs/METHODOLOGY.md
CHANGED
|
@@ -116,7 +116,7 @@ Next-token Jensen-Shannon divergence and greedy generation remain complementary
|
|
| 116 |
|
| 117 |
v0.3 exposed an important numerical issue: batched and separately executed model forwards can differ slightly even when the intended residual edit is zero. A nominal `1×` dose-response condition therefore showed a small non-zero effect when compared with a separately executed baseline.
|
| 118 |
|
| 119 |
-
|
| 120 |
|
| 121 |
Every batched causal experiment now includes an explicit zero-edit condition:
|
| 122 |
|
|
@@ -250,7 +250,7 @@ The strict selected-token comparison measures:
|
|
| 250 |
|
| 251 |
This can be misleading when the two manually selected tokens play different roles. For example, comparing the final token `is` in one prompt with a final punctuation token in its paraphrase is not a clean semantic-anchor comparison.
|
| 252 |
|
| 253 |
-
|
| 254 |
|
| 255 |
```text
|
| 256 |
profile_i(prompt) = max_t z_{t,i}
|
|
@@ -262,23 +262,75 @@ The selected-token and prompt-wide metrics answer different questions and are in
|
|
| 262 |
|
| 263 |
---
|
| 264 |
|
| 265 |
-
##
|
| 266 |
|
| 267 |
-
|
| 268 |
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
For each concept it reports:
|
| 272 |
|
| 273 |
-
- mean activation;
|
| 274 |
-
- median activation;
|
| 275 |
-
- activation rate;
|
|
|
|
| 276 |
- maximum activation.
|
| 277 |
|
|
|
|
|
|
|
| 278 |
This is a **diagnostic**, not the offline feature-labeling procedure. The live scan never modifies `Offline concept hint` and does not claim semantic identity from a handful of prompts.
|
| 279 |
|
| 280 |
---
|
| 281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
## Statistical interpretation in the offline report
|
| 283 |
|
| 284 |
The generated offline report uses paired targeted-vs-control comparisons.
|
|
|
|
| 116 |
|
| 117 |
v0.3 exposed an important numerical issue: batched and separately executed model forwards can differ slightly even when the intended residual edit is zero. A nominal `1×` dose-response condition therefore showed a small non-zero effect when compared with a separately executed baseline.
|
| 118 |
|
| 119 |
+
FeatureLens treats this as instrumentation drift, not causal evidence.
|
| 120 |
|
| 121 |
Every batched causal experiment now includes an explicit zero-edit condition:
|
| 122 |
|
|
|
|
| 250 |
|
| 251 |
This can be misleading when the two manually selected tokens play different roles. For example, comparing the final token `is` in one prompt with a final punctuation token in its paraphrase is not a clean semantic-anchor comparison.
|
| 252 |
|
| 253 |
+
FeatureLens therefore also reports a prompt-wide profile. For each SAE feature, FeatureLens takes its maximum activation across all prompt tokens:
|
| 254 |
|
| 255 |
```text
|
| 256 |
profile_i(prompt) = max_t z_{t,i}
|
|
|
|
| 262 |
|
| 263 |
---
|
| 264 |
|
| 265 |
+
## Feature-token activation trace
|
| 266 |
|
| 267 |
+
For a selected SAE feature and layer, v0.5 can encode every prompt-token residual and report the feature's TopK activation token by token. This answers a basic localization question that a single selected-token view cannot: is the feature concentrated at one syntactic/semantic position, or does it recur across the prompt?
|
| 268 |
|
| 269 |
+
The trace reports activation at each token, active-token count, and the peak token. A zero entry means that feature is not present in that token's TopK SAE support.
|
| 270 |
+
|
| 271 |
+
---
|
| 272 |
+
|
| 273 |
+
## Prompt-wide controlled concept contrast scan
|
| 274 |
+
|
| 275 |
+
The live **Feature evidence** tab also provides an exploratory concept contrast for one selected feature.
|
| 276 |
+
|
| 277 |
+
It samples a small balanced batch from the same seven controlled concept groups, using only one wording from each paraphrase pair. Earlier versions sampled only each prompt's final token, which can miss a feature that is active elsewhere in the prompt. v0.5 instead defines each prompt-level feature score as:
|
| 278 |
+
|
| 279 |
+
```text
|
| 280 |
+
score(feature, prompt) = max over non-padding prompt tokens of z_feature
|
| 281 |
+
```
|
| 282 |
|
| 283 |
For each concept it reports:
|
| 284 |
|
| 285 |
+
- mean prompt-wide maximum activation;
|
| 286 |
+
- median prompt-wide maximum activation;
|
| 287 |
+
- prompt activation rate;
|
| 288 |
+
- mean activation when active;
|
| 289 |
- maximum activation.
|
| 290 |
|
| 291 |
+
If the feature is inactive in every sampled prompt, FeatureLens reports that state explicitly and does not assign a leading concept.
|
| 292 |
+
|
| 293 |
This is a **diagnostic**, not the offline feature-labeling procedure. The live scan never modifies `Offline concept hint` and does not claim semantic identity from a handful of prompts.
|
| 294 |
|
| 295 |
---
|
| 296 |
|
| 297 |
+
## Contrastive continuation preference
|
| 298 |
+
|
| 299 |
+
Absolute probability change for one target can be difficult to interpret when an SAE edit broadly perturbs the output distribution. v0.5 therefore adds a contrastive causal test for two exact continuations, A and B.
|
| 300 |
+
|
| 301 |
+
For each continuation, FeatureLens performs full teacher-forced sequence scoring under the same zero-edit reference, targeted SAE edit, and norm-matched random-control ensemble. It then computes:
|
| 302 |
+
|
| 303 |
+
```text
|
| 304 |
+
preference = log P(A) - log P(B)
|
| 305 |
+
causal preference shift = preference_edited - preference_reference
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
The exact-sequence quantity is a true log-odds comparison between those two specified continuations. The UI additionally reports a token-normalized preference difference for interpretability when the continuations have different lengths.
|
| 309 |
+
|
| 310 |
+
A large next-token JS effect but a weak contrastive preference shift is evidence for **general distributional influence** more than selective control of the A-vs-B behavioral choice.
|
| 311 |
+
|
| 312 |
+
---
|
| 313 |
+
|
| 314 |
+
## Feature-set decoder geometry
|
| 315 |
+
|
| 316 |
+
Joint interventions can be affected by the geometry of SAE decoder directions before any downstream non-linearity is considered. For 2–8 selected same-layer features, v0.5 reports pairwise decoder cosine similarities.
|
| 317 |
+
|
| 318 |
+
It also forms each feature's activation-weighted ablation delta and compares:
|
| 319 |
+
|
| 320 |
+
```text
|
| 321 |
+
||sum_i delta_i||_2
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
with the independent-direction reference:
|
| 325 |
+
|
| 326 |
+
```text
|
| 327 |
+
sqrt(sum_i ||delta_i||_2^2)
|
| 328 |
+
```
|
| 329 |
+
|
| 330 |
+
Their ratio is 1 for an orthogonal/independent norm geometry, below 1 under net cancellation, and above 1 under net alignment. This diagnostic helps interpret joint-ablation results but does not establish downstream causal interaction by itself.
|
| 331 |
+
|
| 332 |
+
---
|
| 333 |
+
|
| 334 |
## Statistical interpretation in the offline report
|
| 335 |
|
| 336 |
The generated offline report uses paired targeted-vs-control comparisons.
|
docs/VALIDATION.md
CHANGED
|
@@ -1,655 +1,308 @@
|
|
| 1 |
-
# FeatureLens v0.
|
| 2 |
|
| 3 |
-
This
|
| 4 |
-
|
| 5 |
-
The tests are divided into four categories:
|
| 6 |
-
|
| 7 |
-
1. local software correctness;
|
| 8 |
-
2. Hugging Face / ZeroGPU deployment correctness;
|
| 9 |
-
3. causal-instrumentation sanity checks;
|
| 10 |
-
4. edge/adversarial behavior.
|
| 11 |
-
|
| 12 |
-
A successful live prompt is a software sanity check, **not** a scientific conclusion. Scientific claims should come from the saved offline benchmark artifacts and their paired controls.
|
| 13 |
-
|
| 14 |
-
---
|
| 15 |
|
| 16 |
## A. Local release gate
|
| 17 |
|
| 18 |
-
From the repository root
|
| 19 |
|
| 20 |
```bash
|
| 21 |
python3 -m pytest -q
|
| 22 |
python3 -m compileall -q app.py featurelens experiments scripts
|
|
|
|
| 23 |
python3 scripts/release_check.py
|
| 24 |
```
|
| 25 |
|
| 26 |
Expected:
|
| 27 |
|
| 28 |
```text
|
| 29 |
-
|
|
|
|
| 30 |
FeatureLens release check: PASS
|
| 31 |
discovery prompts: 224
|
| 32 |
causal tasks: 28
|
| 33 |
layers: [4, 14, 26]
|
| 34 |
feature-set sizes: [1, 3, 5]
|
| 35 |
random controls: 8
|
| 36 |
-
release: v0.
|
| 37 |
```
|
| 38 |
|
| 39 |
-
`
|
| 40 |
-
|
| 41 |
-
Optional lint gate:
|
| 42 |
-
|
| 43 |
-
```bash
|
| 44 |
-
python3 -m ruff check app.py featurelens experiments tests scripts
|
| 45 |
-
```
|
| 46 |
|
| 47 |
---
|
| 48 |
|
| 49 |
## B. Hugging Face startup and layout
|
| 50 |
|
| 51 |
-
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
Expected launch line:
|
| 56 |
|
| 57 |
```text
|
| 58 |
* Running on local URL: http://0.0.0.0:7860
|
| 59 |
```
|
| 60 |
|
| 61 |
-
|
| 62 |
|
| 63 |
-
|
| 64 |
-
coroutine 'App.create_app.<locals>.get_current_user' was never awaited
|
| 65 |
-
```
|
| 66 |
|
| 67 |
-
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
Open **Workbench**, scroll to the bottom of the full application, and then switch through **Feature sets**, **Feature evidence**, **Paraphrase robustness**, and **Layer trajectory**.
|
| 74 |
-
|
| 75 |
-
Verify:
|
| 76 |
-
|
| 77 |
-
- descenders such as `g`, `p`, `q`, and `y` are not clipped inside app content;
|
| 78 |
-
- the last interactive element is followed by visible breathing room;
|
| 79 |
-
- the footer containing `End of workbench.` can be reached;
|
| 80 |
-
- labels do not overlap controls after a tab switch.
|
| 81 |
-
|
| 82 |
-
Also resize the browser to roughly half desktop width.
|
| 83 |
-
|
| 84 |
-
**Pass:** form rows wrap rather than overlap, controls remain readable, and the footer is reachable. If the outer Hugging Face iframe itself temporarily refuses to resize and then corrects itself after loading, record that separately from an app-level clipping failure.
|
| 85 |
|
| 86 |
---
|
| 87 |
|
| 88 |
-
## C. Workbench inspection
|
| 89 |
-
|
| 90 |
-
### Test C1 — inspect a mathematics prompt
|
| 91 |
|
| 92 |
-
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
| 98 |
|
| 99 |
Click **Inspect sparse features**.
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
1. **Prompt tokens** appears and one token has the stronger selected outline.
|
| 104 |
-
2. **Analysis metrics** reports:
|
| 105 |
-
- `Layer 14`;
|
| 106 |
-
- the resolved prompt token index;
|
| 107 |
-
- active SAE features;
|
| 108 |
-
- reconstruction cosine;
|
| 109 |
-
- NMSE;
|
| 110 |
-
- Top-5 activation mass.
|
| 111 |
-
3. **Strongest active SAE features** contains 12 rows unless fewer are available.
|
| 112 |
-
4. **Activation profile** contains the same displayed feature IDs.
|
| 113 |
-
5. **Single feature id** is populated.
|
| 114 |
-
6. Open **Feature sets** and verify **Feature set** is populated with active feature IDs.
|
| 115 |
-
7. Open **Feature evidence** and verify **Feature id** is populated.
|
| 116 |
-
|
| 117 |
-
Basic bounds:
|
| 118 |
-
|
| 119 |
-
- reconstruction cosine is finite and lies in `[-1, 1]`;
|
| 120 |
-
- NMSE is finite and non-negative;
|
| 121 |
-
- feature IDs lie in `[0, 32767]`;
|
| 122 |
-
- displayed TopK activations are non-negative.
|
| 123 |
-
|
| 124 |
-
### Test C2 — copy feature table with headers
|
| 125 |
-
|
| 126 |
-
Under **Strongest active SAE features**, click **Copy table with headers** and paste into a plain-text editor.
|
| 127 |
-
|
| 128 |
-
The **first pasted line must contain the column names**, tab-separated, for example:
|
| 129 |
|
| 130 |
```text
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
```
|
| 133 |
|
| 134 |
-
The
|
| 135 |
-
|
| 136 |
-
Repeat by pasting into a spreadsheet. Columns should split naturally on tabs.
|
| 137 |
-
|
| 138 |
-
---
|
| 139 |
-
|
| 140 |
-
## D. Single-feature causal test
|
| 141 |
-
|
| 142 |
-
Continue from Test C1 and choose the strongest value in **Single feature id**.
|
| 143 |
-
|
| 144 |
-
### Test D1 — active-feature ablation with a multi-token target
|
| 145 |
-
|
| 146 |
-
Set:
|
| 147 |
-
|
| 148 |
-
- **Single-feature intervention**: `ablate`
|
| 149 |
-
- **Target continuation (optional)**: `2x`
|
| 150 |
-
- **Greedy generation length**: `8`
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
Verify:
|
| 155 |
-
|
| 156 |
-
1. **Baseline greedy generation** and **SAE-edited greedy generation** are populated.
|
| 157 |
-
2. **Next-token distribution shift** is populated.
|
| 158 |
-
3. **Target continuation token-by-token score** has one row for every token used to encode `2x`.
|
| 159 |
-
4. The metrics report:
|
| 160 |
-
- original feature activation;
|
| 161 |
-
- Δ coefficient;
|
| 162 |
-
- perturbation L2;
|
| 163 |
-
- next-token JS;
|
| 164 |
-
- random-ensemble mean JS and standard deviation;
|
| 165 |
-
- number of random controls (`8`);
|
| 166 |
-
- JS magnitude ratio and empirical tail probability;
|
| 167 |
-
- execution-context null drift;
|
| 168 |
-
- full target token count;
|
| 169 |
-
- sequence and mean-per-token target effects;
|
| 170 |
-
- random-ensemble target effect statistics.
|
| 171 |
-
5. For an active feature under ablation, `Δ coefficient ≈ -original feature activation`.
|
| 172 |
|
| 173 |
-
|
| 174 |
|
| 175 |
-
|
| 176 |
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
|
| 180 |
|
| 181 |
-
##
|
| 182 |
|
| 183 |
-
|
| 184 |
|
| 185 |
-
|
| 186 |
-
-
|
| 187 |
-
|
|
|
|
|
|
|
| 188 |
|
| 189 |
Click **Run single-feature causal test**.
|
| 190 |
|
| 191 |
-
|
| 192 |
|
| 193 |
-
|
| 194 |
|
| 195 |
-
|
|
|
|
|
|
|
| 196 |
|
| 197 |
-
|
| 198 |
-
- **Additive feature coefficient**: `5`
|
| 199 |
-
- **Target continuation (optional)**: `2x`
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
Expected:
|
| 204 |
-
|
| 205 |
-
- `Δ coefficient = +5` regardless of native activation;
|
| 206 |
-
- perturbation L2 is finite and non-zero;
|
| 207 |
-
- the random ensemble remains norm-matched to the resulting residual perturbation.
|
| 208 |
|
| 209 |
---
|
| 210 |
|
| 211 |
-
## E.
|
| 212 |
-
|
| 213 |
-
This is the **critical v0.4 regression test** for the small non-zero `1×` drift observed in v0.3.
|
| 214 |
|
| 215 |
-
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
Expected rows in **Scale dose-response measurements**:
|
| 220 |
|
| 221 |
```text
|
| 222 |
-
0
|
| 223 |
-
0.
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
```
|
| 229 |
|
| 230 |
-
|
| 231 |
|
| 232 |
-
-
|
| 233 |
-
- `0.5×` = half native activation;
|
| 234 |
-
- `1×` = batched no-edit reference;
|
| 235 |
-
- `1.5×` = 1.5 times native activation;
|
| 236 |
-
- `2×` = double;
|
| 237 |
-
- `3×` = triple.
|
| 238 |
|
| 239 |
-
##
|
| 240 |
|
| 241 |
-
|
| 242 |
|
| 243 |
```text
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
Δ sequence log p = 0
|
| 248 |
-
Next-token JS = 0
|
| 249 |
```
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
The curve itself does not need to be monotonic.
|
| 254 |
-
|
| 255 |
-
---
|
| 256 |
-
|
| 257 |
-
## F. Feature-set causal tests
|
| 258 |
-
|
| 259 |
-
Run Test C1 first so **Feature set** is populated.
|
| 260 |
-
|
| 261 |
-
### Test F1 — top-three joint ablation
|
| 262 |
-
|
| 263 |
-
Open **Feature sets**. Select exactly the three strongest active values in **Feature set**.
|
| 264 |
-
|
| 265 |
-
Set:
|
| 266 |
-
|
| 267 |
-
- **Intervention**: `ablate`
|
| 268 |
-
- **Target continuation**: `2x`
|
| 269 |
-
|
| 270 |
-
Click **Run joint feature-set causal test**.
|
| 271 |
|
| 272 |
-
|
| 273 |
|
| 274 |
-
|
| 275 |
-
- every active feature has `Δ coefficient = -original activation`;
|
| 276 |
-
- the total joint perturbation L2 is finite;
|
| 277 |
-
- the target continuation contains all target tokens;
|
| 278 |
-
- random-control count is `8`;
|
| 279 |
-
- random signed mean, random mean absolute effect, random standard deviation, magnitude ratio, and empirical tail probability are shown;
|
| 280 |
-
- execution-context null drift is shown separately from causal effect.
|
| 281 |
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
-
|
| 285 |
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
- **Intervention**: `scale`
|
| 289 |
-
- **Shared feature multiplier**: `2`
|
| 290 |
-
|
| 291 |
-
Run **Run joint feature-set causal test** again.
|
| 292 |
-
|
| 293 |
-
Expected: each selected active feature receives its own positive native-activation delta before decoder-direction deltas are summed.
|
| 294 |
-
|
| 295 |
-
### Test F3 — copy joint tables
|
| 296 |
-
|
| 297 |
-
Use **Copy table with headers** under both:
|
| 298 |
-
|
| 299 |
-
- **Joint intervention features**;
|
| 300 |
-
- **Target continuation token-by-token score**.
|
| 301 |
-
|
| 302 |
-
For both, the first pasted line must contain the table headers.
|
| 303 |
|
| 304 |
---
|
| 305 |
|
| 306 |
-
## G.
|
| 307 |
-
|
| 308 |
-
In **Feature sets**, set:
|
| 309 |
-
|
| 310 |
-
- **Target continuation for set-size sweep**: `2x`
|
| 311 |
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
Expected **Feature-set size measurements** rows:
|
| 315 |
|
| 316 |
```text
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
5
|
| 320 |
```
|
| 321 |
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
- exactly `k` feature IDs are listed;
|
| 325 |
-
- perturbation L2 is finite;
|
| 326 |
-
- targeted Δ mean log p/token is present;
|
| 327 |
-
- random-ensemble signed mean, mean absolute effect, and standard deviation are present;
|
| 328 |
-
- targeted/random magnitude ratio is finite or safely handled when the random effect is numerically zero;
|
| 329 |
-
- empirical tail probability lies in `[0, 1]`;
|
| 330 |
-
- SAE and random next-token JS statistics are present.
|
| 331 |
-
|
| 332 |
-
**Do not require monotonicity with k.** A k=3 edit may be less effective than k=1, and k=5 may reverse the effect. That can be a real non-linear response rather than a bug.
|
| 333 |
-
|
| 334 |
-
---
|
| 335 |
-
|
| 336 |
-
## H. Individual-vs-joint non-additivity
|
| 337 |
-
|
| 338 |
-
In **Feature sets**, select exactly the three strongest active values in **Feature set**.
|
| 339 |
-
|
| 340 |
-
Set:
|
| 341 |
-
|
| 342 |
-
- **Target continuation for interaction test**: `2x`
|
| 343 |
-
|
| 344 |
-
Click **Run individual-vs-joint decomposition**.
|
| 345 |
-
|
| 346 |
-
Verify:
|
| 347 |
-
|
| 348 |
-
1. **Individual and joint ablation measurements** contains one row per selected feature plus one `Joint ablation` row.
|
| 349 |
-
2. The summary reports:
|
| 350 |
-
- additive expectation = sum of individual Δ mean log p/token values;
|
| 351 |
-
- observed joint effect;
|
| 352 |
-
- interaction excess = joint − additive;
|
| 353 |
-
- normalized interaction;
|
| 354 |
-
- execution-context null drift.
|
| 355 |
-
3. The joint row perturbation norm is finite.
|
| 356 |
-
|
| 357 |
-
No particular sign is required. A non-zero interaction excess indicates non-additivity under this intervention, **not** proof that the selected SAE features form a direct circuit.
|
| 358 |
-
|
| 359 |
-
---
|
| 360 |
-
|
| 361 |
-
## I. Controlled concept contrast
|
| 362 |
-
|
| 363 |
-
First run Test C1. Open **Feature evidence**.
|
| 364 |
-
|
| 365 |
-
Set:
|
| 366 |
-
|
| 367 |
-
- **Feature id**: the strongest feature from Test C1;
|
| 368 |
-
- **Residual layer**: `14`;
|
| 369 |
-
- **Prompts per concept**: `4`.
|
| 370 |
-
|
| 371 |
-
Click **Run controlled concept contrast**.
|
| 372 |
-
|
| 373 |
-
Expected **Feature activation by controlled concept**:
|
| 374 |
-
|
| 375 |
-
- exactly seven concept rows;
|
| 376 |
-
- each row reports prompt count, mean activation, median activation, activation rate, and max activation;
|
| 377 |
-
- the chart contains the same seven concepts;
|
| 378 |
-
- the summary may identify the group with the highest mean activation but must explicitly describe the scan as exploratory.
|
| 379 |
-
|
| 380 |
-
This live scan is **not allowed to rename the feature or populate `Offline concept hint`**. A semantic hint should only come from the offline train/held-out discovery pipeline.
|
| 381 |
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
---
|
| 385 |
-
|
| 386 |
-
## J. Paraphrase robustness
|
| 387 |
-
|
| 388 |
-
Open **Paraphrase robustness** and set:
|
| 389 |
-
|
| 390 |
-
- **Original prompt**: `The derivative of x squared is`
|
| 391 |
-
- **Paraphrase**: `Differentiate x squared with respect to x:`
|
| 392 |
-
- **Residual layer**: `14`
|
| 393 |
-
- **Original prompt token index**: `-1`
|
| 394 |
-
- **Paraphrase token index**: `-1`
|
| 395 |
-
- **Displayed active features**: `12`
|
| 396 |
-
|
| 397 |
-
Click **Compare paraphrase representations**.
|
| 398 |
-
|
| 399 |
-
Verify:
|
| 400 |
-
|
| 401 |
-
1. **Original prompt tokens** and **Paraphrase tokens** render.
|
| 402 |
-
2. **Robustness metrics** reports two distinct comparisons:
|
| 403 |
-
- selected-token TopK Jaccard and sparse activation cosine;
|
| 404 |
-
- **prompt-wide** max-pooled Jaccard and cosine.
|
| 405 |
-
3. **Top-feature overlap at selected tokens** marks features `shared`, `original only`, or `paraphrase only`.
|
| 406 |
-
4. The prompt-wide metrics remain available even when the two selected final tokens play different syntactic/semantic roles.
|
| 407 |
-
|
| 408 |
-
Bounds:
|
| 409 |
-
|
| 410 |
-
- Jaccards lie in `[0, 1]`;
|
| 411 |
-
- cosines should lie approximately in `[0, 1]` for non-negative SAE activations.
|
| 412 |
-
|
| 413 |
-
Do not impose an arbitrary minimum robustness threshold.
|
| 414 |
-
|
| 415 |
-
### Test J2 — identity sanity check
|
| 416 |
-
|
| 417 |
-
Use the **same exact prompt** in **Original prompt** and **Paraphrase**, with the same token indices and layer.
|
| 418 |
-
|
| 419 |
-
Expected, within numerical precision:
|
| 420 |
|
| 421 |
```text
|
| 422 |
-
|
| 423 |
-
selected-token cosine ≈ 1
|
| 424 |
-
prompt-wide Jaccard ≈ 1
|
| 425 |
-
prompt-wide cosine ≈ 1
|
| 426 |
```
|
| 427 |
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
---
|
| 431 |
-
|
| 432 |
-
## K. Layer trajectory
|
| 433 |
-
|
| 434 |
-
Open **Layer trajectory** and set:
|
| 435 |
|
| 436 |
-
-
|
| 437 |
-
-
|
|
|
|
|
|
|
| 438 |
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
Expected:
|
| 442 |
-
|
| 443 |
-
- **Prompt tokens** renders;
|
| 444 |
-
- **Layer diagnostics** has exactly three rows: `4`, `14`, `26`;
|
| 445 |
-
- **Representation trajectory** contains reconstruction cosine, Top-5 mass, and activation entropy.
|
| 446 |
-
|
| 447 |
-
Do not compare numerical feature IDs across layers. Each residual layer has its own independently learned SAE dictionary.
|
| 448 |
|
| 449 |
---
|
| 450 |
|
| 451 |
-
#
|
| 452 |
-
|
| 453 |
-
These tests are designed to expose software or interpretation failures. They are not meant to produce attractive scientific results.
|
| 454 |
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
In **Workbench**, clear **Prompt** completely and click **Inspect sparse features**.
|
| 458 |
-
|
| 459 |
-
**Pass:** the UI displays a clear error and the Space remains alive. The container must not restart.
|
| 460 |
-
|
| 461 |
-
## Test L2 — out-of-range prompt token index
|
| 462 |
-
|
| 463 |
-
Use the normal mathematics prompt but set **Prompt token index** to `999` and click **Inspect sparse features**.
|
| 464 |
-
|
| 465 |
-
**Pass:** a clear index/range error is shown; the application remains usable afterward.
|
| 466 |
-
|
| 467 |
-
## Test L3 — feature-ID boundaries
|
| 468 |
-
|
| 469 |
-
After a successful Workbench inspection, manually enter the following in **Single feature id**:
|
| 470 |
-
|
| 471 |
-
1. `0` — valid boundary;
|
| 472 |
-
2. `32767` — valid boundary;
|
| 473 |
-
3. `32768` — invalid.
|
| 474 |
-
|
| 475 |
-
Run **Run single-feature causal test** with a short target.
|
| 476 |
-
|
| 477 |
-
**Pass:** IDs 0 and 32767 are accepted; 32768 produces a clear validation error without crashing the Space.
|
| 478 |
-
|
| 479 |
-
## Test L4 — inactive-feature semantics
|
| 480 |
-
|
| 481 |
-
Find a feature that has activation 0 at the selected prompt location. A practical way is to enter a valid feature ID that is not among the active TopK IDs.
|
| 482 |
-
|
| 483 |
-
Set **Single-feature intervention** to `ablate` and run the causal test.
|
| 484 |
-
|
| 485 |
-
Expected:
|
| 486 |
|
| 487 |
```text
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
```
|
| 492 |
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
Then switch to `inject`, set **Additive feature coefficient** to `5`, and run again.
|
| 496 |
-
|
| 497 |
-
Expected: a non-zero residual perturbation is possible because injection intentionally tests the decoder direction even when the native activation is zero.
|
| 498 |
-
|
| 499 |
-
## Test L5 — zero-perturbation random-control handling
|
| 500 |
-
|
| 501 |
-
Continue Test L4 with the inactive feature and `ablate`.
|
| 502 |
-
|
| 503 |
-
**Pass:** the zero targeted perturbation does not create `NaN`, `inf`, or a crash in random-control statistics. JS/effect values should be zero or numerical zero and ratios/p-values must be safely represented.
|
| 504 |
-
|
| 505 |
-
## Test L6 — exact target whitespace
|
| 506 |
-
|
| 507 |
-
For the same prompt and feature, run the target twice:
|
| 508 |
|
| 509 |
-
|
| 510 |
-
2x
|
| 511 |
-
```
|
| 512 |
|
| 513 |
-
|
| 514 |
|
| 515 |
```text
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
```
|
| 518 |
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
**Pass:** FeatureLens reports the actual tokenization of each exact string and scores all returned target tokens. Different tokenizations/results are allowed.
|
| 522 |
-
|
| 523 |
-
## Test L7 — required-target validation
|
| 524 |
-
|
| 525 |
-
Clear the target and try:
|
| 526 |
-
|
| 527 |
-
- **Run scale dose-response**;
|
| 528 |
-
- **Run joint feature-set causal test**;
|
| 529 |
-
- **Run 1/3/5-feature ablation sweep**;
|
| 530 |
-
- **Run individual-vs-joint decomposition**.
|
| 531 |
-
|
| 532 |
-
**Pass:** every experiment that mathematically requires a target gives a clear UI error rather than silently inventing one or failing deep in the model call.
|
| 533 |
-
|
| 534 |
-
The ordinary **Run single-feature causal test** may be used without a target because next-token JS and greedy generation are still defined.
|
| 535 |
-
|
| 536 |
-
## Test L8 — identical paraphrases
|
| 537 |
-
|
| 538 |
-
Repeat Test J2 with the exact same text, same layer, and same token index.
|
| 539 |
-
|
| 540 |
-
**Pass:** all robustness metrics are approximately 1. This is the adversarial regression check for the prompt-wide pooling code.
|
| 541 |
|
| 542 |
-
##
|
|
|
|
| 543 |
|
| 544 |
-
|
|
|
|
| 545 |
|
| 546 |
-
|
| 547 |
-
The derivative of x squared is
|
| 548 |
-
```
|
| 549 |
-
|
| 550 |
-
with:
|
| 551 |
-
|
| 552 |
-
```text
|
| 553 |
-
Je voudrais réserver une table pour demain soir.
|
| 554 |
-
```
|
| 555 |
-
|
| 556 |
-
**Pass:** the app returns finite robustness metrics but does **not** label the prompts as semantically equivalent merely because some features overlap. No hard numerical threshold is required.
|
| 557 |
|
| 558 |
-
##
|
| 559 |
|
| 560 |
-
|
| 561 |
|
| 562 |
```text
|
| 563 |
-
|
|
|
|
|
|
|
| 564 |
```
|
| 565 |
|
| 566 |
-
|
| 567 |
|
| 568 |
-
|
| 569 |
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
|
| 572 |
-
|
| 573 |
|
| 574 |
```text
|
| 575 |
-
|
|
|
|
|
|
|
| 576 |
```
|
| 577 |
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
## Test L12 — long prompt truncation
|
| 581 |
-
|
| 582 |
-
Paste a prompt substantially longer than the configured prompt-token limit (repeat several paragraphs if needed) and click **Inspect sparse features**.
|
| 583 |
-
|
| 584 |
-
**Pass:** the request truncates safely to the configured maximum instead of causing an OOM or index corruption. **Prompt tokens** should reflect the actually processed token sequence.
|
| 585 |
-
|
| 586 |
-
## Test L13 — table clipboard behavior
|
| 587 |
-
|
| 588 |
-
For at least three different output tables, click **Copy table with headers** and paste into:
|
| 589 |
-
|
| 590 |
-
1. a plain-text editor;
|
| 591 |
-
2. a spreadsheet.
|
| 592 |
-
|
| 593 |
-
**Pass:** the first row always contains headers, data are tab-separated, and embedded commas in cells do not shift spreadsheet columns.
|
| 594 |
-
|
| 595 |
-
## Test L14 — rapid double click / queue safety
|
| 596 |
-
|
| 597 |
-
Click one GPU-heavy action twice quickly, for example **Run single-feature causal test**.
|
| 598 |
-
|
| 599 |
-
**Pass:** requests queue under the app's concurrency limit rather than corrupting shared activation hooks or crashing the runtime. Do not repeatedly hammer the button beyond this single queue test because ZeroGPU quota is limited.
|
| 600 |
-
|
| 601 |
-
## Test L15 — responsive Feature Sets form
|
| 602 |
-
|
| 603 |
-
At narrow browser width, open **Feature sets**.
|
| 604 |
-
|
| 605 |
-
Verify the row containing:
|
| 606 |
-
|
| 607 |
-
- **Intervention**;
|
| 608 |
-
- **Multiplier (unused for ablation)** or **Shared feature multiplier**;
|
| 609 |
-
- **Target continuation**
|
| 610 |
-
|
| 611 |
-
wraps cleanly. Switch **Intervention** between `ablate` and `scale`.
|
| 612 |
-
|
| 613 |
-
**Pass:** labels remain attached to the correct fields, no control text runs together, and changing the mode updates the multiplier label without shifting the target field into an ambiguous position.
|
| 614 |
|
| 615 |
---
|
| 616 |
|
| 617 |
-
#
|
| 618 |
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
```bash
|
| 622 |
-
python3 experiments/run_all.py
|
| 623 |
-
```
|
| 624 |
|
| 625 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
|
| 627 |
-
-
|
| 628 |
-
- targeted SAE interventions;
|
| 629 |
-
- configurable norm-matched random-control ensembles (default `8`);
|
| 630 |
-
- full-continuation mean log-probability effects.
|
| 631 |
-
|
| 632 |
-
Expected major artifacts include:
|
| 633 |
-
|
| 634 |
-
```text
|
| 635 |
-
artifacts/feature_catalog.csv
|
| 636 |
-
artifacts/layer_metrics.csv
|
| 637 |
-
artifacts/stability.csv
|
| 638 |
-
artifacts/causal_results.csv
|
| 639 |
-
artifacts/feature_set_results.csv
|
| 640 |
-
artifacts/report.md
|
| 641 |
-
artifacts/figures/
|
| 642 |
-
```
|
| 643 |
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
-
|
| 649 |
-
-
|
| 650 |
-
-
|
| 651 |
-
-
|
| 652 |
-
-
|
| 653 |
-
-
|
| 654 |
-
-
|
| 655 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FeatureLens v0.5.0 Validation
|
| 2 |
|
| 3 |
+
This validation pass is intentionally focused on the **new v0.5 behavior and the core v0.4 regressions it could affect**. The full adversarial/edge-case battery is deferred to the final release, as requested.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
## A. Local release gate
|
| 6 |
|
| 7 |
+
From the repository root:
|
| 8 |
|
| 9 |
```bash
|
| 10 |
python3 -m pytest -q
|
| 11 |
python3 -m compileall -q app.py featurelens experiments scripts
|
| 12 |
+
python3 scripts/ui_smoke.py
|
| 13 |
python3 scripts/release_check.py
|
| 14 |
```
|
| 15 |
|
| 16 |
Expected:
|
| 17 |
|
| 18 |
```text
|
| 19 |
+
38 passed
|
| 20 |
+
FeatureLens UI launch smoke: PASS (...)
|
| 21 |
FeatureLens release check: PASS
|
| 22 |
discovery prompts: 224
|
| 23 |
causal tasks: 28
|
| 24 |
layers: [4, 14, 26]
|
| 25 |
feature-set sizes: [1, 3, 5]
|
| 26 |
random controls: 8
|
| 27 |
+
release: v0.5.0
|
| 28 |
```
|
| 29 |
|
| 30 |
+
The UI smoke is important: it executes the real Gradio `launch()` path, which is where the v0.4 theme-font error occurred.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
---
|
| 33 |
|
| 34 |
## B. Hugging Face startup and layout
|
| 35 |
|
| 36 |
+
After pushing, inspect the container log.
|
| 37 |
|
| 38 |
+
Expected startup:
|
|
|
|
|
|
|
| 39 |
|
| 40 |
```text
|
| 41 |
* Running on local URL: http://0.0.0.0:7860
|
| 42 |
```
|
| 43 |
|
| 44 |
+
There should be no SSR line, un-awaited coroutine warning, or theme/font traceback.
|
| 45 |
|
| 46 |
+
### Layout acceptance
|
|
|
|
|
|
|
| 47 |
|
| 48 |
+
On a desktop-width browser:
|
| 49 |
|
| 50 |
+
- The application should be **centered**, not pinned to the left edge.
|
| 51 |
+
- The usable canvas should expand substantially farther across a wide monitor than v0.4.
|
| 52 |
+
- Explicit action buttons and **Copy table with headers** buttons should use the same muted teal treatment.
|
| 53 |
+
- Body copy, labels, controls, and headings should use a consistent serif-oriented typography system.
|
| 54 |
+
- Tables should use the available width before horizontal scrolling is needed.
|
| 55 |
|
| 56 |
+
The widest research tables may still need horizontal scrolling; that is preferable to wrapping numerical headings into unreadable multi-line columns.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
---
|
| 59 |
|
| 60 |
+
## C. Core Workbench inspection
|
|
|
|
|
|
|
| 61 |
|
| 62 |
+
In **Workbench** use:
|
| 63 |
|
| 64 |
+
```text
|
| 65 |
+
Prompt: The derivative of x squared is
|
| 66 |
+
Residual layer: 14
|
| 67 |
+
Prompt token index: -1
|
| 68 |
+
Displayed active features: 12
|
| 69 |
+
```
|
| 70 |
|
| 71 |
Click **Inspect sparse features**.
|
| 72 |
|
| 73 |
+
Expected regression values should remain approximately:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
```text
|
| 76 |
+
prompt token: 5
|
| 77 |
+
Reconstruction cosine: 0.9015
|
| 78 |
+
NMSE: 0.1874
|
| 79 |
+
Top-5 activation mass: 26.7%
|
| 80 |
+
Top feature: 22632
|
| 81 |
+
Top activation: 32.90625
|
| 82 |
```
|
| 83 |
|
| 84 |
+
The **Strongest active SAE features** table and **Activation profile** should populate.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
+
### Copy feedback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
+
Click **Copy table with headers** below the feature table.
|
| 89 |
|
| 90 |
+
Pass condition:
|
| 91 |
|
| 92 |
+
1. Clipboard content starts with the header row, e.g.
|
| 93 |
+
```text
|
| 94 |
+
Rank<TAB>Feature id<TAB>Activation<TAB>Offline concept hint
|
| 95 |
+
```
|
| 96 |
+
2. The clicked button briefly changes to:
|
| 97 |
+
```text
|
| 98 |
+
✓ Copied with headers
|
| 99 |
+
```
|
| 100 |
+
and then returns to **Copy table with headers**.
|
| 101 |
|
| 102 |
+
---
|
| 103 |
|
| 104 |
+
## D. Dynamic-height / embedded scrolling regression
|
| 105 |
|
| 106 |
+
Still in **Workbench**, select feature `22632` and run:
|
| 107 |
|
| 108 |
+
```text
|
| 109 |
+
Single-feature intervention: ablate
|
| 110 |
+
Target continuation (optional): 2x
|
| 111 |
+
Greedy generation length: 8
|
| 112 |
+
```
|
| 113 |
|
| 114 |
Click **Run single-feature causal test**.
|
| 115 |
|
| 116 |
+
After the result expands, **do not switch tabs**.
|
| 117 |
|
| 118 |
+
Immediately try to scroll to:
|
| 119 |
|
| 120 |
+
- **III. Single-feature scale dose-response**
|
| 121 |
+
- **IV. Contrastive continuation preference**
|
| 122 |
+
- and eventually the bottom of the Workbench tab.
|
| 123 |
|
| 124 |
+
Pass condition: the page continues scrolling after the dynamic result appears. A tab switch should not be required to recover the missing lower portion of the page.
|
|
|
|
|
|
|
| 125 |
|
| 126 |
+
v0.5 installs a browser-side resize/mutation observer specifically to request reflow after dynamic output-height changes. If the page still gets stuck, record whether the failure occurs on the Hugging Face Space page only or also when opening the direct `*.hf.space` app URL.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
---
|
| 129 |
|
| 130 |
+
## E. Scale dose-response numerical-null regression
|
|
|
|
|
|
|
| 131 |
|
| 132 |
+
Open **Scale dose-response** and click **Run scale dose-response** using target continuation `2x`.
|
| 133 |
|
| 134 |
+
The `1.0` row must remain the exact batched numerical reference:
|
|
|
|
|
|
|
| 135 |
|
| 136 |
```text
|
| 137 |
+
Multiplier: 1.0
|
| 138 |
+
Δ feature coefficient: 0.0
|
| 139 |
+
Perturbation L2: 0.0
|
| 140 |
+
Δ mean log p/token: 0.0
|
| 141 |
+
Δ sequence log p: 0.0
|
| 142 |
+
Next-token JS: 0.0
|
| 143 |
```
|
| 144 |
|
| 145 |
+
This is a hard regression requirement.
|
| 146 |
|
| 147 |
+
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
+
## F. Contrastive continuation preference — new v0.5 test
|
| 150 |
|
| 151 |
+
In **Workbench → IV. Contrastive continuation preference**, use:
|
| 152 |
|
| 153 |
```text
|
| 154 |
+
Continuation A (preferred): 2x
|
| 155 |
+
Continuation B (comparison): x
|
| 156 |
+
Contrastive intervention: ablate
|
|
|
|
|
|
|
| 157 |
```
|
| 158 |
|
| 159 |
+
Use feature `22632`, layer `14`, prompt token `5` inherited from the Workbench.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
+
Click **Run contrastive preference test**.
|
| 162 |
|
| 163 |
+
Expected outputs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
+
- **Contrastive continuation scores** with two rows: `A (preferred)` and `B (comparison)`.
|
| 166 |
+
- Exact sequence log-probability and mean log-probability/token for both continuations.
|
| 167 |
+
- Baseline A−B sequence log-odds.
|
| 168 |
+
- SAE-edited A−B sequence log-odds.
|
| 169 |
+
- Causal A−B log-odds shift.
|
| 170 |
+
- 8-control random magnitude mean/std, specificity ratio, and empirical tail p.
|
| 171 |
+
- **Preference between exact continuations** chart.
|
| 172 |
|
| 173 |
+
There is no predetermined scientific sign. The test passes if the quantities are finite and internally coherent.
|
| 174 |
|
| 175 |
+
Interpretation rule: a strong JS shift with a weak contrastive preference shift supports **general distributional influence** more than selective preference control.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
---
|
| 178 |
|
| 179 |
+
## G. Feature-token activation trace — new v0.5 test
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
+
Open **Feature evidence**. The Workbench inspection should have populated:
|
|
|
|
|
|
|
| 182 |
|
| 183 |
```text
|
| 184 |
+
Feature id: 22632
|
| 185 |
+
Residual layer: 14
|
|
|
|
| 186 |
```
|
| 187 |
|
| 188 |
+
Click **Trace feature across prompt tokens**.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
+
For the canonical prompt, the output should contain one row per prompt token. The final selected token (`is`, token index `5`) should reproduce the known feature activation approximately:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
```text
|
| 193 |
+
Feature 22632 at token 5 ≈ 32.90625
|
|
|
|
|
|
|
|
|
|
| 194 |
```
|
| 195 |
|
| 196 |
+
Expected outputs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
+
- active token count;
|
| 199 |
+
- peak token position and activation;
|
| 200 |
+
- **Feature activation by prompt token** table;
|
| 201 |
+
- **Feature activation across prompt tokens** chart.
|
| 202 |
|
| 203 |
+
This test directly reveals whether a feature is localized to one token or distributed across the prompt.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
---
|
| 206 |
|
| 207 |
+
## H. Prompt-wide controlled concept contrast — corrected v0.5 test
|
|
|
|
|
|
|
| 208 |
|
| 209 |
+
In **Feature evidence** use:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
```text
|
| 212 |
+
Feature id: 22632
|
| 213 |
+
Residual layer: 14
|
| 214 |
+
Prompts per concept: 4
|
| 215 |
```
|
| 216 |
|
| 217 |
+
Click **Run controlled concept contrast**.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
+
v0.5 no longer evaluates only the final token. For each sampled prompt it takes the feature's **maximum activation across all non-padding prompt tokens**.
|
|
|
|
|
|
|
| 220 |
|
| 221 |
+
The table columns should be:
|
| 222 |
|
| 223 |
```text
|
| 224 |
+
Concept
|
| 225 |
+
Prompts
|
| 226 |
+
Mean prompt-wide max
|
| 227 |
+
Median prompt-wide max
|
| 228 |
+
Prompt activation rate
|
| 229 |
+
Mean when active
|
| 230 |
+
Max activation
|
| 231 |
```
|
| 232 |
|
| 233 |
+
Two outcomes are valid:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
+
### If at least one sampled prompt activates the feature
|
| 236 |
+
The UI may report a highest-mean concept and a runner-up ratio.
|
| 237 |
|
| 238 |
+
### If all sampled activations remain zero
|
| 239 |
+
The UI must say that the feature was **inactive in every sampled prompt**. It must **not** report `code` (or any other concept) as a winner and must not print an undefined winner ratio.
|
| 240 |
|
| 241 |
+
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
+
## I. Feature-set decoder geometry — new v0.5 test
|
| 244 |
|
| 245 |
+
Open **Feature sets** after the canonical Workbench inspection. Select the top three features:
|
| 246 |
|
| 247 |
```text
|
| 248 |
+
22632
|
| 249 |
+
25695
|
| 250 |
+
16369
|
| 251 |
```
|
| 252 |
|
| 253 |
+
Open **Feature-set decoder geometry** and click **Inspect selected-feature geometry**.
|
| 254 |
|
| 255 |
+
Expected:
|
| 256 |
|
| 257 |
+
- exactly three pair rows (`3 choose 2`);
|
| 258 |
+
- decoder cosine for each pair;
|
| 259 |
+
- mean absolute decoder cosine;
|
| 260 |
+
- maximum absolute decoder cosine;
|
| 261 |
+
- activation-weighted joint-ablation L2;
|
| 262 |
+
- independent-direction root-sum-square L2 reference;
|
| 263 |
+
- geometry ratio.
|
| 264 |
|
| 265 |
+
Interpretation:
|
| 266 |
|
| 267 |
```text
|
| 268 |
+
ratio < 1 -> net geometric cancellation
|
| 269 |
+
ratio ≈ 1 -> approximately independent/orthogonal net norm
|
| 270 |
+
ratio > 1 -> net geometric alignment
|
| 271 |
```
|
| 272 |
|
| 273 |
+
This does not replace the individual-vs-joint causal test; it helps separate decoder geometry from downstream non-linearity.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
---
|
| 276 |
|
| 277 |
+
## J. Existing feature-set and paraphrase regressions
|
| 278 |
|
| 279 |
+
A full rerun is not necessary for every v0.4 panel. Do these quick checks:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
+
1. **Run 1/3/5-feature ablation sweep** still produces rows for `k = 1, 3, 5` and 8-control statistics.
|
| 282 |
+
2. **Run individual-vs-joint decomposition** still produces three individual rows plus one joint row for the selected top three features.
|
| 283 |
+
3. In **Paraphrase robustness**, identical prompt vs identical prompt should still return:
|
| 284 |
+
```text
|
| 285 |
+
selected-token Jaccard = 1.000
|
| 286 |
+
selected-token cosine = 1.000
|
| 287 |
+
prompt-wide Jaccard = 1.000
|
| 288 |
+
prompt-wide cosine = 1.000
|
| 289 |
+
```
|
| 290 |
+
4. **Layer trajectory → Compare layers** still returns layers `4`, `14`, and `26`.
|
| 291 |
|
| 292 |
+
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
+
## Deferred final-release tests
|
| 295 |
+
|
| 296 |
+
The comprehensive adversarial suite remains intentionally deferred until the project is feature-complete. The final release should include, at minimum:
|
| 297 |
+
|
| 298 |
+
- empty prompts/targets;
|
| 299 |
+
- invalid token indices;
|
| 300 |
+
- feature IDs `0`, `32767`, and invalid `32768`;
|
| 301 |
+
- inactive feature ablation/scale/injection semantics;
|
| 302 |
+
- whitespace-sensitive continuations (`2x` vs ` 2x`);
|
| 303 |
+
- unrelated and negated paraphrases;
|
| 304 |
+
- mixed-language prompts;
|
| 305 |
+
- long-prompt truncation;
|
| 306 |
+
- rapid repeated clicks / ZeroGPU queue behavior;
|
| 307 |
+
- narrow/mobile layout;
|
| 308 |
+
- direct Space URL vs Hugging Face embedded-page scrolling.
|
featurelens/metrics.py
CHANGED
|
@@ -117,3 +117,37 @@ def sparse_topk_cosine(
|
|
| 117 |
if norm_a == 0.0 or norm_b == 0.0:
|
| 118 |
return 0.0
|
| 119 |
return float(dot / (norm_a * norm_b))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
if norm_a == 0.0 or norm_b == 0.0:
|
| 118 |
return 0.0
|
| 119 |
return float(dot / (norm_a * norm_b))
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def contrastive_log_odds(
|
| 123 |
+
baseline_a: float,
|
| 124 |
+
modified_a: float,
|
| 125 |
+
baseline_b: float,
|
| 126 |
+
modified_b: float,
|
| 127 |
+
) -> tuple[float, float, float]:
|
| 128 |
+
"""Return baseline A-vs-B log-odds, modified log-odds, and causal shift."""
|
| 129 |
+
baseline = float(baseline_a) - float(baseline_b)
|
| 130 |
+
modified = float(modified_a) - float(modified_b)
|
| 131 |
+
return baseline, modified, modified - baseline
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def decoder_cosine_matrix(directions: torch.Tensor) -> torch.Tensor:
|
| 135 |
+
"""Pairwise cosine matrix for decoder directions shaped ``[features, d_model]``."""
|
| 136 |
+
if directions.ndim != 2 or directions.shape[0] < 1:
|
| 137 |
+
raise ValueError('directions must have shape [features, d_model].')
|
| 138 |
+
values = directions.float()
|
| 139 |
+
norms = torch.linalg.vector_norm(values, dim=1, keepdim=True).clamp_min(1e-12)
|
| 140 |
+
normalized = values / norms
|
| 141 |
+
return normalized @ normalized.T
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def joint_direction_norm_ratio(deltas: torch.Tensor) -> tuple[float, float, float]:
|
| 145 |
+
"""Compare the norm of a summed edit with the root-sum-square independent reference."""
|
| 146 |
+
if deltas.ndim != 2 or deltas.shape[0] < 1:
|
| 147 |
+
raise ValueError('deltas must have shape [features, d_model].')
|
| 148 |
+
values = deltas.float()
|
| 149 |
+
individual_norms = torch.linalg.vector_norm(values, dim=1)
|
| 150 |
+
joint_norm = float(torch.linalg.vector_norm(values.sum(dim=0)).item())
|
| 151 |
+
independent_norm = float(torch.sqrt(torch.sum(individual_norms ** 2)).item())
|
| 152 |
+
ratio = joint_norm / max(independent_norm, 1e-12)
|
| 153 |
+
return joint_norm, independent_norm, float(ratio)
|
featurelens/runtime.py
CHANGED
|
@@ -22,6 +22,9 @@ from .interventions import (
|
|
| 22 |
residual_delta,
|
| 23 |
)
|
| 24 |
from .metrics import (
|
|
|
|
|
|
|
|
|
|
| 25 |
js_divergence_from_logits,
|
| 26 |
reconstruction_metrics,
|
| 27 |
sequence_logprob_summary,
|
|
@@ -169,8 +172,59 @@ class ConceptContrastResult:
|
|
| 169 |
prompts_per_concept: int
|
| 170 |
rows: list[list[object]]
|
| 171 |
chart_rows: list[list[object]]
|
| 172 |
-
leading_concept: str
|
| 173 |
-
leading_ratio: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
|
| 176 |
@dataclass
|
|
@@ -1369,6 +1423,247 @@ class FeatureLensRuntime:
|
|
| 1369 |
selected.extend(by_concept[concept][: int(prompts_per_concept)])
|
| 1370 |
return selected
|
| 1371 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1372 |
@torch.inference_mode()
|
| 1373 |
def concept_contrast_scan(
|
| 1374 |
self,
|
|
@@ -1376,9 +1671,11 @@ class FeatureLensRuntime:
|
|
| 1376 |
layer: int,
|
| 1377 |
prompts_per_concept: int | None = None,
|
| 1378 |
) -> ConceptContrastResult:
|
| 1379 |
-
"""Measure one SAE feature
|
| 1380 |
self.ensure_ready(preload_saes=False)
|
| 1381 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
|
|
|
|
|
|
| 1382 |
n = int(prompts_per_concept or self.settings.contrast_prompts_per_concept)
|
| 1383 |
if n < 1 or n > 8:
|
| 1384 |
raise ValueError('Contrast prompts per concept must be between 1 and 8.')
|
|
@@ -1397,13 +1694,17 @@ class FeatureLensRuntime:
|
|
| 1397 |
capture: dict = {}
|
| 1398 |
with self._capture_hook(int(layer), capture):
|
| 1399 |
self.model(**batch, use_cache=False)
|
| 1400 |
-
|
| 1401 |
-
residuals = capture['hidden'][:, -1, :]
|
| 1402 |
sae = self.sae_store.get(int(layer))
|
| 1403 |
encoding = sae.encode(residuals)
|
| 1404 |
-
|
| 1405 |
-
|
| 1406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1407 |
|
| 1408 |
grouped: dict[str, list[float]] = {}
|
| 1409 |
for row, value in zip(rows, values, strict=True):
|
|
@@ -1413,22 +1714,28 @@ class FeatureLensRuntime:
|
|
| 1413 |
vals = grouped[concept]
|
| 1414 |
tensor = torch.tensor(vals, dtype=torch.float64)
|
| 1415 |
active = [value for value in vals if value > 0]
|
| 1416 |
-
|
| 1417 |
table_rows.append(
|
| 1418 |
[
|
| 1419 |
concept,
|
| 1420 |
len(vals),
|
| 1421 |
float(tensor.mean().item()),
|
| 1422 |
-
median,
|
| 1423 |
float(len(active) / len(vals)),
|
|
|
|
| 1424 |
float(max(vals) if vals else 0.0),
|
| 1425 |
]
|
| 1426 |
)
|
| 1427 |
-
table_rows.sort(key=lambda row: (float(row[2]), float(row[4])), reverse=True)
|
| 1428 |
-
|
| 1429 |
-
|
| 1430 |
-
|
| 1431 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1432 |
chart_rows = [[str(row[0]), float(row[2])] for row in table_rows]
|
| 1433 |
return ConceptContrastResult(
|
| 1434 |
feature_id=int(feature_id),
|
|
@@ -1437,7 +1744,9 @@ class FeatureLensRuntime:
|
|
| 1437 |
rows=table_rows,
|
| 1438 |
chart_rows=chart_rows,
|
| 1439 |
leading_concept=leader,
|
| 1440 |
-
leading_ratio=
|
|
|
|
|
|
|
| 1441 |
)
|
| 1442 |
|
| 1443 |
|
|
|
|
| 22 |
residual_delta,
|
| 23 |
)
|
| 24 |
from .metrics import (
|
| 25 |
+
contrastive_log_odds,
|
| 26 |
+
decoder_cosine_matrix,
|
| 27 |
+
joint_direction_norm_ratio,
|
| 28 |
js_divergence_from_logits,
|
| 29 |
reconstruction_metrics,
|
| 30 |
sequence_logprob_summary,
|
|
|
|
| 172 |
prompts_per_concept: int
|
| 173 |
rows: list[list[object]]
|
| 174 |
chart_rows: list[list[object]]
|
| 175 |
+
leading_concept: str | None
|
| 176 |
+
leading_ratio: float | None
|
| 177 |
+
active_prompt_count: int
|
| 178 |
+
total_prompt_count: int
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@dataclass
|
| 182 |
+
class FeatureTraceResult:
|
| 183 |
+
feature_id: int
|
| 184 |
+
layer: int
|
| 185 |
+
tokens: list[str]
|
| 186 |
+
rows: list[list[object]]
|
| 187 |
+
chart_rows: list[list[object]]
|
| 188 |
+
active_token_count: int
|
| 189 |
+
token_count: int
|
| 190 |
+
max_activation: float
|
| 191 |
+
max_token_index: int | None
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
@dataclass
|
| 195 |
+
class FeatureGeometryResult:
|
| 196 |
+
feature_ids: list[int]
|
| 197 |
+
layer: int
|
| 198 |
+
rows: list[list[object]]
|
| 199 |
+
chart_rows: list[list[object]]
|
| 200 |
+
mean_abs_decoder_cosine: float
|
| 201 |
+
max_abs_decoder_cosine: float
|
| 202 |
+
joint_ablation_norm: float
|
| 203 |
+
independent_norm: float
|
| 204 |
+
alignment_ratio: float
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@dataclass
|
| 208 |
+
class ContrastiveCausalResult:
|
| 209 |
+
feature_id: int
|
| 210 |
+
layer: int
|
| 211 |
+
feature_activation: float
|
| 212 |
+
perturbation_norm: float
|
| 213 |
+
target_a_tokens: list[str]
|
| 214 |
+
target_b_tokens: list[str]
|
| 215 |
+
rows: list[list[object]]
|
| 216 |
+
baseline_log_odds: float
|
| 217 |
+
modified_log_odds: float
|
| 218 |
+
delta_log_odds: float
|
| 219 |
+
baseline_normalized_preference: float
|
| 220 |
+
modified_normalized_preference: float
|
| 221 |
+
delta_normalized_preference: float
|
| 222 |
+
random_signed_mean_delta: float
|
| 223 |
+
random_abs_mean_delta: float
|
| 224 |
+
random_delta_std: float
|
| 225 |
+
specificity_ratio: float
|
| 226 |
+
empirical_p: float
|
| 227 |
+
random_control_count: int
|
| 228 |
|
| 229 |
|
| 230 |
@dataclass
|
|
|
|
| 1423 |
selected.extend(by_concept[concept][: int(prompts_per_concept)])
|
| 1424 |
return selected
|
| 1425 |
|
| 1426 |
+
@torch.inference_mode()
|
| 1427 |
+
def feature_token_trace(
|
| 1428 |
+
self,
|
| 1429 |
+
text: str,
|
| 1430 |
+
layer: int,
|
| 1431 |
+
feature_id: int,
|
| 1432 |
+
) -> FeatureTraceResult:
|
| 1433 |
+
"""Trace one SAE feature across every non-padding token in a prompt."""
|
| 1434 |
+
if not text.strip():
|
| 1435 |
+
raise ValueError('Enter a prompt first.')
|
| 1436 |
+
self.ensure_ready(preload_saes=False)
|
| 1437 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 1438 |
+
if int(layer) not in self.settings.layers:
|
| 1439 |
+
raise ValueError(f'Layer must be one of {self.settings.layers}.')
|
| 1440 |
+
if int(feature_id) < 0 or int(feature_id) >= self.settings.sae_width:
|
| 1441 |
+
raise ValueError(f'Feature id must be in [0, {self.settings.sae_width - 1}].')
|
| 1442 |
+
|
| 1443 |
+
inputs = self._inputs(text)
|
| 1444 |
+
capture: dict = {}
|
| 1445 |
+
with self._capture_hook(int(layer), capture):
|
| 1446 |
+
self.model(**inputs, use_cache=False)
|
| 1447 |
+
hidden = capture['hidden'][0]
|
| 1448 |
+
sae = self.sae_store.get(int(layer))
|
| 1449 |
+
encoding = sae.encode(hidden)
|
| 1450 |
+
mask = encoding.indices == int(feature_id)
|
| 1451 |
+
activations = torch.where(mask, encoding.values, torch.zeros_like(encoding.values)).sum(dim=-1)
|
| 1452 |
+
values = activations.detach().float().cpu().tolist()
|
| 1453 |
+
ids = inputs['input_ids'][0].tolist()
|
| 1454 |
+
tokens = [self.tokenizer.decode([int(token_id)]) for token_id in ids]
|
| 1455 |
+
|
| 1456 |
+
rows: list[list[object]] = []
|
| 1457 |
+
chart_rows: list[list[object]] = []
|
| 1458 |
+
for idx, (token, value) in enumerate(zip(tokens, values, strict=True)):
|
| 1459 |
+
value = float(value)
|
| 1460 |
+
rows.append([idx, repr(token), value, bool(value > 0)])
|
| 1461 |
+
chart_rows.append([f'{idx}: {token if token.strip() else repr(token)}', value])
|
| 1462 |
+
|
| 1463 |
+
active = [idx for idx, value in enumerate(values) if float(value) > 0]
|
| 1464 |
+
if active:
|
| 1465 |
+
max_idx = max(active, key=lambda idx: float(values[idx]))
|
| 1466 |
+
max_value = float(values[max_idx])
|
| 1467 |
+
else:
|
| 1468 |
+
max_idx = None
|
| 1469 |
+
max_value = 0.0
|
| 1470 |
+
return FeatureTraceResult(
|
| 1471 |
+
feature_id=int(feature_id),
|
| 1472 |
+
layer=int(layer),
|
| 1473 |
+
tokens=tokens,
|
| 1474 |
+
rows=rows,
|
| 1475 |
+
chart_rows=chart_rows,
|
| 1476 |
+
active_token_count=len(active),
|
| 1477 |
+
token_count=len(tokens),
|
| 1478 |
+
max_activation=max_value,
|
| 1479 |
+
max_token_index=max_idx,
|
| 1480 |
+
)
|
| 1481 |
+
|
| 1482 |
+
@torch.inference_mode()
|
| 1483 |
+
def feature_geometry(
|
| 1484 |
+
self,
|
| 1485 |
+
text: str,
|
| 1486 |
+
layer: int,
|
| 1487 |
+
token_index: int,
|
| 1488 |
+
feature_ids: Sequence[int],
|
| 1489 |
+
) -> FeatureGeometryResult:
|
| 1490 |
+
"""Inspect pairwise SAE decoder geometry and activation-weighted ablation geometry."""
|
| 1491 |
+
if not text.strip():
|
| 1492 |
+
raise ValueError('Enter a prompt first.')
|
| 1493 |
+
ids = list(dict.fromkeys(int(x) for x in feature_ids))
|
| 1494 |
+
if len(ids) < 2:
|
| 1495 |
+
raise ValueError('Select at least two distinct features for geometry analysis.')
|
| 1496 |
+
if len(ids) > 8:
|
| 1497 |
+
raise ValueError('Geometry analysis supports at most eight features in the live app.')
|
| 1498 |
+
if any(feature_id < 0 or feature_id >= self.settings.sae_width for feature_id in ids):
|
| 1499 |
+
raise ValueError(f'Feature ids must be in [0, {self.settings.sae_width - 1}].')
|
| 1500 |
+
|
| 1501 |
+
self.ensure_ready(preload_saes=False)
|
| 1502 |
+
assert self.model is not None and self.sae_store is not None
|
| 1503 |
+
inputs = self._inputs(text)
|
| 1504 |
+
capture: dict = {}
|
| 1505 |
+
with self._capture_hook(int(layer), capture):
|
| 1506 |
+
self.model(**inputs, use_cache=False)
|
| 1507 |
+
hidden = capture['hidden'][0]
|
| 1508 |
+
idx = self._resolve_index(int(token_index), hidden.shape[0])
|
| 1509 |
+
sae = self.sae_store.get(int(layer))
|
| 1510 |
+
encoding = sae.encode(hidden[idx])
|
| 1511 |
+
activations = [float(encoding.activation_for(feature_id)) for feature_id in ids]
|
| 1512 |
+
directions = torch.stack([sae.decoder_direction(feature_id).float() for feature_id in ids])
|
| 1513 |
+
gram = decoder_cosine_matrix(directions)
|
| 1514 |
+
|
| 1515 |
+
rows: list[list[object]] = []
|
| 1516 |
+
chart_rows: list[list[object]] = []
|
| 1517 |
+
offdiag: list[float] = []
|
| 1518 |
+
for i in range(len(ids)):
|
| 1519 |
+
for j in range(i + 1, len(ids)):
|
| 1520 |
+
cosine = float(gram[i, j].item())
|
| 1521 |
+
offdiag.append(abs(cosine))
|
| 1522 |
+
rows.append([ids[i], ids[j], activations[i], activations[j], cosine])
|
| 1523 |
+
chart_rows.append([f'{ids[i]} ↔ {ids[j]}', cosine])
|
| 1524 |
+
|
| 1525 |
+
individual_deltas = torch.stack(
|
| 1526 |
+
[-float(activation) * direction for activation, direction in zip(activations, directions, strict=True)]
|
| 1527 |
+
)
|
| 1528 |
+
joint_norm, independent_norm, alignment_ratio = joint_direction_norm_ratio(individual_deltas)
|
| 1529 |
+
return FeatureGeometryResult(
|
| 1530 |
+
feature_ids=ids,
|
| 1531 |
+
layer=int(layer),
|
| 1532 |
+
rows=rows,
|
| 1533 |
+
chart_rows=chart_rows,
|
| 1534 |
+
mean_abs_decoder_cosine=float(sum(offdiag) / len(offdiag)) if offdiag else 0.0,
|
| 1535 |
+
max_abs_decoder_cosine=float(max(offdiag)) if offdiag else 0.0,
|
| 1536 |
+
joint_ablation_norm=joint_norm,
|
| 1537 |
+
independent_norm=independent_norm,
|
| 1538 |
+
alignment_ratio=float(alignment_ratio),
|
| 1539 |
+
)
|
| 1540 |
+
|
| 1541 |
+
@torch.inference_mode()
|
| 1542 |
+
def contrastive_intervention(
|
| 1543 |
+
self,
|
| 1544 |
+
text: str,
|
| 1545 |
+
layer: int,
|
| 1546 |
+
token_index: int,
|
| 1547 |
+
feature_id: int,
|
| 1548 |
+
mode: str,
|
| 1549 |
+
coefficient: float,
|
| 1550 |
+
target_a: str,
|
| 1551 |
+
target_b: str,
|
| 1552 |
+
) -> ContrastiveCausalResult:
|
| 1553 |
+
"""Measure whether an SAE edit shifts preference between two exact continuations."""
|
| 1554 |
+
if not text.strip():
|
| 1555 |
+
raise ValueError('Enter a prompt first.')
|
| 1556 |
+
if not target_a:
|
| 1557 |
+
raise ValueError('Enter preferred continuation A.')
|
| 1558 |
+
if not target_b:
|
| 1559 |
+
raise ValueError('Enter comparison continuation B.')
|
| 1560 |
+
if target_a == target_b:
|
| 1561 |
+
raise ValueError('Continuations A and B must be different.')
|
| 1562 |
+
|
| 1563 |
+
self.ensure_ready(preload_saes=False)
|
| 1564 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 1565 |
+
prompt_inputs = self._inputs(text)
|
| 1566 |
+
prompt_len = int(prompt_inputs['input_ids'].shape[1])
|
| 1567 |
+
idx = self._resolve_index(int(token_index), prompt_len)
|
| 1568 |
+
capture: dict = {}
|
| 1569 |
+
with self._capture_hook(int(layer), capture):
|
| 1570 |
+
self.model(**prompt_inputs, use_cache=False)
|
| 1571 |
+
residual = capture['hidden'][0, idx]
|
| 1572 |
+
sae = self.sae_store.get(int(layer))
|
| 1573 |
+
encoding = sae.encode(residual)
|
| 1574 |
+
activation = float(encoding.activation_for(int(feature_id)))
|
| 1575 |
+
delta = residual_delta(
|
| 1576 |
+
sae.decoder_direction(int(feature_id)),
|
| 1577 |
+
activation,
|
| 1578 |
+
InterventionSpec(mode=mode, coefficient=float(coefficient)),
|
| 1579 |
+
)
|
| 1580 |
+
seed = self._control_seed(text, int(layer), str(int(feature_id)), mode, float(coefficient))
|
| 1581 |
+
controls = self._random_controls(delta, seed=seed, count=self.settings.live_random_controls)
|
| 1582 |
+
deltas = torch.stack([torch.zeros_like(delta), delta, *controls], dim=0)
|
| 1583 |
+
|
| 1584 |
+
def score(target_text: str):
|
| 1585 |
+
target_ids = self._target_ids(target_text)
|
| 1586 |
+
scoring_inputs = self._append_target(prompt_inputs, target_ids)
|
| 1587 |
+
repeated = self._repeat_inputs(scoring_inputs, deltas.shape[0])
|
| 1588 |
+
with self._batch_delta_hook(int(layer), idx, deltas):
|
| 1589 |
+
output = self.model(**repeated, use_cache=False)
|
| 1590 |
+
summaries = [
|
| 1591 |
+
sequence_logprob_summary(output.logits[row], prompt_length=prompt_len, target_ids=target_ids)
|
| 1592 |
+
for row in range(output.logits.shape[0])
|
| 1593 |
+
]
|
| 1594 |
+
return target_ids, summaries
|
| 1595 |
+
|
| 1596 |
+
ids_a, scores_a = score(target_a)
|
| 1597 |
+
ids_b, scores_b = score(target_b)
|
| 1598 |
+
base_a_seq, base_a_mean, _ = scores_a[0]
|
| 1599 |
+
edit_a_seq, edit_a_mean, _ = scores_a[1]
|
| 1600 |
+
base_b_seq, base_b_mean, _ = scores_b[0]
|
| 1601 |
+
edit_b_seq, edit_b_mean, _ = scores_b[1]
|
| 1602 |
+
|
| 1603 |
+
baseline_log_odds, modified_log_odds, delta_log_odds = contrastive_log_odds(
|
| 1604 |
+
base_a_seq, edit_a_seq, base_b_seq, edit_b_seq
|
| 1605 |
+
)
|
| 1606 |
+
baseline_norm_pref, modified_norm_pref, delta_norm_pref = contrastive_log_odds(
|
| 1607 |
+
base_a_mean, edit_a_mean, base_b_mean, edit_b_mean
|
| 1608 |
+
)
|
| 1609 |
+
|
| 1610 |
+
random_delta_log_odds: list[float] = []
|
| 1611 |
+
for row in range(2, len(scores_a)):
|
| 1612 |
+
random_a_seq = float(scores_a[row][0])
|
| 1613 |
+
random_b_seq = float(scores_b[row][0])
|
| 1614 |
+
random_delta_log_odds.append(
|
| 1615 |
+
float((random_a_seq - random_b_seq) - baseline_log_odds)
|
| 1616 |
+
)
|
| 1617 |
+
random_signed, random_abs, random_std, empirical_p = self._random_effect_summary(
|
| 1618 |
+
random_delta_log_odds, delta_log_odds
|
| 1619 |
+
)
|
| 1620 |
+
ratio = abs(delta_log_odds) / max(random_abs, 1e-12)
|
| 1621 |
+
rows = [
|
| 1622 |
+
[
|
| 1623 |
+
'A (preferred)',
|
| 1624 |
+
target_a,
|
| 1625 |
+
len(ids_a),
|
| 1626 |
+
float(base_a_seq),
|
| 1627 |
+
float(edit_a_seq),
|
| 1628 |
+
float(edit_a_seq - base_a_seq),
|
| 1629 |
+
float(base_a_mean),
|
| 1630 |
+
float(edit_a_mean),
|
| 1631 |
+
float(edit_a_mean - base_a_mean),
|
| 1632 |
+
],
|
| 1633 |
+
[
|
| 1634 |
+
'B (comparison)',
|
| 1635 |
+
target_b,
|
| 1636 |
+
len(ids_b),
|
| 1637 |
+
float(base_b_seq),
|
| 1638 |
+
float(edit_b_seq),
|
| 1639 |
+
float(edit_b_seq - base_b_seq),
|
| 1640 |
+
float(base_b_mean),
|
| 1641 |
+
float(edit_b_mean),
|
| 1642 |
+
float(edit_b_mean - base_b_mean),
|
| 1643 |
+
],
|
| 1644 |
+
]
|
| 1645 |
+
return ContrastiveCausalResult(
|
| 1646 |
+
feature_id=int(feature_id),
|
| 1647 |
+
layer=int(layer),
|
| 1648 |
+
feature_activation=activation,
|
| 1649 |
+
perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
|
| 1650 |
+
target_a_tokens=[self.tokenizer.decode([int(x)]) for x in ids_a],
|
| 1651 |
+
target_b_tokens=[self.tokenizer.decode([int(x)]) for x in ids_b],
|
| 1652 |
+
rows=rows,
|
| 1653 |
+
baseline_log_odds=baseline_log_odds,
|
| 1654 |
+
modified_log_odds=modified_log_odds,
|
| 1655 |
+
delta_log_odds=delta_log_odds,
|
| 1656 |
+
baseline_normalized_preference=baseline_norm_pref,
|
| 1657 |
+
modified_normalized_preference=modified_norm_pref,
|
| 1658 |
+
delta_normalized_preference=delta_norm_pref,
|
| 1659 |
+
random_signed_mean_delta=random_signed,
|
| 1660 |
+
random_abs_mean_delta=random_abs,
|
| 1661 |
+
random_delta_std=random_std,
|
| 1662 |
+
specificity_ratio=float(ratio),
|
| 1663 |
+
empirical_p=float(empirical_p),
|
| 1664 |
+
random_control_count=len(controls),
|
| 1665 |
+
)
|
| 1666 |
+
|
| 1667 |
@torch.inference_mode()
|
| 1668 |
def concept_contrast_scan(
|
| 1669 |
self,
|
|
|
|
| 1671 |
layer: int,
|
| 1672 |
prompts_per_concept: int | None = None,
|
| 1673 |
) -> ConceptContrastResult:
|
| 1674 |
+
"""Measure one SAE feature using prompt-wide max activation on a balanced concept batch."""
|
| 1675 |
self.ensure_ready(preload_saes=False)
|
| 1676 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 1677 |
+
if int(feature_id) < 0 or int(feature_id) >= self.settings.sae_width:
|
| 1678 |
+
raise ValueError(f'Feature id must be in [0, {self.settings.sae_width - 1}].')
|
| 1679 |
n = int(prompts_per_concept or self.settings.contrast_prompts_per_concept)
|
| 1680 |
if n < 1 or n > 8:
|
| 1681 |
raise ValueError('Contrast prompts per concept must be between 1 and 8.')
|
|
|
|
| 1694 |
capture: dict = {}
|
| 1695 |
with self._capture_hook(int(layer), capture):
|
| 1696 |
self.model(**batch, use_cache=False)
|
| 1697 |
+
residuals = capture['hidden']
|
|
|
|
| 1698 |
sae = self.sae_store.get(int(layer))
|
| 1699 |
encoding = sae.encode(residuals)
|
| 1700 |
+
feature_mask = encoding.indices == int(feature_id)
|
| 1701 |
+
token_activations = torch.where(
|
| 1702 |
+
feature_mask, encoding.values, torch.zeros_like(encoding.values)
|
| 1703 |
+
).sum(dim=-1)
|
| 1704 |
+
attention = batch.get('attention_mask', torch.ones_like(batch['input_ids'])).bool()
|
| 1705 |
+
token_activations = torch.where(attention, token_activations, torch.zeros_like(token_activations))
|
| 1706 |
+
prompt_activations = token_activations.max(dim=1).values
|
| 1707 |
+
values = prompt_activations.detach().float().cpu().tolist()
|
| 1708 |
|
| 1709 |
grouped: dict[str, list[float]] = {}
|
| 1710 |
for row, value in zip(rows, values, strict=True):
|
|
|
|
| 1714 |
vals = grouped[concept]
|
| 1715 |
tensor = torch.tensor(vals, dtype=torch.float64)
|
| 1716 |
active = [value for value in vals if value > 0]
|
| 1717 |
+
positive_mean = float(sum(active) / len(active)) if active else 0.0
|
| 1718 |
table_rows.append(
|
| 1719 |
[
|
| 1720 |
concept,
|
| 1721 |
len(vals),
|
| 1722 |
float(tensor.mean().item()),
|
| 1723 |
+
float(torch.median(tensor).item()),
|
| 1724 |
float(len(active) / len(vals)),
|
| 1725 |
+
positive_mean,
|
| 1726 |
float(max(vals) if vals else 0.0),
|
| 1727 |
]
|
| 1728 |
)
|
| 1729 |
+
table_rows.sort(key=lambda row: (float(row[2]), float(row[4]), float(row[6])), reverse=True)
|
| 1730 |
+
active_prompt_count = sum(1 for value in values if float(value) > 0)
|
| 1731 |
+
if table_rows and float(table_rows[0][2]) > 0:
|
| 1732 |
+
leader: str | None = str(table_rows[0][0])
|
| 1733 |
+
first = float(table_rows[0][2])
|
| 1734 |
+
second = float(table_rows[1][2]) if len(table_rows) > 1 else 0.0
|
| 1735 |
+
ratio: float | None = first / second if second > 0 else None
|
| 1736 |
+
else:
|
| 1737 |
+
leader = None
|
| 1738 |
+
ratio = None
|
| 1739 |
chart_rows = [[str(row[0]), float(row[2])] for row in table_rows]
|
| 1740 |
return ConceptContrastResult(
|
| 1741 |
feature_id=int(feature_id),
|
|
|
|
| 1744 |
rows=table_rows,
|
| 1745 |
chart_rows=chart_rows,
|
| 1746 |
leading_concept=leader,
|
| 1747 |
+
leading_ratio=ratio,
|
| 1748 |
+
active_prompt_count=int(active_prompt_count),
|
| 1749 |
+
total_prompt_count=len(values),
|
| 1750 |
)
|
| 1751 |
|
| 1752 |
|
pyproject.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
[project]
|
| 2 |
name = "featurelens"
|
| 3 |
-
version = "0.
|
| 4 |
description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
|
| 5 |
requires-python = ">=3.10"
|
| 6 |
|
|
|
|
| 1 |
[project]
|
| 2 |
name = "featurelens"
|
| 3 |
+
version = "0.5.0"
|
| 4 |
description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
|
| 5 |
requires-python = ">=3.10"
|
| 6 |
|
research_config.json
CHANGED
|
@@ -31,9 +31,9 @@
|
|
| 31 |
"ablate",
|
| 32 |
"scale_2x"
|
| 33 |
],
|
| 34 |
-
"negative_control": "norm-matched random residual direction",
|
| 35 |
"primary_causal_metric": "full target continuation mean log-probability delta per token (teacher-forced)",
|
| 36 |
-
"live_causal_controls": "norm-matched random residual
|
| 37 |
"dose_response_multipliers": [
|
| 38 |
0.0,
|
| 39 |
0.5,
|
|
@@ -61,7 +61,7 @@
|
|
| 61 |
"ablate",
|
| 62 |
"scale"
|
| 63 |
],
|
| 64 |
-
"feature_set_negative_control": "norm-matched random residual direction",
|
| 65 |
"dose_response_execution": "all scale conditions share the 1x batched no-edit reference",
|
| 66 |
"feature_set_sweep_execution": "zero-edit reference + SAE edits + random-control ensembles evaluated in one batched forward",
|
| 67 |
"live_random_controls": 8,
|
|
@@ -77,5 +77,17 @@
|
|
| 77 |
"copy_tables_with_headers"
|
| 78 |
],
|
| 79 |
"concept_contrast_prompts_per_concept": 4,
|
| 80 |
-
"interaction_feature_limit": 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
|
|
|
| 31 |
"ablate",
|
| 32 |
"scale_2x"
|
| 33 |
],
|
| 34 |
+
"negative_control": "norm-matched random residual direction ensemble",
|
| 35 |
"primary_causal_metric": "full target continuation mean log-probability delta per token (teacher-forced)",
|
| 36 |
+
"live_causal_controls": "8-direction norm-matched random residual ensemble",
|
| 37 |
"dose_response_multipliers": [
|
| 38 |
0.0,
|
| 39 |
0.5,
|
|
|
|
| 61 |
"ablate",
|
| 62 |
"scale"
|
| 63 |
],
|
| 64 |
+
"feature_set_negative_control": "norm-matched random residual direction ensemble",
|
| 65 |
"dose_response_execution": "all scale conditions share the 1x batched no-edit reference",
|
| 66 |
"feature_set_sweep_execution": "zero-edit reference + SAE edits + random-control ensembles evaluated in one batched forward",
|
| 67 |
"live_random_controls": 8,
|
|
|
|
| 77 |
"copy_tables_with_headers"
|
| 78 |
],
|
| 79 |
"concept_contrast_prompts_per_concept": 4,
|
| 80 |
+
"interaction_feature_limit": 5,
|
| 81 |
+
"concept_contrast_pooling": "max activation across non-padding prompt tokens",
|
| 82 |
+
"live_geometry_feature_limit": 8,
|
| 83 |
+
"contrastive_preference_metric": "change in exact-sequence log-odds between two user-specified continuations",
|
| 84 |
+
"live_features_v0_5": [
|
| 85 |
+
"wide_centered_responsive_layout",
|
| 86 |
+
"copy_feedback",
|
| 87 |
+
"dynamic_height_reflow_observer",
|
| 88 |
+
"promptwide_concept_contrast_scan",
|
| 89 |
+
"feature_token_activation_trace",
|
| 90 |
+
"contrastive_continuation_preference_test",
|
| 91 |
+
"feature_decoder_geometry"
|
| 92 |
+
]
|
| 93 |
}
|
scripts/release_check.py
CHANGED
|
@@ -25,6 +25,7 @@ REQUIRED = [
|
|
| 25 |
'data/prompts.jsonl',
|
| 26 |
'data/causal_tasks.jsonl',
|
| 27 |
'docs/VALIDATION.md',
|
|
|
|
| 28 |
]
|
| 29 |
|
| 30 |
|
|
@@ -81,6 +82,8 @@ def check_config(config: dict) -> None:
|
|
| 81 |
'offline_random_controls_default': 8,
|
| 82 |
'concept_contrast_prompts_per_concept': 4,
|
| 83 |
'interaction_feature_limit': 5,
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
for key, value in expected.items():
|
| 86 |
if config.get(key) != value:
|
|
@@ -101,6 +104,22 @@ def check_config(config: dict) -> None:
|
|
| 101 |
f'{sorted(actual_live_v04)}'
|
| 102 |
)
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
def check_datasets(config: dict) -> tuple[list[dict], list[dict]]:
|
| 106 |
prompts = load_jsonl(ROOT / 'data' / 'prompts.jsonl')
|
|
@@ -161,16 +180,19 @@ def check_readme() -> None:
|
|
| 161 |
'batched zero-edit',
|
| 162 |
'concept contrast',
|
| 163 |
'non-additivity',
|
|
|
|
|
|
|
|
|
|
| 164 |
]
|
| 165 |
missing = [value for value in required_strings if value.lower() not in readme.lower()]
|
| 166 |
if missing:
|
| 167 |
-
raise SystemExit(f'README.md is missing required v0.
|
| 168 |
|
| 169 |
|
| 170 |
def check_pyproject() -> None:
|
| 171 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 172 |
-
if 'version = "0.
|
| 173 |
-
raise SystemExit('pyproject.toml must declare version 0.
|
| 174 |
|
| 175 |
|
| 176 |
def main() -> None:
|
|
@@ -188,7 +210,7 @@ def main() -> None:
|
|
| 188 |
print(f' layers: {config["layers"]}')
|
| 189 |
print(f' feature-set sizes: {config["feature_set_sizes"]}')
|
| 190 |
print(f' random controls: {config["live_random_controls"]}')
|
| 191 |
-
print(' release: v0.
|
| 192 |
|
| 193 |
|
| 194 |
if __name__ == '__main__':
|
|
|
|
| 25 |
'data/prompts.jsonl',
|
| 26 |
'data/causal_tasks.jsonl',
|
| 27 |
'docs/VALIDATION.md',
|
| 28 |
+
'scripts/ui_smoke.py',
|
| 29 |
]
|
| 30 |
|
| 31 |
|
|
|
|
| 82 |
'offline_random_controls_default': 8,
|
| 83 |
'concept_contrast_prompts_per_concept': 4,
|
| 84 |
'interaction_feature_limit': 5,
|
| 85 |
+
'live_geometry_feature_limit': 8,
|
| 86 |
+
'concept_contrast_pooling': 'max activation across non-padding prompt tokens',
|
| 87 |
}
|
| 88 |
for key, value in expected.items():
|
| 89 |
if config.get(key) != value:
|
|
|
|
| 104 |
f'{sorted(actual_live_v04)}'
|
| 105 |
)
|
| 106 |
|
| 107 |
+
required_live_v05 = {
|
| 108 |
+
'wide_centered_responsive_layout',
|
| 109 |
+
'copy_feedback',
|
| 110 |
+
'dynamic_height_reflow_observer',
|
| 111 |
+
'promptwide_concept_contrast_scan',
|
| 112 |
+
'feature_token_activation_trace',
|
| 113 |
+
'contrastive_continuation_preference_test',
|
| 114 |
+
'feature_decoder_geometry',
|
| 115 |
+
}
|
| 116 |
+
actual_live_v05 = set(config.get('live_features_v0_5', []))
|
| 117 |
+
if actual_live_v05 != required_live_v05:
|
| 118 |
+
raise SystemExit(
|
| 119 |
+
'research_config.json live_features_v0_5 mismatch: '
|
| 120 |
+
f'{sorted(actual_live_v05)}'
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
|
| 124 |
def check_datasets(config: dict) -> tuple[list[dict], list[dict]]:
|
| 125 |
prompts = load_jsonl(ROOT / 'data' / 'prompts.jsonl')
|
|
|
|
| 180 |
'batched zero-edit',
|
| 181 |
'concept contrast',
|
| 182 |
'non-additivity',
|
| 183 |
+
'contrastive',
|
| 184 |
+
'decoder geometry',
|
| 185 |
+
'token activation',
|
| 186 |
]
|
| 187 |
missing = [value for value in required_strings if value.lower() not in readme.lower()]
|
| 188 |
if missing:
|
| 189 |
+
raise SystemExit(f'README.md is missing required v0.5 content: {missing}')
|
| 190 |
|
| 191 |
|
| 192 |
def check_pyproject() -> None:
|
| 193 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 194 |
+
if 'version = "0.5.0"' not in text:
|
| 195 |
+
raise SystemExit('pyproject.toml must declare version 0.5.0.')
|
| 196 |
|
| 197 |
|
| 198 |
def main() -> None:
|
|
|
|
| 210 |
print(f' layers: {config["layers"]}')
|
| 211 |
print(f' feature-set sizes: {config["feature_set_sizes"]}')
|
| 212 |
print(f' random controls: {config["live_random_controls"]}')
|
| 213 |
+
print(' release: v0.5.0')
|
| 214 |
|
| 215 |
|
| 216 |
if __name__ == '__main__':
|
scripts/ui_smoke.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import socket
|
| 5 |
+
import sys
|
| 6 |
+
import types
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
+
if str(ROOT) not in sys.path:
|
| 11 |
+
sys.path.insert(0, str(ROOT))
|
| 12 |
+
|
| 13 |
+
os.environ.setdefault('FEATURELENS_EAGER_LOAD', '0')
|
| 14 |
+
|
| 15 |
+
# The UI smoke validates Gradio construction + launch and intentionally does not
|
| 16 |
+
# load Qwen weights. Allow it to run in a lightweight developer environment
|
| 17 |
+
# where Transformers itself is absent; normal application execution still
|
| 18 |
+
# requires requirements.txt.
|
| 19 |
+
try:
|
| 20 |
+
import transformers # noqa: F401
|
| 21 |
+
except ModuleNotFoundError:
|
| 22 |
+
stub = types.ModuleType('transformers')
|
| 23 |
+
|
| 24 |
+
class _UnavailableAutoClass:
|
| 25 |
+
@classmethod
|
| 26 |
+
def from_pretrained(cls, *_args, **_kwargs): # pragma: no cover - safety guard
|
| 27 |
+
raise RuntimeError(
|
| 28 |
+
'Transformers is not installed. Install requirements.txt before model inference.'
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
stub.AutoModelForCausalLM = _UnavailableAutoClass
|
| 32 |
+
stub.AutoTokenizer = _UnavailableAutoClass
|
| 33 |
+
sys.modules['transformers'] = stub
|
| 34 |
+
|
| 35 |
+
from app import CSS, THEME, demo # noqa: E402
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _free_port() -> int:
|
| 39 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
| 40 |
+
sock.bind(('127.0.0.1', 0))
|
| 41 |
+
return int(sock.getsockname()[1])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main() -> None:
|
| 45 |
+
port = _free_port()
|
| 46 |
+
demo.launch(
|
| 47 |
+
css=CSS,
|
| 48 |
+
theme=THEME,
|
| 49 |
+
ssr_mode=False,
|
| 50 |
+
prevent_thread_lock=True,
|
| 51 |
+
server_name='127.0.0.1',
|
| 52 |
+
server_port=port,
|
| 53 |
+
show_error=True,
|
| 54 |
+
)
|
| 55 |
+
print(f'FeatureLens UI launch smoke: PASS (port {port})')
|
| 56 |
+
demo.close()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == '__main__':
|
| 60 |
+
main()
|
tests/test_live_runtime_helpers.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
import sys
|
| 5 |
+
import types
|
| 6 |
+
from types import SimpleNamespace
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
import transformers # noqa: F401
|
| 12 |
+
except ModuleNotFoundError:
|
| 13 |
+
stub = types.ModuleType('transformers')
|
| 14 |
+
stub.AutoModelForCausalLM = type('AutoModelForCausalLM', (), {})
|
| 15 |
+
stub.AutoTokenizer = type('AutoTokenizer', (), {})
|
| 16 |
+
sys.modules['transformers'] = stub
|
| 17 |
+
|
| 18 |
+
from featurelens.config import Settings
|
| 19 |
+
from featurelens.runtime import FeatureLensRuntime
|
| 20 |
+
from featurelens.sae import SAEWeights
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FakeTokenizer:
|
| 24 |
+
pad_token_id = 0
|
| 25 |
+
eos_token_id = 0
|
| 26 |
+
eos_token = '<pad>'
|
| 27 |
+
pad_token = '<pad>'
|
| 28 |
+
padding_side = 'left'
|
| 29 |
+
|
| 30 |
+
@staticmethod
|
| 31 |
+
def _ids(text: str) -> list[int]:
|
| 32 |
+
# Keep 0 reserved for padding; make deterministic small-vocabulary ids.
|
| 33 |
+
return [1 + (ord(char) % 7) for char in text] or [1]
|
| 34 |
+
|
| 35 |
+
def __call__(
|
| 36 |
+
self,
|
| 37 |
+
text,
|
| 38 |
+
*,
|
| 39 |
+
return_tensors=None,
|
| 40 |
+
padding=False,
|
| 41 |
+
truncation=False,
|
| 42 |
+
max_length=None,
|
| 43 |
+
add_special_tokens=True,
|
| 44 |
+
):
|
| 45 |
+
if isinstance(text, str):
|
| 46 |
+
ids = self._ids(text)
|
| 47 |
+
if max_length is not None:
|
| 48 |
+
ids = ids[-int(max_length) :]
|
| 49 |
+
if return_tensors == 'pt':
|
| 50 |
+
tensor = torch.tensor([ids], dtype=torch.long)
|
| 51 |
+
return {'input_ids': tensor, 'attention_mask': torch.ones_like(tensor)}
|
| 52 |
+
return {'input_ids': ids}
|
| 53 |
+
|
| 54 |
+
sequences = [self._ids(item) for item in text]
|
| 55 |
+
if max_length is not None:
|
| 56 |
+
sequences = [ids[-int(max_length) :] for ids in sequences]
|
| 57 |
+
width = max(len(ids) for ids in sequences)
|
| 58 |
+
padded = []
|
| 59 |
+
masks = []
|
| 60 |
+
for ids in sequences:
|
| 61 |
+
pad = width - len(ids)
|
| 62 |
+
padded.append([0] * pad + ids)
|
| 63 |
+
masks.append([0] * pad + [1] * len(ids))
|
| 64 |
+
return {
|
| 65 |
+
'input_ids': torch.tensor(padded, dtype=torch.long),
|
| 66 |
+
'attention_mask': torch.tensor(masks, dtype=torch.long),
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
def decode(self, ids) -> str:
|
| 70 |
+
token_id = int(ids[0])
|
| 71 |
+
return '<pad>' if token_id == 0 else f't{token_id}'
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class FakeBackbone(torch.nn.Module):
|
| 75 |
+
def __init__(self, d_model: int) -> None:
|
| 76 |
+
super().__init__()
|
| 77 |
+
self.layers = torch.nn.ModuleList([torch.nn.Identity()])
|
| 78 |
+
self.embedding = torch.nn.Embedding(8, d_model)
|
| 79 |
+
with torch.no_grad():
|
| 80 |
+
self.embedding.weight.copy_(
|
| 81 |
+
torch.tensor(
|
| 82 |
+
[
|
| 83 |
+
[0.0, 0.0, 0.0],
|
| 84 |
+
[1.0, 0.2, 0.1],
|
| 85 |
+
[0.8, 0.3, 0.2],
|
| 86 |
+
[0.6, 0.4, 0.3],
|
| 87 |
+
[0.4, 0.5, 0.4],
|
| 88 |
+
[0.3, 0.6, 0.5],
|
| 89 |
+
[0.2, 0.7, 0.6],
|
| 90 |
+
[0.1, 0.8, 0.7],
|
| 91 |
+
]
|
| 92 |
+
)
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class FakeLM(torch.nn.Module):
|
| 97 |
+
def __init__(self) -> None:
|
| 98 |
+
super().__init__()
|
| 99 |
+
self.model = FakeBackbone(d_model=3)
|
| 100 |
+
self.proj = torch.nn.Linear(3, 8, bias=False)
|
| 101 |
+
with torch.no_grad():
|
| 102 |
+
self.proj.weight.copy_(
|
| 103 |
+
torch.tensor(
|
| 104 |
+
[
|
| 105 |
+
[0.0, 0.0, 0.0],
|
| 106 |
+
[1.0, 0.0, 0.0],
|
| 107 |
+
[0.0, 1.0, 0.0],
|
| 108 |
+
[0.0, 0.0, 1.0],
|
| 109 |
+
[0.5, 0.5, 0.0],
|
| 110 |
+
[0.5, 0.0, 0.5],
|
| 111 |
+
[0.0, 0.5, 0.5],
|
| 112 |
+
[-0.4, 0.3, 0.2],
|
| 113 |
+
]
|
| 114 |
+
)
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def forward(self, input_ids, attention_mask=None, use_cache=False):
|
| 118 |
+
hidden = self.model.embedding(input_ids)
|
| 119 |
+
for layer in self.model.layers:
|
| 120 |
+
hidden = layer(hidden)
|
| 121 |
+
return SimpleNamespace(logits=self.proj(hidden))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class FakeSAEStore:
|
| 125 |
+
def __init__(self) -> None:
|
| 126 |
+
self.sae = SAEWeights(
|
| 127 |
+
layer=0,
|
| 128 |
+
w_enc_t=torch.tensor(
|
| 129 |
+
[
|
| 130 |
+
[1.0, 0.0, 0.3, -0.2],
|
| 131 |
+
[0.0, 1.0, 0.2, 0.1],
|
| 132 |
+
[0.0, 0.0, 1.0, 0.4],
|
| 133 |
+
]
|
| 134 |
+
),
|
| 135 |
+
w_dec=torch.tensor(
|
| 136 |
+
[
|
| 137 |
+
[1.0, 0.0, 0.5, -0.2],
|
| 138 |
+
[0.0, 1.0, 0.2, 0.3],
|
| 139 |
+
[0.0, 0.0, 1.0, 0.4],
|
| 140 |
+
]
|
| 141 |
+
),
|
| 142 |
+
b_enc=torch.zeros(4),
|
| 143 |
+
b_dec=torch.zeros(3),
|
| 144 |
+
top_k=2,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
def get(self, layer: int):
|
| 148 |
+
assert layer == 0
|
| 149 |
+
return self.sae
|
| 150 |
+
|
| 151 |
+
def preload(self) -> None:
|
| 152 |
+
return None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def make_runtime() -> FeatureLensRuntime:
|
| 156 |
+
settings = Settings(
|
| 157 |
+
layers=(0,),
|
| 158 |
+
sae_top_k=2,
|
| 159 |
+
sae_width=4,
|
| 160 |
+
d_model=3,
|
| 161 |
+
max_prompt_tokens=32,
|
| 162 |
+
live_random_controls=3,
|
| 163 |
+
contrast_prompts_per_concept=2,
|
| 164 |
+
eager_load=False,
|
| 165 |
+
sae_dtype='float32',
|
| 166 |
+
)
|
| 167 |
+
runtime = FeatureLensRuntime(settings)
|
| 168 |
+
runtime.device = torch.device('cpu')
|
| 169 |
+
runtime.model = FakeLM().eval()
|
| 170 |
+
runtime.tokenizer = FakeTokenizer()
|
| 171 |
+
runtime.sae_store = FakeSAEStore()
|
| 172 |
+
return runtime
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_feature_token_trace_runs_end_to_end_on_toy_runtime() -> None:
|
| 176 |
+
runtime = make_runtime()
|
| 177 |
+
result = runtime.feature_token_trace('abc', layer=0, feature_id=0)
|
| 178 |
+
assert result.token_count == 3
|
| 179 |
+
assert len(result.rows) == 3
|
| 180 |
+
assert result.active_token_count >= 1
|
| 181 |
+
assert result.max_activation > 0
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_feature_geometry_runs_end_to_end_on_toy_runtime() -> None:
|
| 185 |
+
runtime = make_runtime()
|
| 186 |
+
result = runtime.feature_geometry('abc', layer=0, token_index=-1, feature_ids=[0, 1, 2])
|
| 187 |
+
assert len(result.rows) == 3 # 3 choose 2
|
| 188 |
+
assert math.isfinite(result.alignment_ratio)
|
| 189 |
+
assert result.independent_norm >= 0
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_contrastive_intervention_runs_end_to_end_on_toy_runtime() -> None:
|
| 193 |
+
runtime = make_runtime()
|
| 194 |
+
result = runtime.contrastive_intervention(
|
| 195 |
+
text='abc',
|
| 196 |
+
layer=0,
|
| 197 |
+
token_index=-1,
|
| 198 |
+
feature_id=0,
|
| 199 |
+
mode='ablate',
|
| 200 |
+
coefficient=0.0,
|
| 201 |
+
target_a='a',
|
| 202 |
+
target_b='b',
|
| 203 |
+
)
|
| 204 |
+
assert len(result.rows) == 2
|
| 205 |
+
assert result.random_control_count == 3
|
| 206 |
+
assert math.isfinite(result.delta_log_odds)
|
| 207 |
+
assert math.isfinite(result.specificity_ratio)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_concept_contrast_promptwide_scan_runs_on_toy_runtime() -> None:
|
| 211 |
+
runtime = make_runtime()
|
| 212 |
+
result = runtime.concept_contrast_scan(feature_id=0, layer=0, prompts_per_concept=1)
|
| 213 |
+
assert result.total_prompt_count == 7
|
| 214 |
+
assert len(result.rows) == 7
|
| 215 |
+
assert all(len(row) == 7 for row in result.rows)
|
| 216 |
+
assert 0 <= result.active_prompt_count <= result.total_prompt_count
|
tests/test_metrics.py
CHANGED
|
@@ -75,3 +75,40 @@ def test_sparse_topk_cosine_is_one_for_identical_sparse_vectors() -> None:
|
|
| 75 |
def test_sparse_topk_cosine_is_zero_for_disjoint_support() -> None:
|
| 76 |
cosine = sparse_topk_cosine([1, 3], [2.0, 1.0], [2, 4], [5.0, 7.0])
|
| 77 |
assert cosine == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
def test_sparse_topk_cosine_is_zero_for_disjoint_support() -> None:
|
| 76 |
cosine = sparse_topk_cosine([1, 3], [2.0, 1.0], [2, 4], [5.0, 7.0])
|
| 77 |
assert cosine == 0.0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_contrastive_log_odds_reports_preference_shift() -> None:
|
| 81 |
+
from featurelens.metrics import contrastive_log_odds
|
| 82 |
+
|
| 83 |
+
baseline, modified, delta = contrastive_log_odds(
|
| 84 |
+
baseline_a=-4.0,
|
| 85 |
+
modified_a=-3.5,
|
| 86 |
+
baseline_b=-2.0,
|
| 87 |
+
modified_b=-2.2,
|
| 88 |
+
)
|
| 89 |
+
assert math.isclose(baseline, -2.0)
|
| 90 |
+
assert math.isclose(modified, -1.3)
|
| 91 |
+
assert math.isclose(delta, 0.7)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_decoder_cosine_matrix_and_joint_norm_ratio() -> None:
|
| 95 |
+
from featurelens.metrics import decoder_cosine_matrix, joint_direction_norm_ratio
|
| 96 |
+
|
| 97 |
+
orthogonal = torch.tensor([[1.0, 0.0], [0.0, 2.0]])
|
| 98 |
+
matrix = decoder_cosine_matrix(orthogonal)
|
| 99 |
+
assert torch.allclose(matrix, torch.eye(2), atol=1e-6)
|
| 100 |
+
|
| 101 |
+
joint_norm, independent_norm, ratio = joint_direction_norm_ratio(orthogonal)
|
| 102 |
+
assert math.isclose(joint_norm, math.sqrt(5.0), rel_tol=1e-6)
|
| 103 |
+
assert math.isclose(independent_norm, math.sqrt(5.0), rel_tol=1e-6)
|
| 104 |
+
assert math.isclose(ratio, 1.0, rel_tol=1e-6)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_joint_norm_ratio_detects_cancellation() -> None:
|
| 108 |
+
from featurelens.metrics import joint_direction_norm_ratio
|
| 109 |
+
|
| 110 |
+
opposing = torch.tensor([[1.0, 0.0], [-1.0, 0.0]])
|
| 111 |
+
joint_norm, independent_norm, ratio = joint_direction_norm_ratio(opposing)
|
| 112 |
+
assert math.isclose(joint_norm, 0.0, abs_tol=1e-8)
|
| 113 |
+
assert independent_norm > 0
|
| 114 |
+
assert math.isclose(ratio, 0.0, abs_tol=1e-8)
|
tests/test_ui_helpers.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
import types
|
| 5 |
+
from types import SimpleNamespace
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _import_app():
|
| 11 |
+
try:
|
| 12 |
+
import transformers # noqa: F401
|
| 13 |
+
except ModuleNotFoundError:
|
| 14 |
+
stub = types.ModuleType('transformers')
|
| 15 |
+
stub.AutoModelForCausalLM = type('AutoModelForCausalLM', (), {})
|
| 16 |
+
stub.AutoTokenizer = type('AutoTokenizer', (), {})
|
| 17 |
+
sys.modules['transformers'] = stub
|
| 18 |
+
import app
|
| 19 |
+
|
| 20 |
+
return app
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_concept_metrics_do_not_invent_a_leader_for_all_zero_scan() -> None:
|
| 24 |
+
app = _import_app()
|
| 25 |
+
result = SimpleNamespace(
|
| 26 |
+
feature_id=22632,
|
| 27 |
+
layer=14,
|
| 28 |
+
prompts_per_concept=4,
|
| 29 |
+
active_prompt_count=0,
|
| 30 |
+
total_prompt_count=28,
|
| 31 |
+
leading_concept=None,
|
| 32 |
+
leading_ratio=None,
|
| 33 |
+
)
|
| 34 |
+
text = app._concept_metrics_markdown(result)
|
| 35 |
+
assert 'inactive in every sampled prompt' in text
|
| 36 |
+
assert 'Highest prompt-wide mean activation' not in text
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_tsv_copy_payload_keeps_headers() -> None:
|
| 40 |
+
app = _import_app()
|
| 41 |
+
frame = pd.DataFrame([[1, 2.0]], columns=['Feature id', 'Activation'])
|
| 42 |
+
payload = app._tsv(frame)
|
| 43 |
+
assert payload.startswith('Feature id\tActivation\n')
|
| 44 |
+
assert payload.endswith('1\t2.0\n')
|