Spaces:
Running on Zero
Running on Zero
Commit ·
80cf7fc
1
Parent(s): ff63ba1
Release FeatureLens v0.11.0
Browse files- CHANGELOG.md +10 -0
- README.md +21 -11
- app.py +415 -2
- docs/METHODOLOGY.md +69 -0
- docs/VALIDATION.md +87 -106
- featurelens/runtime.py +213 -0
- pyproject.toml +1 -1
- research_config.json +16 -1
- scripts/release_check.py +36 -4
- tests/test_live_runtime_helpers.py +21 -0
- tests/test_ui_helpers.py +75 -0
CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
| 1 |
# Changelog
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## v0.10.0
|
| 4 |
|
| 5 |
- Added a zero-extra-GPU **Discovery–causality alignment** panel after candidate triage.
|
|
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
+
## v0.11.0
|
| 4 |
+
|
| 5 |
+
- Added **Controlled candidate specificity**, a one-batch follow-up that compares up to three candidate SAE ablations against each candidate's own 8-direction norm-matched random ensemble.
|
| 6 |
+
- Added a strategic controlled shortlist that preserves the discovery leader, target-effect leader, and distribution-shift leader when they differ, then fills remaining slots by triage target rank.
|
| 7 |
+
- Added target-specificity and JS-specificity ratios plus coarse empirical random-control tails for every controlled candidate.
|
| 8 |
+
- Added **Association vs controlled causality**, joining concept-discovery evidence to random-normalized causal specificity instead of relying only on raw triage magnitude.
|
| 9 |
+
- Kept target-specific and whole-distribution causal influence separate rather than collapsing them into one score.
|
| 10 |
+
- Kept the validated in-place focus/zoom implementation unchanged.
|
| 11 |
+
- Reduced HF acceptance to one new GPU call; unchanged discovery/triage and other regression paths remain covered by automated tests.
|
| 12 |
+
|
| 13 |
## v0.10.0
|
| 14 |
|
| 15 |
- Added a zero-extra-GPU **Discovery–causality alignment** panel after candidate triage.
|
README.md
CHANGED
|
@@ -13,7 +13,7 @@ license: mit
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
-
> **v0.
|
| 17 |
|
| 18 |
**Research question:**
|
| 19 |
|
|
@@ -30,14 +30,15 @@ FeatureLens uses **Qwen3-1.7B-Base** with **Qwen-Scope residual-stream sparse au
|
|
| 30 |
4. **Candidate discovery** — which features are more active for a chosen controlled concept than for the other groups?
|
| 31 |
5. **Controlled contrast** — does a selected feature prefer one concept group in a balanced live mini-batch?
|
| 32 |
6. **Cue sensitivity** — is a feature responding to a lexical/completion cue rather than the underlying concept?
|
| 33 |
-
7. **
|
| 34 |
-
8. **
|
| 35 |
-
9. **
|
| 36 |
-
10. **
|
| 37 |
-
11. **
|
| 38 |
-
12. **
|
| 39 |
-
13. **
|
| 40 |
-
14. **
|
|
|
|
| 41 |
|
| 42 |
A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
|
| 43 |
|
|
@@ -358,6 +359,15 @@ FeatureLens/
|
|
| 358 |
- **Association-vs-target-effect scatter** is generated from the same two existing GPU calls; the synthesis itself costs no additional GPU time.
|
| 359 |
- Existing v0.9 causal-ready discovery, candidate triage, in-place focus, and GPU-budget-aware validation remain unchanged.
|
| 360 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
## Validation
|
| 362 |
|
| 363 |
```bash
|
|
@@ -368,7 +378,7 @@ python scripts/ui_smoke.py
|
|
| 368 |
python scripts/release_check.py
|
| 369 |
```
|
| 370 |
|
| 371 |
-
See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.
|
| 372 |
|
| 373 |
## Limitations
|
| 374 |
|
|
@@ -384,7 +394,7 @@ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.10 validation guide uses
|
|
| 384 |
## Resume-ready description
|
| 385 |
|
| 386 |
> **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
|
| 387 |
-
> Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, concept-guided candidate discovery, token/prompt-wide, completion-cue, and cue × context feature evidence, reconstruction-preserving single and multi-feature interventions, full-continuation and contrastive preference scoring, dose-response analysis, decoder-geometry/non-additivity diagnostics, discovery-to-causality rank analysis, and norm-matched random-control ensembles.
|
| 388 |
|
| 389 |
## Acknowledgements
|
| 390 |
|
|
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
+
> **v0.11:** a controlled association-to-causality workflow that follows cheap candidate triage with per-feature norm-matched random ensembles, separating raw target effect from random-normalized causal specificity.
|
| 17 |
|
| 18 |
**Research question:**
|
| 19 |
|
|
|
|
| 30 |
4. **Candidate discovery** — which features are more active for a chosen controlled concept than for the other groups?
|
| 31 |
5. **Controlled contrast** — does a selected feature prefer one concept group in a balanced live mini-batch?
|
| 32 |
6. **Cue sensitivity** — is a feature responding to a lexical/completion cue rather than the underlying concept?
|
| 33 |
+
7. **Candidate causal specificity** — do shortlisted candidate ablations exceed their own norm-matched random perturbation ensembles?
|
| 34 |
+
8. **Single-feature causality** — does ablation/scaling change downstream probability?
|
| 35 |
+
9. **Dose-response** — does effect size vary coherently with feature coefficient?
|
| 36 |
+
10. **Feature-set causality** — do small sparse subspaces matter more than one feature?
|
| 37 |
+
11. **Non-additivity** — does a joint edit differ from the sum of individual effects?
|
| 38 |
+
12. **Specificity** — are targeted effects larger than norm-matched random residual perturbations?
|
| 39 |
+
13. **Contrastive specificity** — does an edit change preference between a desired and comparison continuation?
|
| 40 |
+
14. **Geometry** — are selected SAE decoder directions aligned, orthogonal, or cancelling before downstream model non-linearity?
|
| 41 |
+
15. **Association–causality concordance** — do features ranked highly by concept evidence also rank highly by downstream target effect or distribution shift?
|
| 42 |
|
| 43 |
A high AUROC, high paraphrase overlap, or a large activation is still correlational evidence. Causal evidence comes from downstream change under controlled intervention.
|
| 44 |
|
|
|
|
| 359 |
- **Association-vs-target-effect scatter** is generated from the same two existing GPU calls; the synthesis itself costs no additional GPU time.
|
| 360 |
- Existing v0.9 causal-ready discovery, candidate triage, in-place focus, and GPU-budget-aware validation remain unchanged.
|
| 361 |
|
| 362 |
+
## v0.11 controlled candidate specificity
|
| 363 |
+
|
| 364 |
+
- **Controlled candidate specificity** compares up to three shortlisted SAE ablations against each candidate's own eight-direction norm-matched random ensemble in one shared batched execution context.
|
| 365 |
+
- **Strategic shortlist selection** preserves the discovery leader, target-effect leader, and distribution-shift leader when they differ, then fills remaining slots by triage target rank. This avoids confirming only whichever metric already won.
|
| 366 |
+
- **Two controlled causal notions remain separate:** target-specificity ratio normalizes the specified continuation effect; JS-specificity ratio normalizes whole next-token distribution shift.
|
| 367 |
+
- **Association vs controlled causality** joins discovery score/rank to random-normalized target and JS specificity, with descriptive Spearman concordance and rank shifts.
|
| 368 |
+
- The controlled live screen remains deliberately small: three candidates × eight controls each plus one zero-edit reference. Empirical tails are therefore coarse diagnostics rather than conventional significance tests.
|
| 369 |
+
- The validated in-place focus behavior and unchanged paraphrase/trajectory/feature-set paths are not touched. HF acceptance for v0.11 requires only the new controlled-candidate GPU path.
|
| 370 |
+
|
| 371 |
## Validation
|
| 372 |
|
| 373 |
```bash
|
|
|
|
| 378 |
python scripts/release_check.py
|
| 379 |
```
|
| 380 |
|
| 381 |
+
See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.11 validation guide uses the **exact labels shown in the UI** and asks for only the new controlled-candidate comparison on HF. Discovery, triage, paraphrase, trajectory, feature-set, dose-response, cue, and focus paths are not rerun because their implementations were not changed.
|
| 382 |
|
| 383 |
## Limitations
|
| 384 |
|
|
|
|
| 394 |
## Resume-ready description
|
| 395 |
|
| 396 |
> **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
|
| 397 |
+
> Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, concept-guided candidate discovery, token/prompt-wide, completion-cue, and cue × context feature evidence, reconstruction-preserving single and multi-feature interventions, full-continuation and contrastive preference scoring, dose-response analysis, decoder-geometry/non-additivity diagnostics, discovery-to-causality rank analysis, multi-candidate random-controlled specificity screening, and norm-matched random-control ensembles.
|
| 398 |
|
| 399 |
## Acknowledgements
|
| 400 |
|
app.py
CHANGED
|
@@ -749,6 +749,218 @@ def _candidate_alignment_outputs(
|
|
| 749 |
return summary, table, chart
|
| 750 |
|
| 751 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 752 |
def _cue_context_metrics_markdown(result) -> str:
|
| 753 |
active = ", ".join(
|
| 754 |
f"{cue}: {count}/{len(result.stems)} contexts"
|
|
@@ -1349,6 +1561,11 @@ def run_candidate_causal_screen(
|
|
| 1349 |
alignment_metrics, alignment_table, alignment_chart = _candidate_alignment_outputs(
|
| 1350 |
discovery_table, table
|
| 1351 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1352 |
return (
|
| 1353 |
_candidate_screen_metrics_markdown(result),
|
| 1354 |
table,
|
|
@@ -1359,6 +1576,69 @@ def run_candidate_causal_screen(
|
|
| 1359 |
alignment_table,
|
| 1360 |
alignment_chart,
|
| 1361 |
_tsv(alignment_table),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1362 |
)
|
| 1363 |
except Exception as exc:
|
| 1364 |
_raise_ui_error(exc)
|
|
@@ -1445,7 +1725,7 @@ def set_mode_help(mode: str):
|
|
| 1445 |
with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
|
| 1446 |
gr.HTML(
|
| 1447 |
'<header class="hero">'
|
| 1448 |
-
'<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.
|
| 1449 |
'<div class="subtitle">Causal Interpretability Workbench</div>'
|
| 1450 |
'<div class="question">Discover sparse features, test robustness, and separate correlation from causal influence.</div>'
|
| 1451 |
'</header>'
|
|
@@ -2075,6 +2355,108 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
|
|
| 2075 |
height=330,
|
| 2076 |
)
|
| 2077 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2078 |
gr.HTML('<div class="section-rule">C. Inspect one feature</div>')
|
| 2079 |
contrast_location = gr.Markdown(
|
| 2080 |
"**Activation trace uses the current Workbench prompt.** The controlled concept scan below uses its own balanced prompt set."
|
|
@@ -2390,7 +2772,7 @@ Batched experiments include an explicit **zero-edit row**. Causal effects are me
|
|
| 2390 |
|
| 2391 |
1. SAE reconstruction quality.
|
| 2392 |
2. Held-out feature/concept prediction.
|
| 2393 |
-
3. Concept-guided candidate discovery
|
| 2394 |
4. Selected-feature concept contrast and token-local activation traces, completion-cue sensitivity, and cue × context specificity.
|
| 2395 |
5. Local and prompt-wide paraphrase robustness.
|
| 2396 |
6. Single-feature causal intervention and dose-response.
|
|
@@ -2519,8 +2901,37 @@ Association, robustness, geometry, and intervention evidence remain separate cla
|
|
| 2519 |
candidate_alignment_table,
|
| 2520 |
candidate_alignment_plot,
|
| 2521 |
candidate_alignment_tsv,
|
|
|
|
| 2522 |
],
|
| 2523 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2524 |
candidate_screen_table.select(
|
| 2525 |
select_candidate_row,
|
| 2526 |
inputs=[candidate_screen_table],
|
|
@@ -2576,6 +2987,8 @@ Association, robustness, geometry, and intervention evidence remain separate cla
|
|
| 2576 |
(discovery_copy, discovery_tsv),
|
| 2577 |
(candidate_screen_copy, candidate_screen_tsv),
|
| 2578 |
(candidate_alignment_copy, candidate_alignment_tsv),
|
|
|
|
|
|
|
| 2579 |
(cue_copy, cue_tsv),
|
| 2580 |
(cue_context_copy, cue_context_tsv),
|
| 2581 |
(para_copy, para_tsv),
|
|
|
|
| 749 |
return summary, table, chart
|
| 750 |
|
| 751 |
|
| 752 |
+
|
| 753 |
+
def _controlled_candidate_shortlist(
|
| 754 |
+
discovery_table: pd.DataFrame | None,
|
| 755 |
+
screen_table: pd.DataFrame | None,
|
| 756 |
+
limit: int = 3,
|
| 757 |
+
) -> list[str]:
|
| 758 |
+
"""Pick a small controlled follow-up set without another model call.
|
| 759 |
+
|
| 760 |
+
Prefer the strongest discovery candidate, strongest target-effect candidate,
|
| 761 |
+
and strongest distribution-shift candidate. Fill any duplicate slots using
|
| 762 |
+
target-effect rank. This preserves the association-vs-causality contrast
|
| 763 |
+
instead of blindly testing only the triage top-k.
|
| 764 |
+
"""
|
| 765 |
+
if screen_table is None:
|
| 766 |
+
return []
|
| 767 |
+
screen = pd.DataFrame(screen_table).copy()
|
| 768 |
+
if screen.empty or "Feature id" not in screen:
|
| 769 |
+
return []
|
| 770 |
+
screen["Feature id"] = pd.to_numeric(screen["Feature id"], errors="coerce")
|
| 771 |
+
screen = screen.dropna(subset=["Feature id"]).copy()
|
| 772 |
+
screen["Feature id"] = screen["Feature id"].astype(int)
|
| 773 |
+
if screen.empty:
|
| 774 |
+
return []
|
| 775 |
+
|
| 776 |
+
selected: list[int] = []
|
| 777 |
+
|
| 778 |
+
def add(feature_id: int) -> None:
|
| 779 |
+
if feature_id not in selected and len(selected) < int(limit):
|
| 780 |
+
selected.append(feature_id)
|
| 781 |
+
|
| 782 |
+
if discovery_table is not None:
|
| 783 |
+
discovery = pd.DataFrame(discovery_table).copy()
|
| 784 |
+
if not discovery.empty and {"Feature id", "Rank"}.issubset(discovery.columns):
|
| 785 |
+
discovery["Feature id"] = pd.to_numeric(discovery["Feature id"], errors="coerce")
|
| 786 |
+
discovery = discovery.dropna(subset=["Feature id"]).copy()
|
| 787 |
+
discovery["Feature id"] = discovery["Feature id"].astype(int)
|
| 788 |
+
screened_ids = set(screen["Feature id"].astype(int).tolist())
|
| 789 |
+
discovery = discovery[discovery["Feature id"].isin(screened_ids)]
|
| 790 |
+
if not discovery.empty:
|
| 791 |
+
top_discovery = discovery.sort_values(["Rank", "Feature id"]).iloc[0]
|
| 792 |
+
add(int(top_discovery["Feature id"]))
|
| 793 |
+
|
| 794 |
+
target_sorted = screen.sort_values(["Rank", "Feature id"])
|
| 795 |
+
add(int(target_sorted.iloc[0]["Feature id"]))
|
| 796 |
+
|
| 797 |
+
if "Next-token JS" in screen.columns:
|
| 798 |
+
js_sorted = screen.sort_values(["Next-token JS", "Feature id"], ascending=[False, True])
|
| 799 |
+
add(int(js_sorted.iloc[0]["Feature id"]))
|
| 800 |
+
|
| 801 |
+
for feature_id in target_sorted["Feature id"].astype(int).tolist():
|
| 802 |
+
add(feature_id)
|
| 803 |
+
if len(selected) >= int(limit):
|
| 804 |
+
break
|
| 805 |
+
return [str(feature_id) for feature_id in selected]
|
| 806 |
+
|
| 807 |
+
|
| 808 |
+
def _candidate_specificity_metrics_markdown(result) -> str:
|
| 809 |
+
tokens = " ".join(repr(token) for token in result.target_tokens)
|
| 810 |
+
if result.rows:
|
| 811 |
+
top = result.rows[0]
|
| 812 |
+
strongest = (
|
| 813 |
+
f"Highest target-specificity ratio: feature **{int(top[1])}** at **{float(top[9]):.2f}×** "
|
| 814 |
+
f"the random mean |effect| (empirical tail **{float(top[10]):.3f}**)."
|
| 815 |
+
)
|
| 816 |
+
else:
|
| 817 |
+
strongest = "No controlled candidate rows were produced."
|
| 818 |
+
return (
|
| 819 |
+
f"Compared **{result.candidate_count}** candidate feature(s); **{result.active_feature_count}** were active "
|
| 820 |
+
f"at the selected Workbench token. Each candidate uses **{result.random_control_count}** norm-matched random controls. \n"
|
| 821 |
+
f"Target continuation: {len(result.target_tokens)} token(s): {tokens}. \n"
|
| 822 |
+
f"{strongest} \n"
|
| 823 |
+
f"Execution-context null drift: mean log p/token **{result.execution_drift_mean_logprob:+.2e}**, "
|
| 824 |
+
f"JS **{result.execution_drift_js:.2e}**. \n\n"
|
| 825 |
+
"This is the controlled follow-up to triage: target-specificity compares |Δ mean log p/token| against the "
|
| 826 |
+
"candidate's own norm-matched random ensemble; JS-specificity does the same for the local next-token distribution. "
|
| 827 |
+
"With eight controls, empirical tail values remain intentionally coarse."
|
| 828 |
+
)
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
def _controlled_alignment_outputs(
|
| 832 |
+
discovery_table: pd.DataFrame | None,
|
| 833 |
+
specificity_table: pd.DataFrame | None,
|
| 834 |
+
) -> tuple[str, pd.DataFrame, pd.DataFrame]:
|
| 835 |
+
"""Join concept evidence to random-controlled causal specificity."""
|
| 836 |
+
columns = [
|
| 837 |
+
"Feature id",
|
| 838 |
+
"Discovery rank",
|
| 839 |
+
"Specificity rank",
|
| 840 |
+
"Target-effect rank",
|
| 841 |
+
"JS-specificity rank",
|
| 842 |
+
"Candidate score",
|
| 843 |
+
"|SAE Δ mean log p/token|",
|
| 844 |
+
"Random mean |Δ|",
|
| 845 |
+
"Target specificity ratio",
|
| 846 |
+
"Target empirical tail p",
|
| 847 |
+
"SAE next-token JS",
|
| 848 |
+
"Random mean JS",
|
| 849 |
+
"JS specificity ratio",
|
| 850 |
+
"JS empirical tail p",
|
| 851 |
+
"Discovery→specificity rank shift",
|
| 852 |
+
]
|
| 853 |
+
if discovery_table is None or specificity_table is None:
|
| 854 |
+
return "", pd.DataFrame(columns=columns), pd.DataFrame()
|
| 855 |
+
discovery = pd.DataFrame(discovery_table).copy()
|
| 856 |
+
controlled = pd.DataFrame(specificity_table).copy()
|
| 857 |
+
if discovery.empty or controlled.empty or "Feature id" not in discovery or "Feature id" not in controlled:
|
| 858 |
+
return "", pd.DataFrame(columns=columns), pd.DataFrame()
|
| 859 |
+
|
| 860 |
+
discovery["Feature id"] = pd.to_numeric(discovery["Feature id"], errors="coerce")
|
| 861 |
+
controlled["Feature id"] = pd.to_numeric(controlled["Feature id"], errors="coerce")
|
| 862 |
+
discovery = discovery.dropna(subset=["Feature id"]).copy()
|
| 863 |
+
controlled = controlled.dropna(subset=["Feature id"]).copy()
|
| 864 |
+
discovery["Feature id"] = discovery["Feature id"].astype(int)
|
| 865 |
+
controlled["Feature id"] = controlled["Feature id"].astype(int)
|
| 866 |
+
|
| 867 |
+
needed_discovery = {"Rank", "Candidate score"}
|
| 868 |
+
needed_controlled = {
|
| 869 |
+
"Rank",
|
| 870 |
+
"SAE Δ mean log p/token",
|
| 871 |
+
"Random mean |Δ|",
|
| 872 |
+
"Target specificity ratio",
|
| 873 |
+
"Target empirical tail p",
|
| 874 |
+
"SAE next-token JS",
|
| 875 |
+
"Random mean JS",
|
| 876 |
+
"JS specificity ratio",
|
| 877 |
+
"JS empirical tail p",
|
| 878 |
+
}
|
| 879 |
+
if not needed_discovery.issubset(discovery.columns) or not needed_controlled.issubset(controlled.columns):
|
| 880 |
+
return "", pd.DataFrame(columns=columns), pd.DataFrame()
|
| 881 |
+
|
| 882 |
+
discovery_lookup = discovery.set_index("Feature id", drop=False)
|
| 883 |
+
target_ranked = controlled.assign(
|
| 884 |
+
_abs_target=controlled["SAE Δ mean log p/token"].astype(float).abs()
|
| 885 |
+
).sort_values(["_abs_target", "Feature id"], ascending=[False, True])
|
| 886 |
+
target_ranks = {
|
| 887 |
+
int(row["Feature id"]): rank for rank, (_, row) in enumerate(target_ranked.iterrows(), start=1)
|
| 888 |
+
}
|
| 889 |
+
js_ranked = controlled.sort_values(
|
| 890 |
+
["JS specificity ratio", "Feature id"], ascending=[False, True]
|
| 891 |
+
)
|
| 892 |
+
js_ranks = {
|
| 893 |
+
int(row["Feature id"]): rank for rank, (_, row) in enumerate(js_ranked.iterrows(), start=1)
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
rows: list[list[object]] = []
|
| 897 |
+
for _, row in controlled.iterrows():
|
| 898 |
+
feature_id = int(row["Feature id"])
|
| 899 |
+
if feature_id not in discovery_lookup.index:
|
| 900 |
+
continue
|
| 901 |
+
discovery_row = discovery_lookup.loc[feature_id]
|
| 902 |
+
if isinstance(discovery_row, pd.DataFrame):
|
| 903 |
+
discovery_row = discovery_row.iloc[0]
|
| 904 |
+
discovery_rank = int(float(discovery_row["Rank"]))
|
| 905 |
+
specificity_rank = int(float(row["Rank"]))
|
| 906 |
+
rows.append(
|
| 907 |
+
[
|
| 908 |
+
feature_id,
|
| 909 |
+
discovery_rank,
|
| 910 |
+
specificity_rank,
|
| 911 |
+
int(target_ranks[feature_id]),
|
| 912 |
+
int(js_ranks[feature_id]),
|
| 913 |
+
float(discovery_row["Candidate score"]),
|
| 914 |
+
abs(float(row["SAE Δ mean log p/token"])),
|
| 915 |
+
float(row["Random mean |Δ|"]),
|
| 916 |
+
float(row["Target specificity ratio"]),
|
| 917 |
+
float(row["Target empirical tail p"]),
|
| 918 |
+
float(row["SAE next-token JS"]),
|
| 919 |
+
float(row["Random mean JS"]),
|
| 920 |
+
float(row["JS specificity ratio"]),
|
| 921 |
+
float(row["JS empirical tail p"]),
|
| 922 |
+
discovery_rank - specificity_rank,
|
| 923 |
+
]
|
| 924 |
+
)
|
| 925 |
+
|
| 926 |
+
table = pd.DataFrame(rows, columns=columns)
|
| 927 |
+
if table.empty:
|
| 928 |
+
return "", table, pd.DataFrame()
|
| 929 |
+
|
| 930 |
+
rho_target = _spearman_rank_corr(
|
| 931 |
+
table["Candidate score"].astype(float).tolist(),
|
| 932 |
+
table["Target specificity ratio"].astype(float).tolist(),
|
| 933 |
+
)
|
| 934 |
+
rho_js = _spearman_rank_corr(
|
| 935 |
+
table["Candidate score"].astype(float).tolist(),
|
| 936 |
+
table["JS specificity ratio"].astype(float).tolist(),
|
| 937 |
+
)
|
| 938 |
+
top_discovery = table.sort_values(["Discovery rank", "Feature id"]).iloc[0]
|
| 939 |
+
top_specificity = table.sort_values(["Specificity rank", "Feature id"]).iloc[0]
|
| 940 |
+
top_js = table.sort_values(["JS-specificity rank", "Feature id"]).iloc[0]
|
| 941 |
+
|
| 942 |
+
def fmt_rho(value: float | None) -> str:
|
| 943 |
+
return "undefined" if value is None else f"{value:+.3f}"
|
| 944 |
+
|
| 945 |
+
summary = (
|
| 946 |
+
f"Controlled comparison covers **{len(table)}** candidates from the discovery/triage workflow. \n"
|
| 947 |
+
f"Top discovery candidate: **{int(top_discovery['Feature id'])}** · strongest random-normalized target effect: "
|
| 948 |
+
f"**{int(top_specificity['Feature id'])}** · strongest random-normalized JS shift: **{int(top_js['Feature id'])}**. \n"
|
| 949 |
+
f"Spearman ρ(candidate score, target-specificity ratio): **{fmt_rho(rho_target)}** · "
|
| 950 |
+
f"ρ(candidate score, JS-specificity ratio): **{fmt_rho(rho_js)}**. \n\n"
|
| 951 |
+
"This is stronger than the cheap triage comparison because each candidate is normalized against its own "
|
| 952 |
+
"norm-matched random ensemble. The candidate count and eight-control ensemble are still small, so the correlations "
|
| 953 |
+
"and empirical tails are descriptive live diagnostics rather than significance claims."
|
| 954 |
+
)
|
| 955 |
+
|
| 956 |
+
chart = table[
|
| 957 |
+
["Feature id", "Candidate score", "Target specificity ratio", "Discovery rank", "Specificity rank", "Target empirical tail p"]
|
| 958 |
+
].copy()
|
| 959 |
+
chart["Feature id"] = chart["Feature id"].astype(str)
|
| 960 |
+
chart["Series"] = "Controlled candidate"
|
| 961 |
+
return summary, table, chart
|
| 962 |
+
|
| 963 |
+
|
| 964 |
def _cue_context_metrics_markdown(result) -> str:
|
| 965 |
active = ", ".join(
|
| 966 |
f"{cue}: {count}/{len(result.stems)} contexts"
|
|
|
|
| 1561 |
alignment_metrics, alignment_table, alignment_chart = _candidate_alignment_outputs(
|
| 1562 |
discovery_table, table
|
| 1563 |
)
|
| 1564 |
+
specificity_shortlist = _controlled_candidate_shortlist(discovery_table, table, limit=3)
|
| 1565 |
+
specificity_update = gr.update(
|
| 1566 |
+
choices=choices,
|
| 1567 |
+
value=specificity_shortlist,
|
| 1568 |
+
)
|
| 1569 |
return (
|
| 1570 |
_candidate_screen_metrics_markdown(result),
|
| 1571 |
table,
|
|
|
|
| 1576 |
alignment_table,
|
| 1577 |
alignment_chart,
|
| 1578 |
_tsv(alignment_table),
|
| 1579 |
+
specificity_update,
|
| 1580 |
+
)
|
| 1581 |
+
except Exception as exc:
|
| 1582 |
+
_raise_ui_error(exc)
|
| 1583 |
+
|
| 1584 |
+
|
| 1585 |
+
@gpu(duration=40)
|
| 1586 |
+
def run_candidate_specificity_screen(
|
| 1587 |
+
prompt: str,
|
| 1588 |
+
layer: int,
|
| 1589 |
+
token_index: int,
|
| 1590 |
+
feature_ids: list[str] | None,
|
| 1591 |
+
target_text: str,
|
| 1592 |
+
discovery_table: pd.DataFrame | None,
|
| 1593 |
+
):
|
| 1594 |
+
try:
|
| 1595 |
+
selected = [int(float(value)) for value in (feature_ids or [])]
|
| 1596 |
+
result = RUNTIME.candidate_specificity_screen(
|
| 1597 |
+
text=prompt,
|
| 1598 |
+
layer=int(layer),
|
| 1599 |
+
token_index=int(token_index),
|
| 1600 |
+
feature_ids=selected,
|
| 1601 |
+
target_text=target_text,
|
| 1602 |
+
)
|
| 1603 |
+
columns = [
|
| 1604 |
+
"Rank",
|
| 1605 |
+
"Feature id",
|
| 1606 |
+
"Native activation",
|
| 1607 |
+
"Active at current token",
|
| 1608 |
+
"Perturbation L2",
|
| 1609 |
+
"SAE Δ mean log p/token",
|
| 1610 |
+
"Random signed mean Δ",
|
| 1611 |
+
"Random mean |Δ|",
|
| 1612 |
+
"Random |Δ| std",
|
| 1613 |
+
"Target specificity ratio",
|
| 1614 |
+
"Target empirical tail p",
|
| 1615 |
+
"SAE Δ sequence log p",
|
| 1616 |
+
"SAE next-token JS",
|
| 1617 |
+
"Random mean JS",
|
| 1618 |
+
"Random JS std",
|
| 1619 |
+
"JS specificity ratio",
|
| 1620 |
+
"JS empirical tail p",
|
| 1621 |
+
]
|
| 1622 |
+
table = pd.DataFrame(result.rows, columns=columns)
|
| 1623 |
+
chart = pd.DataFrame(
|
| 1624 |
+
result.chart_rows,
|
| 1625 |
+
columns=["Feature", "Specificity metric", "Ratio"],
|
| 1626 |
+
)
|
| 1627 |
+
alignment_metrics, alignment_table, alignment_chart = _controlled_alignment_outputs(
|
| 1628 |
+
discovery_table, table
|
| 1629 |
+
)
|
| 1630 |
+
choices = [str(feature_id) for feature_id in result.feature_ids]
|
| 1631 |
+
candidate_update = gr.update(choices=choices, value=choices[0] if choices else None)
|
| 1632 |
+
return (
|
| 1633 |
+
_candidate_specificity_metrics_markdown(result),
|
| 1634 |
+
table,
|
| 1635 |
+
chart,
|
| 1636 |
+
candidate_update,
|
| 1637 |
+
_tsv(table),
|
| 1638 |
+
alignment_metrics,
|
| 1639 |
+
alignment_table,
|
| 1640 |
+
alignment_chart,
|
| 1641 |
+
_tsv(alignment_table),
|
| 1642 |
)
|
| 1643 |
except Exception as exc:
|
| 1644 |
_raise_ui_error(exc)
|
|
|
|
| 1725 |
with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
|
| 1726 |
gr.HTML(
|
| 1727 |
'<header class="hero">'
|
| 1728 |
+
'<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.11</span></h1>'
|
| 1729 |
'<div class="subtitle">Causal Interpretability Workbench</div>'
|
| 1730 |
'<div class="question">Discover sparse features, test robustness, and separate correlation from causal influence.</div>'
|
| 1731 |
'</header>'
|
|
|
|
| 2355 |
height=330,
|
| 2356 |
)
|
| 2357 |
|
| 2358 |
+
gr.Markdown("#### Controlled candidate specificity")
|
| 2359 |
+
gr.Markdown(
|
| 2360 |
+
"Follow the cheap triage with a small random-controlled comparison. FeatureLens automatically proposes a "
|
| 2361 |
+
"three-feature shortlist that preserves the disagreement between discovery, target-effect, and distribution-shift "
|
| 2362 |
+
"rankings. Each candidate receives its own 8-direction norm-matched random ensemble in one batched forward."
|
| 2363 |
+
)
|
| 2364 |
+
with gr.Row(equal_height=True):
|
| 2365 |
+
candidate_specificity_ids = gr.Dropdown(
|
| 2366 |
+
choices=[],
|
| 2367 |
+
value=[],
|
| 2368 |
+
multiselect=True,
|
| 2369 |
+
allow_custom_value=True,
|
| 2370 |
+
max_choices=3,
|
| 2371 |
+
label="Candidates for controlled comparison",
|
| 2372 |
+
info="Auto-filled after triage; choose up to three features.",
|
| 2373 |
+
scale=3,
|
| 2374 |
+
)
|
| 2375 |
+
candidate_specificity_target = gr.Textbox(
|
| 2376 |
+
label="Controlled target continuation",
|
| 2377 |
+
value="2x",
|
| 2378 |
+
info="Exact continuation scored for every candidate and its random controls.",
|
| 2379 |
+
scale=2,
|
| 2380 |
+
)
|
| 2381 |
+
candidate_specificity_btn = gr.Button(
|
| 2382 |
+
"Run controlled candidate comparison", variant="primary", elem_classes=["action-btn"]
|
| 2383 |
+
)
|
| 2384 |
+
candidate_specificity_metrics = gr.Markdown()
|
| 2385 |
+
with gr.Row(equal_height=False):
|
| 2386 |
+
with gr.Column(scale=3):
|
| 2387 |
+
_table_heading('Controlled candidate specificity')
|
| 2388 |
+
candidate_specificity_table = gr.Dataframe(
|
| 2389 |
+
interactive=False,
|
| 2390 |
+
label="Controlled candidate specificity",
|
| 2391 |
+
show_label=False,
|
| 2392 |
+
buttons=["fullscreen"],
|
| 2393 |
+
elem_classes=["result-table"],
|
| 2394 |
+
wrap=False,
|
| 2395 |
+
max_height=390,
|
| 2396 |
+
)
|
| 2397 |
+
candidate_specificity_tsv = gr.Textbox(visible="hidden")
|
| 2398 |
+
candidate_specificity_copy = _copy_button()
|
| 2399 |
+
with gr.Column(scale=2):
|
| 2400 |
+
candidate_specificity_plot = gr.BarPlot(
|
| 2401 |
+
x="Feature",
|
| 2402 |
+
y="Ratio",
|
| 2403 |
+
color="Specificity metric",
|
| 2404 |
+
color_map={
|
| 2405 |
+
"Target specificity": INK_TEAL,
|
| 2406 |
+
"JS specificity": INK_UMBER,
|
| 2407 |
+
},
|
| 2408 |
+
title="Random-normalized causal specificity",
|
| 2409 |
+
elem_id="plot-candidate-specificity",
|
| 2410 |
+
x_title="Feature id",
|
| 2411 |
+
y_title="SAE effect / random mean effect",
|
| 2412 |
+
x_label_angle=-35,
|
| 2413 |
+
buttons=["fullscreen", "export"],
|
| 2414 |
+
elem_classes=["fl-plot"],
|
| 2415 |
+
height=330,
|
| 2416 |
+
)
|
| 2417 |
+
|
| 2418 |
+
gr.Markdown("#### Association vs controlled causality")
|
| 2419 |
+
gr.Markdown(
|
| 2420 |
+
"Computed from concept discovery and the random-controlled comparison above. This is the stronger live version "
|
| 2421 |
+
"of the association-vs-causality diagnostic because each candidate is normalized against its own matched-control ensemble.",
|
| 2422 |
+
elem_classes=["candidate-help"],
|
| 2423 |
+
)
|
| 2424 |
+
controlled_alignment_metrics = gr.Markdown()
|
| 2425 |
+
with gr.Row(equal_height=False):
|
| 2426 |
+
with gr.Column(scale=3):
|
| 2427 |
+
_table_heading('Discovery–controlled-causality alignment')
|
| 2428 |
+
controlled_alignment_table = gr.Dataframe(
|
| 2429 |
+
interactive=False,
|
| 2430 |
+
label="Discovery–controlled-causality alignment",
|
| 2431 |
+
show_label=False,
|
| 2432 |
+
buttons=["fullscreen"],
|
| 2433 |
+
elem_classes=["result-table"],
|
| 2434 |
+
wrap=False,
|
| 2435 |
+
max_height=360,
|
| 2436 |
+
)
|
| 2437 |
+
controlled_alignment_tsv = gr.Textbox(visible="hidden")
|
| 2438 |
+
controlled_alignment_copy = _copy_button()
|
| 2439 |
+
with gr.Column(scale=2):
|
| 2440 |
+
controlled_alignment_plot = gr.ScatterPlot(
|
| 2441 |
+
x="Candidate score",
|
| 2442 |
+
y="Target specificity ratio",
|
| 2443 |
+
color="Series",
|
| 2444 |
+
color_map={"Controlled candidate": INK_TEAL},
|
| 2445 |
+
title="Association evidence vs controlled target specificity",
|
| 2446 |
+
elem_id="plot-controlled-association-causality",
|
| 2447 |
+
x_title="Discovery candidate score",
|
| 2448 |
+
y_title="Target specificity ratio",
|
| 2449 |
+
tooltip=[
|
| 2450 |
+
"Feature id",
|
| 2451 |
+
"Discovery rank",
|
| 2452 |
+
"Specificity rank",
|
| 2453 |
+
"Target empirical tail p",
|
| 2454 |
+
],
|
| 2455 |
+
buttons=["fullscreen", "export"],
|
| 2456 |
+
elem_classes=["fl-plot"],
|
| 2457 |
+
height=330,
|
| 2458 |
+
)
|
| 2459 |
+
|
| 2460 |
gr.HTML('<div class="section-rule">C. Inspect one feature</div>')
|
| 2461 |
contrast_location = gr.Markdown(
|
| 2462 |
"**Activation trace uses the current Workbench prompt.** The controlled concept scan below uses its own balanced prompt set."
|
|
|
|
| 2772 |
|
| 2773 |
1. SAE reconstruction quality.
|
| 2774 |
2. Held-out feature/concept prediction.
|
| 2775 |
+
3. Concept-guided candidate discovery, batched causal triage, and random-controlled candidate comparison.
|
| 2776 |
4. Selected-feature concept contrast and token-local activation traces, completion-cue sensitivity, and cue × context specificity.
|
| 2777 |
5. Local and prompt-wide paraphrase robustness.
|
| 2778 |
6. Single-feature causal intervention and dose-response.
|
|
|
|
| 2901 |
candidate_alignment_table,
|
| 2902 |
candidate_alignment_plot,
|
| 2903 |
candidate_alignment_tsv,
|
| 2904 |
+
candidate_specificity_ids,
|
| 2905 |
],
|
| 2906 |
)
|
| 2907 |
+
candidate_specificity_btn.click(
|
| 2908 |
+
run_candidate_specificity_screen,
|
| 2909 |
+
inputs=[
|
| 2910 |
+
prompt,
|
| 2911 |
+
discovery_layer,
|
| 2912 |
+
token_index,
|
| 2913 |
+
candidate_specificity_ids,
|
| 2914 |
+
candidate_specificity_target,
|
| 2915 |
+
discovery_table,
|
| 2916 |
+
],
|
| 2917 |
+
outputs=[
|
| 2918 |
+
candidate_specificity_metrics,
|
| 2919 |
+
candidate_specificity_table,
|
| 2920 |
+
candidate_specificity_plot,
|
| 2921 |
+
discovery_candidate,
|
| 2922 |
+
candidate_specificity_tsv,
|
| 2923 |
+
controlled_alignment_metrics,
|
| 2924 |
+
controlled_alignment_table,
|
| 2925 |
+
controlled_alignment_plot,
|
| 2926 |
+
controlled_alignment_tsv,
|
| 2927 |
+
],
|
| 2928 |
+
)
|
| 2929 |
+
candidate_specificity_table.select(
|
| 2930 |
+
select_candidate_row,
|
| 2931 |
+
inputs=[candidate_specificity_table],
|
| 2932 |
+
outputs=[discovery_candidate],
|
| 2933 |
+
queue=False,
|
| 2934 |
+
)
|
| 2935 |
candidate_screen_table.select(
|
| 2936 |
select_candidate_row,
|
| 2937 |
inputs=[candidate_screen_table],
|
|
|
|
| 2987 |
(discovery_copy, discovery_tsv),
|
| 2988 |
(candidate_screen_copy, candidate_screen_tsv),
|
| 2989 |
(candidate_alignment_copy, candidate_alignment_tsv),
|
| 2990 |
+
(candidate_specificity_copy, candidate_specificity_tsv),
|
| 2991 |
+
(controlled_alignment_copy, controlled_alignment_tsv),
|
| 2992 |
(cue_copy, cue_tsv),
|
| 2993 |
(cue_context_copy, cue_context_tsv),
|
| 2994 |
(para_copy, para_tsv),
|
docs/METHODOLOGY.md
CHANGED
|
@@ -462,3 +462,72 @@ so positive values indicate a feature that rises in the target-effect ordering r
|
|
| 462 |
Across the small screened set, FeatureLens computes tie-aware Spearman correlations between candidate score and (a) absolute target effect and (b) next-token JS. These are deliberately labeled **descriptive** because the live shortlist is small and the triage stage does not spend random-control ensembles. A promoted feature still requires the full single-feature causal test before specificity is interpreted.
|
| 463 |
|
| 464 |
This synthesis costs no additional model forward: it is computed from the discovery and triage tables already produced by the two-stage workflow.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
Across the small screened set, FeatureLens computes tie-aware Spearman correlations between candidate score and (a) absolute target effect and (b) next-token JS. These are deliberately labeled **descriptive** because the live shortlist is small and the triage stage does not spend random-control ensembles. A promoted feature still requires the full single-feature causal test before specificity is interpreted.
|
| 463 |
|
| 464 |
This synthesis costs no additional model forward: it is computed from the discovery and triage tables already produced by the two-stage workflow.
|
| 465 |
+
|
| 466 |
+
## v0.11 controlled candidate specificity
|
| 467 |
+
|
| 468 |
+
The cheap candidate-ablation triage intentionally omits random controls so multiple candidate features can be screened in one small batch. Raw intervention magnitude is useful for triage, but it does not establish that an SAE direction is more behaviorally specific than an arbitrary residual perturbation of the same norm.
|
| 469 |
+
|
| 470 |
+
v0.11 therefore adds a second-stage controlled screen for at most three candidates. For candidate feature \(i\) with native activation \(z_i\) and decoder direction \(d_i\), the targeted ablation is
|
| 471 |
+
|
| 472 |
+
\[
|
| 473 |
+
\Delta h_i = -z_i d_i.
|
| 474 |
+
\]
|
| 475 |
+
|
| 476 |
+
For each candidate, FeatureLens generates `live_random_controls` deterministic random residual directions \(r_{ij}\) such that
|
| 477 |
+
|
| 478 |
+
\[
|
| 479 |
+
\|r_{ij}\|_2 = \|\Delta h_i\|_2.
|
| 480 |
+
\]
|
| 481 |
+
|
| 482 |
+
A single batched execution contains one zero-edit reference, every targeted SAE ablation, and every candidate-specific random control. All target and JS effects are therefore measured relative to the same batched null.
|
| 483 |
+
|
| 484 |
+
Two random-normalized causal quantities are kept separate:
|
| 485 |
+
|
| 486 |
+
1. **Target specificity ratio**
|
| 487 |
+
|
| 488 |
+
\[
|
| 489 |
+
\frac{|\Delta \bar{\ell}_{\text{SAE}}|}
|
| 490 |
+
{\operatorname{mean}_j |\Delta \bar{\ell}_{r_j}|},
|
| 491 |
+
\]
|
| 492 |
+
|
| 493 |
+
where \(\bar{\ell}\) is the teacher-forced mean log probability per target token.
|
| 494 |
+
|
| 495 |
+
2. **JS specificity ratio**
|
| 496 |
+
|
| 497 |
+
\[
|
| 498 |
+
\frac{\operatorname{JS}(p_0, p_{\text{SAE}})}
|
| 499 |
+
{\operatorname{mean}_j \operatorname{JS}(p_0, p_{r_j})}.
|
| 500 |
+
\]
|
| 501 |
+
|
| 502 |
+
The first asks whether the SAE edit is unusually influential for the specified continuation. The second asks whether it is unusually disruptive to the local next-token distribution as a whole. They are intentionally not collapsed into a single score.
|
| 503 |
+
|
| 504 |
+
The live empirical random-control tail uses the finite-ensemble correction
|
| 505 |
+
|
| 506 |
+
\[
|
| 507 |
+
p = \frac{1 + \#\{|e_{r_j}| \ge |e_{\text{SAE}}|\}}
|
| 508 |
+
{1 + N_{\text{controls}}}.
|
| 509 |
+
\]
|
| 510 |
+
|
| 511 |
+
With eight controls, the smallest possible live value is \(1/9\approx0.111\). These values are therefore coarse specificity diagnostics, not conventional significance tests.
|
| 512 |
+
|
| 513 |
+
### Strategic controlled shortlist
|
| 514 |
+
|
| 515 |
+
After cheap triage, the default controlled shortlist is chosen to preserve disagreement rather than merely retest the top target-effect rows:
|
| 516 |
+
|
| 517 |
+
1. strongest discovery candidate among the screened features;
|
| 518 |
+
2. strongest raw target-effect candidate;
|
| 519 |
+
3. strongest next-token-JS candidate if distinct;
|
| 520 |
+
4. fill any remaining slot by target-effect rank.
|
| 521 |
+
|
| 522 |
+
This makes the controlled follow-up directly test the project's central question: whether strong concept-association evidence predicts random-normalized causal specificity.
|
| 523 |
+
|
| 524 |
+
### Association vs controlled causality
|
| 525 |
+
|
| 526 |
+
The zero-GPU synthesis layer joins discovery evidence to the controlled table and keeps four ranks separate:
|
| 527 |
+
|
| 528 |
+
- discovery rank;
|
| 529 |
+
- raw target-effect rank;
|
| 530 |
+
- random-normalized target-specificity rank;
|
| 531 |
+
- random-normalized JS-specificity rank.
|
| 532 |
+
|
| 533 |
+
Descriptive Spearman correlations between candidate score and the two specificity ratios are shown only as small-sample diagnostics. The full offline benchmark remains the place for larger candidate sets, more random controls, uncertainty intervals, and formal held-out conclusions.
|
docs/VALIDATION.md
CHANGED
|
@@ -1,12 +1,10 @@
|
|
| 1 |
-
# FeatureLens v0.
|
| 2 |
|
| 3 |
-
v0.
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
From the repository root:
|
| 10 |
|
| 11 |
```bash
|
| 12 |
python3 -m pytest -q && \
|
|
@@ -16,139 +14,122 @@ python3 scripts/ui_smoke.py && \
|
|
| 16 |
python3 scripts/release_check.py
|
| 17 |
```
|
| 18 |
|
| 19 |
-
Expected
|
| 20 |
-
|
| 21 |
-
- all tests pass;
|
| 22 |
-
- Ruff reports `All checks passed!`;
|
| 23 |
-
- `FeatureLens UI launch smoke: PASS`;
|
| 24 |
-
- release check ends with `release: v0.10.0`.
|
| 25 |
-
|
| 26 |
-
Stop if any local gate fails.
|
| 27 |
-
|
| 28 |
-
---
|
| 29 |
-
|
| 30 |
-
# HF acceptance — two GPU calls total
|
| 31 |
-
|
| 32 |
-
Use the existing Workbench context:
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
**Feature evidence → A. Concept-guided candidate discovery**
|
| 45 |
|
| 46 |
-
|
| 47 |
|
| 48 |
-
|
| 49 |
-
- **Residual layer:** `14`
|
| 50 |
-
- **Prompts per concept:** `4`
|
| 51 |
-
- **Candidate features:** `12`
|
| 52 |
-
- **Candidate ranking:** `Causal-ready at current token`
|
| 53 |
|
| 54 |
-
|
| 55 |
|
| 56 |
-
**
|
| 57 |
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
| 61 |
-
- **Candidate features to screen** should auto-populate with the first five returned feature IDs;
|
| 62 |
-
- no additional UI/focus regression testing is required because that code was not changed in v0.10.
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
|
| 71 |
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
|
| 74 |
Set:
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
**Screen candidate ablations**
|
| 81 |
-
|
| 82 |
-
The normal **Candidate ablation screen** should appear first.
|
| 83 |
-
|
| 84 |
-
Immediately below it, without another GPU action, v0.10 should populate:
|
| 85 |
-
|
| 86 |
-
### `Association vs causal influence`
|
| 87 |
-
|
| 88 |
-
with:
|
| 89 |
|
| 90 |
-
|
| 91 |
-
2. **Discovery–causality alignment** table;
|
| 92 |
-
3. **Association evidence vs target effect** scatter plot.
|
| 93 |
|
| 94 |
-
###
|
| 95 |
|
| 96 |
-
|
| 97 |
-
- `Discovery rank`
|
| 98 |
-
- `Target-effect rank`
|
| 99 |
-
- `Distribution-shift rank`
|
| 100 |
-
- `Candidate score`
|
| 101 |
-
- `Selectivity`
|
| 102 |
-
- `Current token activation`
|
| 103 |
-
- `|Δ mean log p/token|`
|
| 104 |
-
- `Next-token JS`
|
| 105 |
-
- `Discovery→target rank shift`
|
| 106 |
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
The
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
- Spearman `ρ(candidate score, next-token JS)`;
|
| 116 |
-
- an explicit warning that the live correlations are descriptive because the screened set is small and triage has no random-control ensemble.
|
| 117 |
|
| 118 |
-
|
| 119 |
|
| 120 |
-
|
| 121 |
-
- strongest target effect: feature `25992`;
|
| 122 |
-
- strongest next-token JS shift: feature `16369`;
|
| 123 |
-
- discovery score versus target-effect magnitude: strongly negative descriptive rank correlation;
|
| 124 |
-
- discovery score versus JS: positive but weaker descriptive rank correlation.
|
| 125 |
|
| 126 |
-
|
| 127 |
|
| 128 |
-
|
| 129 |
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
-
|
| 133 |
-
- feature `16369`: discovery rank `1`, target-effect rank `5` → rank shift `-4`.
|
| 134 |
|
| 135 |
-
|
| 136 |
|
| 137 |
-
|
| 138 |
|
| 139 |
-
|
| 140 |
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
-
|
| 144 |
-
2. the new **Discovery–causality alignment** table;
|
| 145 |
-
3. the new association/causality summary with the two Spearman values;
|
| 146 |
-
4. optionally a screenshot of **Association evidence vs target effect** if the plot looks wrong.
|
| 147 |
|
| 148 |
-
|
| 149 |
|
| 150 |
-
|
| 151 |
|
| 152 |
-
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
|
|
|
|
| 1 |
+
# FeatureLens v0.11 validation
|
| 2 |
|
| 3 |
+
v0.11 adds one new GPU inference path: **Controlled candidate specificity**. It does **not** change concept discovery, cheap candidate triage, paraphrase robustness, layer trajectory, feature-set interventions, dose response, cue diagnostics, or the validated in-place focus behavior. Do not spend ZeroGPU quota rerunning those unchanged paths.
|
| 4 |
|
| 5 |
+
## Local release gate
|
| 6 |
|
| 7 |
+
Run from the repository root:
|
|
|
|
|
|
|
| 8 |
|
| 9 |
```bash
|
| 10 |
python3 -m pytest -q && \
|
|
|
|
| 14 |
python3 scripts/release_check.py
|
| 15 |
```
|
| 16 |
|
| 17 |
+
Expected release checker tail:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
```text
|
| 20 |
+
FeatureLens release check: PASS
|
| 21 |
+
discovery prompts: 224
|
| 22 |
+
causal tasks: 28
|
| 23 |
+
layers: [4, 14, 26]
|
| 24 |
+
feature-set sizes: [1, 3, 5]
|
| 25 |
+
random controls: 8
|
| 26 |
+
release: v0.11.0
|
| 27 |
+
```
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
## HF acceptance: one GPU call only
|
| 30 |
|
| 31 |
+
The v0.10 discovery and triage paths were already validated. For v0.11, test only the new controlled follow-up.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
+
### Exact path
|
| 34 |
|
| 35 |
+
Open **Feature evidence** and scroll to:
|
| 36 |
|
| 37 |
+
**B. Batched causal candidate triage → Controlled candidate specificity**
|
| 38 |
|
| 39 |
+
Use the current Workbench context:
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
```text
|
| 42 |
+
Prompt: The derivative of x squared is
|
| 43 |
+
Residual layer: 14
|
| 44 |
+
Prompt token index: -1
|
| 45 |
+
```
|
| 46 |
|
| 47 |
+
In **Candidates for controlled comparison**, manually enter/select exactly:
|
| 48 |
|
| 49 |
+
```text
|
| 50 |
+
16369
|
| 51 |
+
25992
|
| 52 |
+
21670
|
| 53 |
+
```
|
| 54 |
|
| 55 |
+
These are the three strategic candidates established by the validated v0.10 workflow:
|
| 56 |
|
| 57 |
+
- `16369`: discovery leader and next-token-JS leader;
|
| 58 |
+
- `25992`: raw target-effect leader;
|
| 59 |
+
- `21670`: second-largest raw target effect.
|
| 60 |
|
| 61 |
Set:
|
| 62 |
|
| 63 |
+
```text
|
| 64 |
+
Controlled target continuation: 2x
|
| 65 |
+
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
+
Click **Run controlled candidate comparison**.
|
|
|
|
|
|
|
| 68 |
|
| 69 |
+
### Pass conditions
|
| 70 |
|
| 71 |
+
The run should produce **Controlled candidate specificity** with exactly three feature rows. For every row verify:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
+
- `Active at current token` is `True`;
|
| 74 |
+
- `Target specificity ratio` is finite and non-negative;
|
| 75 |
+
- `Target empirical tail p` lies in `(0, 1]`;
|
| 76 |
+
- `JS specificity ratio` is finite and non-negative;
|
| 77 |
+
- `JS empirical tail p` lies in `(0, 1]`;
|
| 78 |
+
- random-control means/stds are populated;
|
| 79 |
+
- the summary says each candidate used 8 norm-matched random controls;
|
| 80 |
+
- execution-context null drift is reported separately from the causal effect.
|
| 81 |
|
| 82 |
+
The plot **Random-normalized causal specificity** should show two series per feature:
|
| 83 |
|
| 84 |
+
```text
|
| 85 |
+
Target specificity
|
| 86 |
+
JS specificity
|
| 87 |
+
```
|
|
|
|
|
|
|
| 88 |
|
| 89 |
+
Do not assume which feature must win. That is the empirical result this version is intended to measure.
|
| 90 |
|
| 91 |
+
## Optional integration check — no additional GPU call
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
+
If the Space still has the v0.10 discovery table populated in the same browser session, the **Association vs controlled causality** section should also populate automatically.
|
| 94 |
|
| 95 |
+
Verify that **Discovery–controlled-causality alignment** contains the same controlled features and includes:
|
| 96 |
|
| 97 |
+
- discovery rank;
|
| 98 |
+
- specificity rank;
|
| 99 |
+
- target-effect rank;
|
| 100 |
+
- JS-specificity rank;
|
| 101 |
+
- target specificity ratio;
|
| 102 |
+
- target empirical tail p;
|
| 103 |
+
- JS specificity ratio;
|
| 104 |
+
- JS empirical tail p;
|
| 105 |
+
- discovery→specificity rank shift.
|
| 106 |
|
| 107 |
+
The summary may include descriptive Spearman correlations. With only three controlled candidates, these are diagnostic summaries, **not significance claims**.
|
|
|
|
| 108 |
|
| 109 |
+
If the discovery table is not populated because the Space was freshly rebuilt, do **not** spend two extra GPU calls merely to repopulate it for v0.11 acceptance. The zero-GPU join logic is covered by automated tests.
|
| 110 |
|
| 111 |
+
## Do not rerun for v0.11
|
| 112 |
|
| 113 |
+
Do not rerun these paths unless you independently notice a regression:
|
| 114 |
|
| 115 |
+
- Concept-guided candidate discovery;
|
| 116 |
+
- Batched causal candidate triage;
|
| 117 |
+
- Scale dose-response;
|
| 118 |
+
- Single-feature causal test;
|
| 119 |
+
- Paraphrase identity control;
|
| 120 |
+
- Layer trajectory;
|
| 121 |
+
- 1/3/5 feature-set sweep;
|
| 122 |
+
- cue sensitivity or cue × context;
|
| 123 |
+
- plot/table focus behavior.
|
| 124 |
|
| 125 |
+
Those implementations were not changed in v0.11.
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
## What to send back
|
| 128 |
|
| 129 |
+
For the new controlled comparison, send:
|
| 130 |
|
| 131 |
+
1. the three feature rows from **Controlled candidate specificity**;
|
| 132 |
+
2. the summary above the table;
|
| 133 |
+
3. optionally the **Association vs controlled causality** summary/table if it populated without extra GPU work.
|
| 134 |
|
| 135 |
+
That is sufficient to decide whether the live feature set should now be frozen and the project moved to the full offline benchmark.
|
featurelens/runtime.py
CHANGED
|
@@ -255,6 +255,19 @@ class CandidateCausalScreenResult:
|
|
| 255 |
execution_drift_js: float
|
| 256 |
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
@dataclass
|
| 259 |
class FeatureCueScanResult:
|
| 260 |
feature_id: int
|
|
@@ -2131,6 +2144,206 @@ class FeatureLensRuntime:
|
|
| 2131 |
execution_drift_js=float(execution_drift_js),
|
| 2132 |
)
|
| 2133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2134 |
@staticmethod
|
| 2135 |
def _cue_prompt(stem: str, cue: str) -> str:
|
| 2136 |
stem = stem.rstrip()
|
|
|
|
| 255 |
execution_drift_js: float
|
| 256 |
|
| 257 |
|
| 258 |
+
@dataclass
|
| 259 |
+
class CandidateSpecificityResult:
|
| 260 |
+
feature_ids: list[int]
|
| 261 |
+
target_tokens: list[str]
|
| 262 |
+
rows: list[list[object]]
|
| 263 |
+
chart_rows: list[list[object]]
|
| 264 |
+
active_feature_count: int
|
| 265 |
+
candidate_count: int
|
| 266 |
+
random_control_count: int
|
| 267 |
+
execution_drift_mean_logprob: float
|
| 268 |
+
execution_drift_js: float
|
| 269 |
+
|
| 270 |
+
|
| 271 |
@dataclass
|
| 272 |
class FeatureCueScanResult:
|
| 273 |
feature_id: int
|
|
|
|
| 2144 |
execution_drift_js=float(execution_drift_js),
|
| 2145 |
)
|
| 2146 |
|
| 2147 |
+
@torch.inference_mode()
|
| 2148 |
+
def candidate_specificity_screen(
|
| 2149 |
+
self,
|
| 2150 |
+
text: str,
|
| 2151 |
+
layer: int,
|
| 2152 |
+
token_index: int,
|
| 2153 |
+
feature_ids: Sequence[int],
|
| 2154 |
+
target_text: str,
|
| 2155 |
+
) -> CandidateSpecificityResult:
|
| 2156 |
+
"""Compare a small candidate set against per-feature norm-matched random ensembles.
|
| 2157 |
+
|
| 2158 |
+
This is the controlled follow-up to :meth:`candidate_causal_screen`. Each SAE
|
| 2159 |
+
ablation gets its own deterministic random-control ensemble with the same L2 norm.
|
| 2160 |
+
All targeted and random conditions share one batched zero-edit reference so the
|
| 2161 |
+
comparison is both GPU-efficient and execution-context consistent.
|
| 2162 |
+
"""
|
| 2163 |
+
if not text.strip():
|
| 2164 |
+
raise ValueError('Enter and inspect a Workbench prompt first.')
|
| 2165 |
+
if not target_text.strip():
|
| 2166 |
+
raise ValueError('Enter a target continuation for controlled candidate comparison.')
|
| 2167 |
+
ids = list(dict.fromkeys(int(x) for x in feature_ids))
|
| 2168 |
+
if not ids:
|
| 2169 |
+
raise ValueError('Select at least one candidate feature for controlled comparison.')
|
| 2170 |
+
if len(ids) > 3:
|
| 2171 |
+
raise ValueError('Controlled candidate comparison supports at most three features per run.')
|
| 2172 |
+
if any(feature_id < 0 or feature_id >= self.settings.sae_width for feature_id in ids):
|
| 2173 |
+
raise ValueError(f'Feature ids must be in [0, {self.settings.sae_width - 1}].')
|
| 2174 |
+
|
| 2175 |
+
self.ensure_ready(preload_saes=False)
|
| 2176 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 2177 |
+
prompt_inputs = self._inputs(text)
|
| 2178 |
+
prompt_len = int(prompt_inputs['input_ids'].shape[1])
|
| 2179 |
+
idx = self._resolve_index(int(token_index), prompt_len)
|
| 2180 |
+
target_ids = self._target_ids(target_text)
|
| 2181 |
+
full_inputs = self._append_target(prompt_inputs, target_ids)
|
| 2182 |
+
sae = self.sae_store.get(int(layer))
|
| 2183 |
+
|
| 2184 |
+
capture: dict = {}
|
| 2185 |
+
with self._capture_hook(int(layer), capture):
|
| 2186 |
+
single_baseline_out = self.model(**full_inputs, use_cache=False)
|
| 2187 |
+
single_logits = single_baseline_out.logits[0]
|
| 2188 |
+
_, single_mean, _ = sequence_logprob_summary(
|
| 2189 |
+
single_logits, prompt_length=prompt_len, target_ids=target_ids
|
| 2190 |
+
)
|
| 2191 |
+
|
| 2192 |
+
residual = capture['hidden'][0, idx]
|
| 2193 |
+
encoding = sae.encode(residual)
|
| 2194 |
+
activations = [float(encoding.activation_for(feature_id)) for feature_id in ids]
|
| 2195 |
+
|
| 2196 |
+
zero = torch.zeros_like(residual)
|
| 2197 |
+
all_deltas: list[torch.Tensor] = [zero]
|
| 2198 |
+
metadata: list[tuple[int, str, float, float]] = []
|
| 2199 |
+
for feature_id, activation in zip(ids, activations, strict=True):
|
| 2200 |
+
delta = residual_delta(
|
| 2201 |
+
sae.decoder_direction(feature_id),
|
| 2202 |
+
activation,
|
| 2203 |
+
InterventionSpec('ablate', 0.0),
|
| 2204 |
+
)
|
| 2205 |
+
norm = float(torch.linalg.vector_norm(delta.float()).item())
|
| 2206 |
+
all_deltas.append(delta)
|
| 2207 |
+
metadata.append((feature_id, 'sae', activation, norm))
|
| 2208 |
+
seed = self._control_seed(text, int(layer), str(feature_id), 'candidate_specificity', 0.0)
|
| 2209 |
+
controls = self._random_controls(
|
| 2210 |
+
delta, seed=seed, count=self.settings.live_random_controls
|
| 2211 |
+
)
|
| 2212 |
+
for control_idx, control in enumerate(controls):
|
| 2213 |
+
all_deltas.append(control)
|
| 2214 |
+
metadata.append((feature_id, f'random_{control_idx}', activation, norm))
|
| 2215 |
+
|
| 2216 |
+
delta_batch = torch.stack(all_deltas, dim=0)
|
| 2217 |
+
repeated = self._repeat_inputs(full_inputs, delta_batch.shape[0])
|
| 2218 |
+
with self._batch_delta_hook(int(layer), idx, delta_batch):
|
| 2219 |
+
outputs = self.model(**repeated, use_cache=False)
|
| 2220 |
+
|
| 2221 |
+
baseline_logits = outputs.logits[0]
|
| 2222 |
+
baseline_seq, baseline_mean, _ = sequence_logprob_summary(
|
| 2223 |
+
baseline_logits, prompt_length=prompt_len, target_ids=target_ids
|
| 2224 |
+
)
|
| 2225 |
+
baseline_next = baseline_logits[prompt_len - 1]
|
| 2226 |
+
execution_drift_mean = float(baseline_mean - single_mean)
|
| 2227 |
+
execution_drift_js = js_divergence_from_logits(
|
| 2228 |
+
single_logits[prompt_len - 1], baseline_next
|
| 2229 |
+
)
|
| 2230 |
+
|
| 2231 |
+
grouped: dict[int, dict[str, object]] = {
|
| 2232 |
+
feature_id: {
|
| 2233 |
+
'activation': activation,
|
| 2234 |
+
'norm': 0.0,
|
| 2235 |
+
'sae': None,
|
| 2236 |
+
'random': [],
|
| 2237 |
+
}
|
| 2238 |
+
for feature_id, activation in zip(ids, activations, strict=True)
|
| 2239 |
+
}
|
| 2240 |
+
|
| 2241 |
+
for output_idx, meta in enumerate(metadata, start=1):
|
| 2242 |
+
feature_id, kind, activation, norm = meta
|
| 2243 |
+
logits = outputs.logits[output_idx]
|
| 2244 |
+
seq_logp, mean_logp, _ = sequence_logprob_summary(
|
| 2245 |
+
logits, prompt_length=prompt_len, target_ids=target_ids
|
| 2246 |
+
)
|
| 2247 |
+
item = {
|
| 2248 |
+
'seq_delta': float(seq_logp - baseline_seq),
|
| 2249 |
+
'mean_delta': float(mean_logp - baseline_mean),
|
| 2250 |
+
'js': float(js_divergence_from_logits(baseline_next, logits[prompt_len - 1])),
|
| 2251 |
+
}
|
| 2252 |
+
grouped[feature_id]['activation'] = float(activation)
|
| 2253 |
+
grouped[feature_id]['norm'] = float(norm)
|
| 2254 |
+
if kind == 'sae':
|
| 2255 |
+
grouped[feature_id]['sae'] = item
|
| 2256 |
+
else:
|
| 2257 |
+
random_items = grouped[feature_id]['random']
|
| 2258 |
+
assert isinstance(random_items, list)
|
| 2259 |
+
random_items.append(item)
|
| 2260 |
+
|
| 2261 |
+
scored: list[dict[str, object]] = []
|
| 2262 |
+
for feature_id in ids:
|
| 2263 |
+
group = grouped[feature_id]
|
| 2264 |
+
sae_item = group['sae']
|
| 2265 |
+
random_items = group['random']
|
| 2266 |
+
assert isinstance(sae_item, dict)
|
| 2267 |
+
assert isinstance(random_items, list) and random_items
|
| 2268 |
+
mean_delta = float(sae_item['mean_delta'])
|
| 2269 |
+
js = float(sae_item['js'])
|
| 2270 |
+
random_mean_deltas = [float(item['mean_delta']) for item in random_items]
|
| 2271 |
+
random_js_values = [float(item['js']) for item in random_items]
|
| 2272 |
+
random_signed, random_abs, random_std, target_p = self._random_effect_summary(
|
| 2273 |
+
random_mean_deltas, mean_delta
|
| 2274 |
+
)
|
| 2275 |
+
_, random_js_abs, random_js_std, js_p = self._random_effect_summary(
|
| 2276 |
+
random_js_values, js
|
| 2277 |
+
)
|
| 2278 |
+
scored.append(
|
| 2279 |
+
{
|
| 2280 |
+
'feature_id': int(feature_id),
|
| 2281 |
+
'activation': float(group['activation']),
|
| 2282 |
+
'active': bool(float(group['activation']) > 0),
|
| 2283 |
+
'norm': float(group['norm']),
|
| 2284 |
+
'mean_delta': mean_delta,
|
| 2285 |
+
'seq_delta': float(sae_item['seq_delta']),
|
| 2286 |
+
'random_signed': float(random_signed),
|
| 2287 |
+
'random_abs': float(random_abs),
|
| 2288 |
+
'random_std': float(random_std),
|
| 2289 |
+
'target_ratio': float(abs(mean_delta) / max(random_abs, 1e-12)),
|
| 2290 |
+
'target_p': float(target_p),
|
| 2291 |
+
'js': js,
|
| 2292 |
+
'random_js': float(random_js_abs),
|
| 2293 |
+
'random_js_std': float(random_js_std),
|
| 2294 |
+
'js_ratio': float(js / max(random_js_abs, 1e-12)),
|
| 2295 |
+
'js_p': float(js_p),
|
| 2296 |
+
}
|
| 2297 |
+
)
|
| 2298 |
+
|
| 2299 |
+
scored.sort(
|
| 2300 |
+
key=lambda item: (float(item['target_ratio']), abs(float(item['mean_delta']))),
|
| 2301 |
+
reverse=True,
|
| 2302 |
+
)
|
| 2303 |
+
rows: list[list[object]] = []
|
| 2304 |
+
chart_rows: list[list[object]] = []
|
| 2305 |
+
for rank, item in enumerate(scored, start=1):
|
| 2306 |
+
feature_id = int(item['feature_id'])
|
| 2307 |
+
rows.append(
|
| 2308 |
+
[
|
| 2309 |
+
rank,
|
| 2310 |
+
feature_id,
|
| 2311 |
+
float(item['activation']),
|
| 2312 |
+
bool(item['active']),
|
| 2313 |
+
float(item['norm']),
|
| 2314 |
+
float(item['mean_delta']),
|
| 2315 |
+
float(item['random_signed']),
|
| 2316 |
+
float(item['random_abs']),
|
| 2317 |
+
float(item['random_std']),
|
| 2318 |
+
float(item['target_ratio']),
|
| 2319 |
+
float(item['target_p']),
|
| 2320 |
+
float(item['seq_delta']),
|
| 2321 |
+
float(item['js']),
|
| 2322 |
+
float(item['random_js']),
|
| 2323 |
+
float(item['random_js_std']),
|
| 2324 |
+
float(item['js_ratio']),
|
| 2325 |
+
float(item['js_p']),
|
| 2326 |
+
]
|
| 2327 |
+
)
|
| 2328 |
+
chart_rows.extend(
|
| 2329 |
+
[
|
| 2330 |
+
[str(feature_id), 'Target specificity', float(item['target_ratio'])],
|
| 2331 |
+
[str(feature_id), 'JS specificity', float(item['js_ratio'])],
|
| 2332 |
+
]
|
| 2333 |
+
)
|
| 2334 |
+
|
| 2335 |
+
return CandidateSpecificityResult(
|
| 2336 |
+
feature_ids=[int(item['feature_id']) for item in scored],
|
| 2337 |
+
target_tokens=[self.tokenizer.decode([int(token_id)]) for token_id in target_ids],
|
| 2338 |
+
rows=rows,
|
| 2339 |
+
chart_rows=chart_rows,
|
| 2340 |
+
active_feature_count=sum(bool(item['active']) for item in scored),
|
| 2341 |
+
candidate_count=len(scored),
|
| 2342 |
+
random_control_count=self.settings.live_random_controls,
|
| 2343 |
+
execution_drift_mean_logprob=execution_drift_mean,
|
| 2344 |
+
execution_drift_js=float(execution_drift_js),
|
| 2345 |
+
)
|
| 2346 |
+
|
| 2347 |
@staticmethod
|
| 2348 |
def _cue_prompt(stem: str, cue: str) -> str:
|
| 2349 |
stem = stem.rstrip()
|
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.11.0"
|
| 4 |
description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
|
| 5 |
requires-python = ">=3.10"
|
| 6 |
|
research_config.json
CHANGED
|
@@ -90,7 +90,7 @@
|
|
| 90 |
"contrastive_continuation_preference_test",
|
| 91 |
"feature_decoder_geometry"
|
| 92 |
],
|
| 93 |
-
"concept_candidate_discovery_metric": "balanced exploratory score = selectivity
|
| 94 |
"completion_cue_scan": "final-token feature activation after controlled suffix/cue substitution",
|
| 95 |
"live_features_v0_6": [
|
| 96 |
"start_here_plain_language_onboarding",
|
|
@@ -139,5 +139,20 @@
|
|
| 139 |
"descriptive_spearman_concordance_summary",
|
| 140 |
"target_effect_vs_distribution_shift_rank_separation",
|
| 141 |
"no_extra_gpu_candidate_synthesis"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
]
|
| 143 |
}
|
|
|
|
| 90 |
"contrastive_continuation_preference_test",
|
| 91 |
"feature_decoder_geometry"
|
| 92 |
],
|
| 93 |
+
"concept_candidate_discovery_metric": "balanced exploratory score = selectivity \u00d7 target activation rate \u00d7 log1p(target mean); causal-ready mode additionally requires current-token activity and log-scales that activation; raw mean-difference remains available as a scale-sensitive comparison",
|
| 94 |
"completion_cue_scan": "final-token feature activation after controlled suffix/cue substitution",
|
| 95 |
"live_features_v0_6": [
|
| 96 |
"start_here_plain_language_onboarding",
|
|
|
|
| 139 |
"descriptive_spearman_concordance_summary",
|
| 140 |
"target_effect_vs_distribution_shift_rank_separation",
|
| 141 |
"no_extra_gpu_candidate_synthesis"
|
| 142 |
+
],
|
| 143 |
+
"candidate_specificity_limit": 3,
|
| 144 |
+
"candidate_specificity_control": "each candidate gets live_random_controls deterministic norm-matched residual directions in one shared batched zero-edit execution context",
|
| 145 |
+
"candidate_specificity_metrics": [
|
| 146 |
+
"target effect versus candidate-specific random mean absolute effect",
|
| 147 |
+
"next-token JS versus candidate-specific random mean JS",
|
| 148 |
+
"coarse empirical random-control tail probabilities",
|
| 149 |
+
"discovery rank versus random-normalized target-specificity rank"
|
| 150 |
+
],
|
| 151 |
+
"live_features_v0_11": [
|
| 152 |
+
"controlled_multi_candidate_random_specificity_screen",
|
| 153 |
+
"strategic_discovery_target_js_shortlist",
|
| 154 |
+
"association_vs_controlled_causality_alignment",
|
| 155 |
+
"target_specificity_vs_js_specificity_separation",
|
| 156 |
+
"single_new_gpu_call_hf_acceptance"
|
| 157 |
]
|
| 158 |
}
|
scripts/release_check.py
CHANGED
|
@@ -84,6 +84,7 @@ def check_config(config: dict) -> None:
|
|
| 84 |
'live_geometry_feature_limit': 8,
|
| 85 |
'concept_contrast_pooling': 'max activation across non-padding prompt tokens',
|
| 86 |
'candidate_causal_screen_limit': 8,
|
|
|
|
| 87 |
}
|
| 88 |
for key, value in expected.items():
|
| 89 |
if config.get(key) != value:
|
|
@@ -179,6 +180,32 @@ def check_config(config: dict) -> None:
|
|
| 179 |
'research_config.json live_features_v0_9 mismatch: ' f'{sorted(actual_live_v09)}'
|
| 180 |
)
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
if 'german_language' not in config.get('concepts', []) or 'french_language' in config.get('concepts', []):
|
| 183 |
raise SystemExit('research_config.json must use german_language and must not contain french_language.')
|
| 184 |
|
|
@@ -260,16 +287,21 @@ def check_readme() -> None:
|
|
| 260 |
'spearman',
|
| 261 |
'rank-shift',
|
| 262 |
'no additional gpu',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
]
|
| 264 |
missing = [value for value in required_strings if value.lower() not in readme.lower()]
|
| 265 |
if missing:
|
| 266 |
-
raise SystemExit(f'README.md is missing required v0.
|
| 267 |
|
| 268 |
|
| 269 |
def check_pyproject() -> None:
|
| 270 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 271 |
-
if 'version = "0.
|
| 272 |
-
raise SystemExit('pyproject.toml must declare version 0.
|
| 273 |
|
| 274 |
|
| 275 |
def main() -> None:
|
|
@@ -287,7 +319,7 @@ def main() -> None:
|
|
| 287 |
print(f' layers: {config["layers"]}')
|
| 288 |
print(f' feature-set sizes: {config["feature_set_sizes"]}')
|
| 289 |
print(f' random controls: {config["live_random_controls"]}')
|
| 290 |
-
print(' release: v0.
|
| 291 |
|
| 292 |
|
| 293 |
if __name__ == '__main__':
|
|
|
|
| 84 |
'live_geometry_feature_limit': 8,
|
| 85 |
'concept_contrast_pooling': 'max activation across non-padding prompt tokens',
|
| 86 |
'candidate_causal_screen_limit': 8,
|
| 87 |
+
'candidate_specificity_limit': 3,
|
| 88 |
}
|
| 89 |
for key, value in expected.items():
|
| 90 |
if config.get(key) != value:
|
|
|
|
| 180 |
'research_config.json live_features_v0_9 mismatch: ' f'{sorted(actual_live_v09)}'
|
| 181 |
)
|
| 182 |
|
| 183 |
+
required_live_v10 = {
|
| 184 |
+
'discovery_to_causality_alignment_table',
|
| 185 |
+
'association_evidence_vs_target_effect_scatter',
|
| 186 |
+
'descriptive_spearman_concordance_summary',
|
| 187 |
+
'target_effect_vs_distribution_shift_rank_separation',
|
| 188 |
+
'no_extra_gpu_candidate_synthesis',
|
| 189 |
+
}
|
| 190 |
+
actual_live_v10 = set(config.get('live_features_v0_10', []))
|
| 191 |
+
if actual_live_v10 != required_live_v10:
|
| 192 |
+
raise SystemExit(
|
| 193 |
+
'research_config.json live_features_v0_10 mismatch: ' f'{sorted(actual_live_v10)}'
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
required_live_v11 = {
|
| 197 |
+
'controlled_multi_candidate_random_specificity_screen',
|
| 198 |
+
'strategic_discovery_target_js_shortlist',
|
| 199 |
+
'association_vs_controlled_causality_alignment',
|
| 200 |
+
'target_specificity_vs_js_specificity_separation',
|
| 201 |
+
'single_new_gpu_call_hf_acceptance',
|
| 202 |
+
}
|
| 203 |
+
actual_live_v11 = set(config.get('live_features_v0_11', []))
|
| 204 |
+
if actual_live_v11 != required_live_v11:
|
| 205 |
+
raise SystemExit(
|
| 206 |
+
'research_config.json live_features_v0_11 mismatch: ' f'{sorted(actual_live_v11)}'
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
if 'german_language' not in config.get('concepts', []) or 'french_language' in config.get('concepts', []):
|
| 210 |
raise SystemExit('research_config.json must use german_language and must not contain french_language.')
|
| 211 |
|
|
|
|
| 287 |
'spearman',
|
| 288 |
'rank-shift',
|
| 289 |
'no additional gpu',
|
| 290 |
+
'controlled candidate specificity',
|
| 291 |
+
'norm-matched random ensemble',
|
| 292 |
+
'association vs controlled causality',
|
| 293 |
+
'target-specificity ratio',
|
| 294 |
+
'js-specificity ratio',
|
| 295 |
]
|
| 296 |
missing = [value for value in required_strings if value.lower() not in readme.lower()]
|
| 297 |
if missing:
|
| 298 |
+
raise SystemExit(f'README.md is missing required v0.11 content: {missing}')
|
| 299 |
|
| 300 |
|
| 301 |
def check_pyproject() -> None:
|
| 302 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 303 |
+
if 'version = "0.11.0"' not in text:
|
| 304 |
+
raise SystemExit('pyproject.toml must declare version 0.11.0.')
|
| 305 |
|
| 306 |
|
| 307 |
def main() -> None:
|
|
|
|
| 319 |
print(f' layers: {config["layers"]}')
|
| 320 |
print(f' feature-set sizes: {config["feature_set_sizes"]}')
|
| 321 |
print(f' random controls: {config["live_random_controls"]}')
|
| 322 |
+
print(' release: v0.11.0')
|
| 323 |
|
| 324 |
|
| 325 |
if __name__ == '__main__':
|
tests/test_live_runtime_helpers.py
CHANGED
|
@@ -342,3 +342,24 @@ def test_candidate_causal_screen_batches_multiple_ablation_candidates() -> None:
|
|
| 342 |
assert 0 <= result.active_feature_count <= result.candidate_count
|
| 343 |
assert all(math.isfinite(float(row[5])) for row in result.rows)
|
| 344 |
assert all(float(row[7]) >= 0 for row in result.rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
assert 0 <= result.active_feature_count <= result.candidate_count
|
| 343 |
assert all(math.isfinite(float(row[5])) for row in result.rows)
|
| 344 |
assert all(float(row[7]) >= 0 for row in result.rows)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def test_candidate_specificity_screen_batches_random_controlled_candidates() -> None:
|
| 348 |
+
runtime = make_runtime()
|
| 349 |
+
result = runtime.candidate_specificity_screen(
|
| 350 |
+
text='abc',
|
| 351 |
+
layer=0,
|
| 352 |
+
token_index=-1,
|
| 353 |
+
feature_ids=[0, 1],
|
| 354 |
+
target_text='d',
|
| 355 |
+
)
|
| 356 |
+
assert result.candidate_count == 2
|
| 357 |
+
assert result.random_control_count == 3
|
| 358 |
+
assert len(result.rows) == 2
|
| 359 |
+
assert len(result.chart_rows) == 4
|
| 360 |
+
assert all(len(row) == 17 for row in result.rows)
|
| 361 |
+
assert all(row[0] == rank for rank, row in enumerate(result.rows, start=1))
|
| 362 |
+
assert all(float(row[9]) >= 0 for row in result.rows) # target specificity
|
| 363 |
+
assert all(0 < float(row[10]) <= 1 for row in result.rows) # empirical tail
|
| 364 |
+
assert all(float(row[15]) >= 0 for row in result.rows) # JS specificity
|
| 365 |
+
assert all(0 < float(row[16]) <= 1 for row in result.rows)
|
tests/test_ui_helpers.py
CHANGED
|
@@ -181,3 +181,78 @@ def test_candidate_alignment_ui_uses_same_screen_call_and_no_new_gpu_button() ->
|
|
| 181 |
assert app.candidate_alignment_table.show_label is False
|
| 182 |
assert app.candidate_alignment_plot.visible is True
|
| 183 |
assert 'Association evidence vs target effect' == app.candidate_alignment_plot.title
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
assert app.candidate_alignment_table.show_label is False
|
| 182 |
assert app.candidate_alignment_plot.visible is True
|
| 183 |
assert 'Association evidence vs target effect' == app.candidate_alignment_plot.title
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _v10_discovery_table() -> pd.DataFrame:
|
| 187 |
+
return pd.DataFrame(
|
| 188 |
+
[
|
| 189 |
+
[1, 16369, 4.7348, 15.0, 0.0, 15.0, 1.0, 0.5, 0.0, 50.0, 29.25, True],
|
| 190 |
+
[2, 5712, 4.1969, 9.1, 1.5, 7.6, 0.72, 1.0, 0.17, 11.4, 11.38, True],
|
| 191 |
+
[3, 26112, 3.9419, 10.8, 0.0, 10.8, 1.0, 0.5, 0.0, 23.4, 23.42, True],
|
| 192 |
+
[4, 25992, 3.7342, 10.1, 0.0, 10.1, 1.0, 0.5, 0.0, 21.4, 21.36, True],
|
| 193 |
+
[5, 21670, 3.6200, 18.9, 2.0, 16.9, 0.81, 0.75, 0.125, 25.3, 6.36, True],
|
| 194 |
+
],
|
| 195 |
+
columns=[
|
| 196 |
+
'Rank', 'Feature id', 'Candidate score', 'Target mean max', 'Other mean max',
|
| 197 |
+
'Mean difference', 'Selectivity', 'Target activation rate', 'Other activation rate',
|
| 198 |
+
'Current prompt max', 'Current token activation', 'Active at current token',
|
| 199 |
+
],
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _v10_screen_table() -> pd.DataFrame:
|
| 204 |
+
return pd.DataFrame(
|
| 205 |
+
[
|
| 206 |
+
[1, 25992, 21.3, True, 21.3, -0.1119, -0.2238, 0.001066],
|
| 207 |
+
[2, 21670, 6.36, True, 6.36, 0.0313, 0.0626, 0.000106],
|
| 208 |
+
[3, 26112, 23.4, True, 23.4, -0.0211, -0.0423, 0.000417],
|
| 209 |
+
[4, 5712, 11.38, True, 11.38, 0.0110, 0.0221, 0.000155],
|
| 210 |
+
[5, 16369, 29.23, True, 29.23, -0.0101, -0.0201, 0.001714],
|
| 211 |
+
],
|
| 212 |
+
columns=[
|
| 213 |
+
'Rank', 'Feature id', 'Native activation', 'Active at current token', 'Perturbation L2',
|
| 214 |
+
'Δ mean log p/token', 'Δ sequence log p', 'Next-token JS',
|
| 215 |
+
],
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_controlled_candidate_shortlist_preserves_discovery_and_causal_leaders() -> None:
|
| 220 |
+
app = _import_app()
|
| 221 |
+
selected = app._controlled_candidate_shortlist(_v10_discovery_table(), _v10_screen_table(), limit=3)
|
| 222 |
+
assert selected == ['16369', '25992', '21670']
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def test_controlled_alignment_uses_random_normalized_specificity() -> None:
|
| 226 |
+
app = _import_app()
|
| 227 |
+
controlled = pd.DataFrame(
|
| 228 |
+
[
|
| 229 |
+
[1, 25992, 21.3, True, 21.3, -0.1119, -0.01, 0.04, 0.02, 2.80, 0.111, -0.2238, 0.001066, 0.00040, 0.0001, 2.665, 0.111],
|
| 230 |
+
[2, 16369, 29.2, True, 29.2, -0.0101, 0.00, 0.05, 0.03, 0.20, 0.778, -0.0201, 0.001714, 0.00050, 0.0002, 3.428, 0.111],
|
| 231 |
+
[3, 21670, 6.36, True, 6.36, 0.0313, 0.00, 0.03, 0.01, 1.04, 0.444, 0.0626, 0.000106, 0.00020, 0.0001, 0.53, 0.778],
|
| 232 |
+
],
|
| 233 |
+
columns=[
|
| 234 |
+
'Rank', 'Feature id', 'Native activation', 'Active at current token', 'Perturbation L2',
|
| 235 |
+
'SAE Δ mean log p/token', 'Random signed mean Δ', 'Random mean |Δ|', 'Random |Δ| std',
|
| 236 |
+
'Target specificity ratio', 'Target empirical tail p', 'SAE Δ sequence log p',
|
| 237 |
+
'SAE next-token JS', 'Random mean JS', 'Random JS std', 'JS specificity ratio',
|
| 238 |
+
'JS empirical tail p',
|
| 239 |
+
],
|
| 240 |
+
)
|
| 241 |
+
summary, table, chart = app._controlled_alignment_outputs(_v10_discovery_table(), controlled)
|
| 242 |
+
assert len(table) == 3
|
| 243 |
+
assert int(table.loc[table['Specificity rank'].idxmin(), 'Feature id']) == 25992
|
| 244 |
+
row_25992 = table.loc[table['Feature id'] == 25992].iloc[0]
|
| 245 |
+
row_16369 = table.loc[table['Feature id'] == 16369].iloc[0]
|
| 246 |
+
assert int(row_25992['Discovery→specificity rank shift']) == 3
|
| 247 |
+
assert int(row_16369['Discovery→specificity rank shift']) == -1
|
| 248 |
+
assert 'norm-matched random ensemble' in summary
|
| 249 |
+
assert set(chart['Feature id']) == {'25992', '16369', '21670'}
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def test_controlled_candidate_ui_limits_live_comparison_to_three_features() -> None:
|
| 253 |
+
app = _import_app()
|
| 254 |
+
assert app.candidate_specificity_ids.multiselect is True
|
| 255 |
+
assert app.candidate_specificity_ids.max_choices == 3
|
| 256 |
+
assert app.candidate_specificity_target.value == '2x'
|
| 257 |
+
assert app.candidate_specificity_table.show_label is False
|
| 258 |
+
assert app.controlled_alignment_table.show_label is False
|