Spaces:
Running on Zero
Running on Zero
Commit ·
0481a55
1
Parent(s): 0536091
Release FeatureLens v0.13.0
Browse files- CHANGELOG.md +7 -0
- README.md +19 -1
- app.py +82 -3
- docs/METHODOLOGY.md +14 -0
- docs/VALIDATION.md +40 -82
- featurelens/runtime.py +134 -16
- pyproject.toml +1 -1
- research_config.json +8 -0
- scripts/release_check.py +23 -4
- tests/test_live_runtime_helpers.py +33 -4
- tests/test_ui_helpers.py +55 -0
CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
## v0.12.0
|
|
|
|
| 1 |
+
## v0.13.0
|
| 2 |
+
|
| 3 |
+
- Added deterministic **32-resample candidate-support diagnostics** from the same concept-discovery activation batch; each displayed feature now reports shortlist support and median resample rank without another model forward.
|
| 4 |
+
- Extended cross-target profiling with **normalized effect entropy, effect concentration, signed bias, and a descriptive profile pattern** so concentrated target dependence is separated from broad same-sign behavior.
|
| 5 |
+
- Added **pairwise target-preference shifts** derived from the same cross-target scores: Δ(A−B) = Δmean(A) − Δmean(B), with a table and plot and no additional inference.
|
| 6 |
+
- Kept HF acceptance quota-aware: only concept discovery and cross-target profiling are touched GPU paths.
|
| 7 |
+
|
| 8 |
# Changelog
|
| 9 |
|
| 10 |
## v0.12.0
|
README.md
CHANGED
|
@@ -13,7 +13,7 @@ license: mit
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
-
> **v0.
|
| 17 |
|
| 18 |
**Research question:**
|
| 19 |
|
|
@@ -412,3 +412,21 @@ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.12 guide uses the **exact
|
|
| 412 |
## Acknowledgements
|
| 413 |
|
| 414 |
FeatureLens builds on the open Qwen3 model and Qwen-Scope residual-stream SAE checkpoints from the Qwen team.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
+
> **v0.13:** a stability-and-target-selectivity release that adds deterministic resample support to live discovery and extracts effect concentration, signed bias, and pairwise target-preference shifts from the existing cross-target GPU call.
|
| 17 |
|
| 18 |
**Research question:**
|
| 19 |
|
|
|
|
| 412 |
## Acknowledgements
|
| 413 |
|
| 414 |
FeatureLens builds on the open Qwen3 model and Qwen-Scope residual-stream SAE checkpoints from the Qwen team.
|
| 415 |
+
|
| 416 |
+
## v0.13 stability and target-selectivity synthesis
|
| 417 |
+
|
| 418 |
+
v0.13 deliberately adds **no new GPU callback**. It strengthens the two live paths already used in v0.12.
|
| 419 |
+
|
| 420 |
+
### Resample shortlist support
|
| 421 |
+
|
| 422 |
+
Concept-guided discovery still reports split-half Jaccard, but now also reranks the same already-computed controlled activations under **32 deterministic balanced bootstrap resamples**. Each displayed feature receives a **Resample shortlist support** fraction and **Median resample rank**. The summary reports the mean displayed support and how many candidates survive at least 75% of resampled shortlists. These are small-sample sensitivity diagnostics, not confidence intervals or held-out reliability claims.
|
| 423 |
+
|
| 424 |
+
### Cross-target effect concentration
|
| 425 |
+
|
| 426 |
+
The cross-target profile now separates a feature whose causal effect is concentrated on one exact continuation from one that broadly moves several alternatives. For each feature it reports **Normalized effect entropy**, **Effect concentration = 1 − entropy**, and **Signed bias = ΣΔ / Σ|Δ|**. A descriptive profile pattern labels cases such as target-concentrated/mixed-sign or broad same-sign suppression/enhancement; this label is a heuristic summary, not a semantic feature label.
|
| 427 |
+
|
| 428 |
+
### Pairwise target preference
|
| 429 |
+
|
| 430 |
+
Every cross-target run also derives **pairwise target preference shifts** without another forward pass. For targets A and B, FeatureLens reports `Δ(A−B) = Δ mean log p/token(A) − Δ mean log p/token(B)`. Positive values mean the SAE ablation shifts token-normalized preference toward A relative to B. This turns the same cross-target scores into a more direct comparison of alternatives while keeping the random-controlled specificity experiment separate.
|
| 431 |
+
|
| 432 |
+
See [`docs/VALIDATION.md`](docs/VALIDATION.md). v0.13 requires only the two touched GPU paths: causal-ready discovery and cross-target profiling.
|
app.py
CHANGED
|
@@ -605,6 +605,13 @@ def _discovery_metrics_markdown(result) -> str:
|
|
| 605 |
f"**{result.split_half_shared_count}** shared candidate(s), Jaccard **{result.split_half_jaccard:.3f}**. "
|
| 606 |
f"This is a small-sample sensitivity diagnostic, not a reliability estimate."
|
| 607 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
return (
|
| 609 |
f"Concept **{result.concept}** · layer **{result.layer}** · "
|
| 610 |
f"{result.prompts_per_concept} prompts/concept. \n"
|
|
@@ -933,14 +940,25 @@ def _cross_target_metrics_markdown(result) -> str:
|
|
| 933 |
f"Largest screened cross-target effect: feature **{int(strongest[0])}** on **{strongest[1]!r}** "
|
| 934 |
f"with Δ mean log p/token **{float(strongest[2]):+.4f}**."
|
| 935 |
)
|
|
|
|
|
|
|
| 936 |
else:
|
| 937 |
lead = "No cross-target rows were produced."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 938 |
return (
|
| 939 |
f"Profiled feature(s) **{feature_text}** across exact continuation(s) {target_text}; "
|
| 940 |
f"**{result.active_feature_count}/{len(result.feature_ids)}** selected features were active at the Workbench token. \n"
|
| 941 |
-
f"{lead} \n\n"
|
| 942 |
"This is a **target-profile screen** using native SAE ablations and a batched no-edit reference for each continuation. "
|
| 943 |
-
"
|
|
|
|
| 944 |
)
|
| 945 |
|
| 946 |
|
|
@@ -1626,6 +1644,8 @@ def run_concept_feature_discovery(
|
|
| 1626 |
"Current prompt max",
|
| 1627 |
"Current token activation",
|
| 1628 |
"Active at current token",
|
|
|
|
|
|
|
| 1629 |
]
|
| 1630 |
table = pd.DataFrame(result.rows, columns=columns)
|
| 1631 |
chart = pd.DataFrame(result.chart_rows, columns=["Feature", "Candidate score"])
|
|
@@ -1823,16 +1843,36 @@ def run_candidate_cross_target_profile(
|
|
| 1823 |
"Mean |effect| on other targets",
|
| 1824 |
"Target-profile ratio",
|
| 1825 |
"Effect sign pattern",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1826 |
"Maximum next-token JS",
|
| 1827 |
]
|
| 1828 |
summary_table = pd.DataFrame(result.summary_rows, columns=summary_columns)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1829 |
return (
|
| 1830 |
_cross_target_metrics_markdown(result),
|
| 1831 |
table,
|
| 1832 |
chart,
|
| 1833 |
summary_table,
|
|
|
|
|
|
|
| 1834 |
_tsv(table),
|
| 1835 |
_tsv(summary_table),
|
|
|
|
| 1836 |
)
|
| 1837 |
except Exception as exc:
|
| 1838 |
_raise_ui_error(exc)
|
|
@@ -1919,7 +1959,7 @@ def set_mode_help(mode: str):
|
|
| 1919 |
with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
|
| 1920 |
gr.HTML(
|
| 1921 |
'<header class="hero">'
|
| 1922 |
-
'<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.
|
| 1923 |
'<div class="subtitle">Causal Interpretability Workbench</div>'
|
| 1924 |
'<div class="question">Discover sparse features, test robustness, and separate correlation from causal influence.</div>'
|
| 1925 |
'</header>'
|
|
@@ -2738,6 +2778,41 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
|
|
| 2738 |
cross_target_summary_tsv = gr.Textbox(visible="hidden")
|
| 2739 |
cross_target_summary_copy = _copy_button()
|
| 2740 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2741 |
gr.HTML('<div class="section-rule">D. Inspect one feature</div>')
|
| 2742 |
contrast_location = gr.Markdown(
|
| 2743 |
"**Activation trace uses the current Workbench prompt.** The controlled concept scan below uses its own balanced prompt set."
|
|
@@ -3225,8 +3300,11 @@ Association, robustness, geometry, and intervention evidence remain separate cla
|
|
| 3225 |
cross_target_table,
|
| 3226 |
cross_target_plot,
|
| 3227 |
cross_target_summary_table,
|
|
|
|
|
|
|
| 3228 |
cross_target_tsv,
|
| 3229 |
cross_target_summary_tsv,
|
|
|
|
| 3230 |
],
|
| 3231 |
)
|
| 3232 |
candidate_specificity_table.select(
|
|
@@ -3295,6 +3373,7 @@ Association, robustness, geometry, and intervention evidence remain separate cla
|
|
| 3295 |
(controlled_alignment_copy, controlled_alignment_tsv),
|
| 3296 |
(cross_target_copy, cross_target_tsv),
|
| 3297 |
(cross_target_summary_copy, cross_target_summary_tsv),
|
|
|
|
| 3298 |
(cue_copy, cue_tsv),
|
| 3299 |
(cue_context_copy, cue_context_tsv),
|
| 3300 |
(para_copy, para_tsv),
|
|
|
|
| 605 |
f"**{result.split_half_shared_count}** shared candidate(s), Jaccard **{result.split_half_jaccard:.3f}**. "
|
| 606 |
f"This is a small-sample sensitivity diagnostic, not a reliability estimate."
|
| 607 |
)
|
| 608 |
+
if result.resample_replicates and result.resample_mean_support is not None:
|
| 609 |
+
stability += (
|
| 610 |
+
f" \nDeterministic balanced-resample support ({result.resample_replicates} resamples, same activations): "
|
| 611 |
+
f"mean support across displayed candidates **{result.resample_mean_support:.1%}**; "
|
| 612 |
+
f"**{result.resample_high_support_count}/{len(result.candidate_ids)}** displayed candidates appeared in at least 75% "
|
| 613 |
+
"of resampled shortlists. This is still a live small-sample stability diagnostic, not a confidence interval."
|
| 614 |
+
)
|
| 615 |
return (
|
| 616 |
f"Concept **{result.concept}** · layer **{result.layer}** · "
|
| 617 |
f"{result.prompts_per_concept} prompts/concept. \n"
|
|
|
|
| 940 |
f"Largest screened cross-target effect: feature **{int(strongest[0])}** on **{strongest[1]!r}** "
|
| 941 |
f"with Δ mean log p/token **{float(strongest[2]):+.4f}**."
|
| 942 |
)
|
| 943 |
+
patterns = "; ".join(f"{int(row[0])}: {row[10]}" for row in result.summary_rows)
|
| 944 |
+
profile_text = f" \nHeuristic target-profile patterns — {patterns}."
|
| 945 |
else:
|
| 946 |
lead = "No cross-target rows were produced."
|
| 947 |
+
profile_text = ""
|
| 948 |
+
pairwise_text = ""
|
| 949 |
+
if result.pairwise_rows:
|
| 950 |
+
top_pair = result.pairwise_rows[0]
|
| 951 |
+
pairwise_text = (
|
| 952 |
+
f" \nLargest token-normalized pairwise preference shift: feature **{int(top_pair[0])}**, "
|
| 953 |
+
f"**{top_pair[1]!r} vs {top_pair[2]!r}**, Δ(A−B) **{float(top_pair[3]):+.4f}**."
|
| 954 |
+
)
|
| 955 |
return (
|
| 956 |
f"Profiled feature(s) **{feature_text}** across exact continuation(s) {target_text}; "
|
| 957 |
f"**{result.active_feature_count}/{len(result.feature_ids)}** selected features were active at the Workbench token. \n"
|
| 958 |
+
f"{lead}{profile_text}{pairwise_text} \n\n"
|
| 959 |
"This is a **target-profile screen** using native SAE ablations and a batched no-edit reference for each continuation. "
|
| 960 |
+
"The profile labels are descriptive heuristics, and pairwise shifts compare token-normalized target effects; neither spends "
|
| 961 |
+
"random controls. Use Controlled candidate specificity for matched-random causal claims."
|
| 962 |
)
|
| 963 |
|
| 964 |
|
|
|
|
| 1644 |
"Current prompt max",
|
| 1645 |
"Current token activation",
|
| 1646 |
"Active at current token",
|
| 1647 |
+
"Resample shortlist support",
|
| 1648 |
+
"Median resample rank",
|
| 1649 |
]
|
| 1650 |
table = pd.DataFrame(result.rows, columns=columns)
|
| 1651 |
chart = pd.DataFrame(result.chart_rows, columns=["Feature", "Candidate score"])
|
|
|
|
| 1843 |
"Mean |effect| on other targets",
|
| 1844 |
"Target-profile ratio",
|
| 1845 |
"Effect sign pattern",
|
| 1846 |
+
"Normalized effect entropy",
|
| 1847 |
+
"Effect concentration",
|
| 1848 |
+
"Signed bias",
|
| 1849 |
+
"Profile pattern",
|
| 1850 |
"Maximum next-token JS",
|
| 1851 |
]
|
| 1852 |
summary_table = pd.DataFrame(result.summary_rows, columns=summary_columns)
|
| 1853 |
+
pairwise_columns = [
|
| 1854 |
+
"Feature id",
|
| 1855 |
+
"Target A",
|
| 1856 |
+
"Target B",
|
| 1857 |
+
"Δ normalized preference A−B",
|
| 1858 |
+
"|Preference shift|",
|
| 1859 |
+
"Direction",
|
| 1860 |
+
]
|
| 1861 |
+
pairwise_table = pd.DataFrame(result.pairwise_rows, columns=pairwise_columns)
|
| 1862 |
+
pairwise_chart = pairwise_table.copy()
|
| 1863 |
+
if not pairwise_chart.empty:
|
| 1864 |
+
pairwise_chart["Target pair"] = pairwise_chart["Target A"].astype(str) + " vs " + pairwise_chart["Target B"].astype(str)
|
| 1865 |
+
pairwise_chart["Feature"] = pairwise_chart["Feature id"].astype(str)
|
| 1866 |
return (
|
| 1867 |
_cross_target_metrics_markdown(result),
|
| 1868 |
table,
|
| 1869 |
chart,
|
| 1870 |
summary_table,
|
| 1871 |
+
pairwise_table,
|
| 1872 |
+
pairwise_chart,
|
| 1873 |
_tsv(table),
|
| 1874 |
_tsv(summary_table),
|
| 1875 |
+
_tsv(pairwise_table),
|
| 1876 |
)
|
| 1877 |
except Exception as exc:
|
| 1878 |
_raise_ui_error(exc)
|
|
|
|
| 1959 |
with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_width=True) as demo:
|
| 1960 |
gr.HTML(
|
| 1961 |
'<header class="hero">'
|
| 1962 |
+
'<h1>FeatureLens <span style="font-size:.48em;opacity:.58;font-weight:400">v0.13</span></h1>'
|
| 1963 |
'<div class="subtitle">Causal Interpretability Workbench</div>'
|
| 1964 |
'<div class="question">Discover sparse features, test robustness, and separate correlation from causal influence.</div>'
|
| 1965 |
'</header>'
|
|
|
|
| 2778 |
cross_target_summary_tsv = gr.Textbox(visible="hidden")
|
| 2779 |
cross_target_summary_copy = _copy_button()
|
| 2780 |
|
| 2781 |
+
gr.Markdown("#### Pairwise target preference shifts")
|
| 2782 |
+
gr.Markdown(
|
| 2783 |
+
"Derived from the same cross-target scores with **no additional model call**. For each feature, "
|
| 2784 |
+
"Δ(A−B) = Δ mean log p/token(A) − Δ mean log p/token(B), so positive values mean the ablation shifts "
|
| 2785 |
+
"token-normalized preference toward A relative to B."
|
| 2786 |
+
)
|
| 2787 |
+
with gr.Row(equal_height=False):
|
| 2788 |
+
with gr.Column(scale=3):
|
| 2789 |
+
_table_heading('Pairwise target preference shifts')
|
| 2790 |
+
cross_target_pairwise_table = gr.Dataframe(
|
| 2791 |
+
interactive=False,
|
| 2792 |
+
label="Pairwise target preference shifts",
|
| 2793 |
+
show_label=False,
|
| 2794 |
+
buttons=["fullscreen"],
|
| 2795 |
+
elem_classes=["result-table"],
|
| 2796 |
+
wrap=False,
|
| 2797 |
+
max_height=340,
|
| 2798 |
+
)
|
| 2799 |
+
cross_target_pairwise_tsv = gr.Textbox(visible="hidden")
|
| 2800 |
+
cross_target_pairwise_copy = _copy_button()
|
| 2801 |
+
with gr.Column(scale=2):
|
| 2802 |
+
cross_target_pairwise_plot = gr.BarPlot(
|
| 2803 |
+
x="Target pair",
|
| 2804 |
+
y="Δ normalized preference A−B",
|
| 2805 |
+
color="Feature",
|
| 2806 |
+
title="Pairwise target preference shifts",
|
| 2807 |
+
elem_id="plot-cross-target-pairwise",
|
| 2808 |
+
x_title="Target pair",
|
| 2809 |
+
y_title="Δ normalized preference A−B",
|
| 2810 |
+
x_label_angle=-30,
|
| 2811 |
+
buttons=["fullscreen", "export"],
|
| 2812 |
+
elem_classes=["fl-plot"],
|
| 2813 |
+
height=330,
|
| 2814 |
+
)
|
| 2815 |
+
|
| 2816 |
gr.HTML('<div class="section-rule">D. Inspect one feature</div>')
|
| 2817 |
contrast_location = gr.Markdown(
|
| 2818 |
"**Activation trace uses the current Workbench prompt.** The controlled concept scan below uses its own balanced prompt set."
|
|
|
|
| 3300 |
cross_target_table,
|
| 3301 |
cross_target_plot,
|
| 3302 |
cross_target_summary_table,
|
| 3303 |
+
cross_target_pairwise_table,
|
| 3304 |
+
cross_target_pairwise_plot,
|
| 3305 |
cross_target_tsv,
|
| 3306 |
cross_target_summary_tsv,
|
| 3307 |
+
cross_target_pairwise_tsv,
|
| 3308 |
],
|
| 3309 |
)
|
| 3310 |
candidate_specificity_table.select(
|
|
|
|
| 3373 |
(controlled_alignment_copy, controlled_alignment_tsv),
|
| 3374 |
(cross_target_copy, cross_target_tsv),
|
| 3375 |
(cross_target_summary_copy, cross_target_summary_tsv),
|
| 3376 |
+
(cross_target_pairwise_copy, cross_target_pairwise_tsv),
|
| 3377 |
(cue_copy, cue_tsv),
|
| 3378 |
(cue_context_copy, cue_context_tsv),
|
| 3379 |
(para_copy, para_tsv),
|
docs/METHODOLOGY.md
CHANGED
|
@@ -569,3 +569,17 @@ R_{profile} = \frac{\max_t |\Delta \bar{\ell}_t|}{\operatorname{mean}_{u \ne t^*
|
|
| 569 |
\]
|
| 570 |
|
| 571 |
This profile is a screening diagnostic and intentionally omits random controls. Random-normalized causal claims still require the Controlled candidate specificity experiment.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 569 |
\]
|
| 570 |
|
| 571 |
This profile is a screening diagnostic and intentionally omits random controls. Random-normalized causal claims still require the Controlled candidate specificity experiment.
|
| 572 |
+
|
| 573 |
+
## v0.13 resample stability and pairwise target preference
|
| 574 |
+
|
| 575 |
+
### Deterministic balanced resampling
|
| 576 |
+
|
| 577 |
+
The live discovery forward already yields a prompt-wide SAE activation vector for every controlled prompt. v0.13 reuses that tensor for 32 deterministic balanced bootstrap resamples. Each concept is resampled independently with replacement so the live concept balance is preserved. Candidate ranking is recomputed under the currently selected ranking mode, including the fixed current-token compatibility term for `causal_ready`. For every candidate in the full-data shortlist, FeatureLens records the fraction of resamples in which it reappears and its median rank when present. No model or SAE forward is repeated. Because the live prompt count is deliberately tiny, these are sensitivity descriptors rather than statistical confidence estimates.
|
| 578 |
+
|
| 579 |
+
### Cross-target concentration
|
| 580 |
+
|
| 581 |
+
For one feature with target effects `Δ_t`, v0.13 normalizes `|Δ_t|` into a probability vector and reports normalized entropy `H / log(T)`, effect concentration `1 − H/log(T)`, and signed bias `ΣΔ_t / Σ|Δ_t|`. These quantities distinguish target-concentrated effects from broad effects and indicate whether broad influence is primarily suppressive, enhancing, or mixed-sign. The profile text is a descriptive heuristic only.
|
| 582 |
+
|
| 583 |
+
### Pairwise preference shifts
|
| 584 |
+
|
| 585 |
+
For every unordered target pair `(A, B)`, FeatureLens derives `Δ_pref(A,B) = Δmean(A) − Δmean(B)` from the already-computed teacher-forced mean-log-probability effects. This is the intervention-induced change in token-normalized preference for A relative to B. It does not add a model call and does not replace matched-random specificity controls.
|
docs/VALIDATION.md
CHANGED
|
@@ -1,11 +1,9 @@
|
|
| 1 |
-
# FeatureLens v0.
|
| 2 |
|
| 3 |
-
v0.
|
| 4 |
|
| 5 |
## 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 && \
|
|
@@ -14,9 +12,7 @@ python3 scripts/ui_smoke.py && \
|
|
| 14 |
python3 scripts/release_check.py
|
| 15 |
```
|
| 16 |
|
| 17 |
-
Expected
|
| 18 |
-
|
| 19 |
-
Expected release footer:
|
| 20 |
|
| 21 |
```text
|
| 22 |
FeatureLens release check: PASS
|
|
@@ -25,107 +21,69 @@ FeatureLens release check: PASS
|
|
| 25 |
layers: [4, 14, 26]
|
| 26 |
feature-set sizes: [1, 3, 5]
|
| 27 |
random controls: 8
|
| 28 |
-
release: v0.
|
| 29 |
```
|
| 30 |
|
| 31 |
-
## HF
|
| 32 |
-
|
| 33 |
-
Use this Workbench context before both calls:
|
| 34 |
-
|
| 35 |
-
```text
|
| 36 |
-
Prompt: The derivative of x squared is
|
| 37 |
-
Residual layer: 14
|
| 38 |
-
Prompt token index: -1
|
| 39 |
-
```
|
| 40 |
|
| 41 |
-
|
| 42 |
|
| 43 |
-
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
**
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
``
|
| 52 |
-
Target concept: mathematics
|
| 53 |
-
Residual layer: 14
|
| 54 |
-
Prompts per concept: 4
|
| 55 |
-
Candidate features: 12
|
| 56 |
-
Candidate ranking: Causal-ready at current token
|
| 57 |
-
```
|
| 58 |
|
| 59 |
-
Click
|
| 60 |
|
| 61 |
-
|
| 62 |
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
-
|
| 66 |
-
- The summary includes a line beginning **Split-half shortlist stability from the same activation batch**.
|
| 67 |
-
- The reported Jaccard is in `[0, 1]`.
|
| 68 |
-
- The diagnostic is described as small-sample sensitivity, not semantic reliability.
|
| 69 |
|
| 70 |
-
|
| 71 |
|
| 72 |
-
|
| 73 |
|
| 74 |
-
|
| 75 |
|
| 76 |
-
**
|
| 77 |
-
|
| 78 |
-
Set:
|
| 79 |
|
| 80 |
```text
|
| 81 |
-
Features for cross-target profile:
|
| 82 |
-
25992
|
| 83 |
-
16369
|
| 84 |
-
21670
|
| 85 |
-
|
| 86 |
-
Exact target continuations (one per line):
|
| 87 |
2x
|
| 88 |
x
|
| 89 |
0
|
| 90 |
x^2
|
| 91 |
```
|
| 92 |
|
| 93 |
-
Click
|
| 94 |
-
|
| 95 |
-
**Run cross-target causal profile**
|
| 96 |
-
|
| 97 |
-
Pass conditions:
|
| 98 |
-
|
| 99 |
-
- The summary says 3 features were profiled across 4 exact continuations.
|
| 100 |
-
- **Cross-target causal profile** contains `3 × 4 = 12` rows.
|
| 101 |
-
- **Target-profile summary** contains one row per feature.
|
| 102 |
-
- The chart contains separate feature series across the four targets.
|
| 103 |
-
- No random-control/significance claim is made in this panel.
|
| 104 |
|
| 105 |
-
|
| 106 |
|
| 107 |
-
1.
|
| 108 |
-
2.
|
| 109 |
-
3. the
|
| 110 |
|
| 111 |
-
##
|
| 112 |
|
| 113 |
-
Do not spend
|
| 114 |
|
| 115 |
-
-
|
| 116 |
-
-
|
| 117 |
-
-
|
| 118 |
-
|
| 119 |
-
## Do not rerun for v0.12
|
| 120 |
-
|
| 121 |
-
Unless something visibly breaks, skip:
|
| 122 |
-
|
| 123 |
-
- identity paraphrase;
|
| 124 |
- layer trajectory;
|
| 125 |
- 1/3/5 feature-set sweep;
|
| 126 |
-
-
|
| 127 |
-
-
|
| 128 |
-
- controlled candidate specificity;
|
| 129 |
-
- zoom/focus behavior.
|
| 130 |
|
| 131 |
-
Those
|
|
|
|
| 1 |
+
# FeatureLens v0.13 validation
|
| 2 |
|
| 3 |
+
v0.13 changes only **concept-guided discovery post-processing** and **cross-target profile post-processing**. Both changes reuse activations/scores already produced by those GPU calls. Do not rerun unrelated live experiments.
|
| 4 |
|
| 5 |
## Local release gate
|
| 6 |
|
|
|
|
|
|
|
| 7 |
```bash
|
| 8 |
python3 -m pytest -q && \
|
| 9 |
python3 -m compileall -q app.py featurelens experiments scripts && \
|
|
|
|
| 12 |
python3 scripts/release_check.py
|
| 13 |
```
|
| 14 |
|
| 15 |
+
Expected release checker tail:
|
|
|
|
|
|
|
| 16 |
|
| 17 |
```text
|
| 18 |
FeatureLens release check: PASS
|
|
|
|
| 21 |
layers: [4, 14, 26]
|
| 22 |
feature-set sizes: [1, 3, 5]
|
| 23 |
random controls: 8
|
| 24 |
+
release: v0.13.0
|
| 25 |
```
|
| 26 |
|
| 27 |
+
## HF GPU call 1 — discovery stability
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
Exact path: **Feature evidence → A. Concept-guided candidate discovery**.
|
| 30 |
|
| 31 |
+
Use:
|
| 32 |
|
| 33 |
+
- **Target concept:** `mathematics`
|
| 34 |
+
- **Residual layer:** `14`
|
| 35 |
+
- **Prompts per concept:** `4`
|
| 36 |
+
- **Candidate features:** `12`
|
| 37 |
+
- **Candidate ranking:** `Causal-ready at current token`
|
| 38 |
+
- Current Workbench prompt: `The derivative of x squared is`
|
| 39 |
+
- Current Workbench token index: `-1`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
Click **Discover concept-associated candidates**.
|
| 42 |
|
| 43 |
+
Record only the new stability evidence:
|
| 44 |
|
| 45 |
+
- split-half shared count and Jaccard;
|
| 46 |
+
- summary mean resample support;
|
| 47 |
+
- number of displayed candidates with ≥75% resample support;
|
| 48 |
+
- for the top five rows: **Feature id**, **Resample shortlist support**, **Median resample rank**.
|
| 49 |
|
| 50 |
+
No other discovery table columns need to be recopied unless the candidate ranking unexpectedly changes.
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
+
## HF GPU call 2 — richer cross-target profile
|
| 53 |
|
| 54 |
+
Exact path: **Feature evidence → C. Cross-target causal profile**.
|
| 55 |
|
| 56 |
+
Use:
|
| 57 |
|
| 58 |
+
- **Features for cross-target profile:** `25992`, `16369`, `21670`
|
| 59 |
+
- **Exact target continuations:**
|
|
|
|
| 60 |
|
| 61 |
```text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
2x
|
| 63 |
x
|
| 64 |
0
|
| 65 |
x^2
|
| 66 |
```
|
| 67 |
|
| 68 |
+
Click **Run cross-target causal profile**.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
+
Send:
|
| 71 |
|
| 72 |
+
1. **Target-profile summary** with the new columns **Normalized effect entropy**, **Effect concentration**, **Signed bias**, and **Profile pattern**.
|
| 73 |
+
2. The top six rows of **Pairwise target preference shifts** by `|Preference shift|`.
|
| 74 |
+
3. A screenshot of the pairwise plot if convenient.
|
| 75 |
|
| 76 |
+
## Do not rerun for v0.13
|
| 77 |
|
| 78 |
+
Do **not** spend ZeroGPU quota on:
|
| 79 |
|
| 80 |
+
- controlled candidate specificity;
|
| 81 |
+
- candidate triage;
|
| 82 |
+
- dose response;
|
| 83 |
+
- paraphrase identity;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
- layer trajectory;
|
| 85 |
- 1/3/5 feature-set sweep;
|
| 86 |
+
- cue or cue × context tests;
|
| 87 |
+
- focus/zoom behavior.
|
|
|
|
|
|
|
| 88 |
|
| 89 |
+
Those inference paths are unchanged in v0.13.
|
featurelens/runtime.py
CHANGED
|
@@ -5,6 +5,7 @@ import html
|
|
| 5 |
import json
|
| 6 |
import math
|
| 7 |
import os
|
|
|
|
| 8 |
from collections.abc import Iterator, Sequence
|
| 9 |
from contextlib import contextmanager
|
| 10 |
from dataclasses import dataclass
|
|
@@ -244,6 +245,9 @@ class ConceptFeatureDiscoveryResult:
|
|
| 244 |
split_half_k: int | None
|
| 245 |
split_half_shared_count: int
|
| 246 |
split_half_jaccard: float | None
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
|
| 249 |
@dataclass
|
|
@@ -278,6 +282,7 @@ class CandidateCrossTargetResult:
|
|
| 278 |
rows: list[list[object]]
|
| 279 |
chart_rows: list[list[object]]
|
| 280 |
summary_rows: list[list[object]]
|
|
|
|
| 281 |
active_feature_count: int
|
| 282 |
|
| 283 |
|
|
@@ -1950,16 +1955,30 @@ class FeatureLensRuntime:
|
|
| 1950 |
include_self=True,
|
| 1951 |
)
|
| 1952 |
|
| 1953 |
-
#
|
| 1954 |
-
#
|
| 1955 |
-
#
|
| 1956 |
split_half_k: int | None = None
|
| 1957 |
split_half_shared_count = 0
|
| 1958 |
split_half_jaccard: float | None = None
|
|
|
|
|
|
|
|
|
|
| 1959 |
|
| 1960 |
-
def
|
| 1961 |
-
|
| 1962 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1963 |
if sub_target.shape[0] == 0 or sub_other.shape[0] == 0:
|
| 1964 |
return []
|
| 1965 |
sub_target_mean = sub_target.mean(dim=0)
|
|
@@ -1989,18 +2008,17 @@ class FeatureLensRuntime:
|
|
| 1989 |
if n >= 2:
|
| 1990 |
half = max(1, n // 2)
|
| 1991 |
seen_by_concept: dict[str, int] = {}
|
| 1992 |
-
|
| 1993 |
-
|
| 1994 |
-
for row_idx,
|
| 1995 |
-
row_concept = str(row['concept'])
|
| 1996 |
local_idx = seen_by_concept.get(row_concept, 0)
|
| 1997 |
seen_by_concept[row_concept] = local_idx + 1
|
| 1998 |
if local_idx < half:
|
| 1999 |
-
|
| 2000 |
else:
|
| 2001 |
-
|
| 2002 |
-
ids_a =
|
| 2003 |
-
ids_b =
|
| 2004 |
if ids_a and ids_b:
|
| 2005 |
set_a, set_b = set(ids_a), set(ids_b)
|
| 2006 |
shared = set_a & set_b
|
|
@@ -2009,6 +2027,22 @@ class FeatureLensRuntime:
|
|
| 2009 |
split_half_shared_count = len(shared)
|
| 2010 |
split_half_jaccard = float(len(shared) / len(union)) if union else 1.0
|
| 2011 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2012 |
eligible = (target_mean > 0) & (mean_diff > 0)
|
| 2013 |
if ranking_mode == 'causal_ready':
|
| 2014 |
if not current_context_available or resolved_current_idx is None:
|
|
@@ -2041,11 +2075,39 @@ class FeatureLensRuntime:
|
|
| 2041 |
split_half_k=split_half_k,
|
| 2042 |
split_half_shared_count=split_half_shared_count,
|
| 2043 |
split_half_jaccard=split_half_jaccard,
|
|
|
|
|
|
|
|
|
|
| 2044 |
)
|
| 2045 |
|
| 2046 |
order = torch.argsort(ranking_values[candidate_idx], descending=True)
|
| 2047 |
candidate_idx = candidate_idx[order[:top_n]]
|
| 2048 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2049 |
table_rows: list[list[object]] = []
|
| 2050 |
chart_rows: list[list[object]] = []
|
| 2051 |
default_candidate_id: int | None = None
|
|
@@ -2069,6 +2131,8 @@ class FeatureLensRuntime:
|
|
| 2069 |
current_max,
|
| 2070 |
current_token,
|
| 2071 |
bool(current_token > 0),
|
|
|
|
|
|
|
| 2072 |
]
|
| 2073 |
table_rows.append(row)
|
| 2074 |
chart_rows.append([str(fid), score])
|
|
@@ -2088,10 +2152,13 @@ class FeatureLensRuntime:
|
|
| 2088 |
default_candidate_id=default_candidate_id,
|
| 2089 |
current_context_available=current_context_available,
|
| 2090 |
current_token_index=resolved_current_idx,
|
| 2091 |
-
displayed_current_active_count=sum(bool(row[
|
| 2092 |
split_half_k=split_half_k,
|
| 2093 |
split_half_shared_count=split_half_shared_count,
|
| 2094 |
split_half_jaccard=split_half_jaccard,
|
|
|
|
|
|
|
|
|
|
| 2095 |
)
|
| 2096 |
|
| 2097 |
@torch.inference_mode()
|
|
@@ -2527,6 +2594,7 @@ class FeatureLensRuntime:
|
|
| 2527 |
by_feature[feature_id].append((target_text, mean_delta, js))
|
| 2528 |
|
| 2529 |
summary_rows: list[list[object]] = []
|
|
|
|
| 2530 |
for feature_id in ids:
|
| 2531 |
items = by_feature[feature_id]
|
| 2532 |
strongest = max(items, key=lambda item: abs(item[1]))
|
|
@@ -2534,9 +2602,33 @@ class FeatureLensRuntime:
|
|
| 2534 |
other_abs = [abs(float(item[1])) for item in items if item is not strongest]
|
| 2535 |
mean_other = float(sum(other_abs) / len(other_abs)) if other_abs else 0.0
|
| 2536 |
profile_ratio = float(strongest_abs / max(mean_other, 1e-12))
|
| 2537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2538 |
nonzero_signs = {sign for sign in signs if sign != 0}
|
| 2539 |
sign_consistency = 'same sign' if len(nonzero_signs) <= 1 else 'mixed signs'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2540 |
summary_rows.append(
|
| 2541 |
[
|
| 2542 |
int(feature_id),
|
|
@@ -2546,10 +2638,35 @@ class FeatureLensRuntime:
|
|
| 2546 |
mean_other,
|
| 2547 |
profile_ratio,
|
| 2548 |
sign_consistency,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2549 |
max(float(item[2]) for item in items),
|
| 2550 |
]
|
| 2551 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2552 |
summary_rows.sort(key=lambda row: float(row[3]), reverse=True)
|
|
|
|
| 2553 |
|
| 2554 |
return CandidateCrossTargetResult(
|
| 2555 |
feature_ids=ids,
|
|
@@ -2557,6 +2674,7 @@ class FeatureLensRuntime:
|
|
| 2557 |
rows=rows,
|
| 2558 |
chart_rows=chart_rows,
|
| 2559 |
summary_rows=summary_rows,
|
|
|
|
| 2560 |
active_feature_count=sum(activation > 0 for activation in activations),
|
| 2561 |
)
|
| 2562 |
|
|
|
|
| 5 |
import json
|
| 6 |
import math
|
| 7 |
import os
|
| 8 |
+
import random
|
| 9 |
from collections.abc import Iterator, Sequence
|
| 10 |
from contextlib import contextmanager
|
| 11 |
from dataclasses import dataclass
|
|
|
|
| 245 |
split_half_k: int | None
|
| 246 |
split_half_shared_count: int
|
| 247 |
split_half_jaccard: float | None
|
| 248 |
+
resample_replicates: int
|
| 249 |
+
resample_mean_support: float | None
|
| 250 |
+
resample_high_support_count: int
|
| 251 |
|
| 252 |
|
| 253 |
@dataclass
|
|
|
|
| 282 |
rows: list[list[object]]
|
| 283 |
chart_rows: list[list[object]]
|
| 284 |
summary_rows: list[list[object]]
|
| 285 |
+
pairwise_rows: list[list[object]]
|
| 286 |
active_feature_count: int
|
| 287 |
|
| 288 |
|
|
|
|
| 1955 |
include_self=True,
|
| 1956 |
)
|
| 1957 |
|
| 1958 |
+
# Stability diagnostics reuse the same controlled activation batch, so they add no model inference.
|
| 1959 |
+
# Split-half overlap is intentionally simple; deterministic balanced bootstrap support gives a second
|
| 1960 |
+
# view of how often each displayed feature survives small changes to the live prompt sample.
|
| 1961 |
split_half_k: int | None = None
|
| 1962 |
split_half_shared_count = 0
|
| 1963 |
split_half_jaccard: float | None = None
|
| 1964 |
+
resample_replicates = 0
|
| 1965 |
+
resample_rank_lists: list[list[int]] = []
|
| 1966 |
+
row_concepts = [str(row['concept']) for row in rows]
|
| 1967 |
|
| 1968 |
+
def _rank_indices(row_indices: list[int]) -> list[int]:
|
| 1969 |
+
if not row_indices:
|
| 1970 |
+
return []
|
| 1971 |
+
index_tensor = torch.tensor(row_indices, device=dense.device, dtype=torch.long)
|
| 1972 |
+
sub_dense = controlled_dense.index_select(0, index_tensor)
|
| 1973 |
+
sub_labels = [row_concepts[index] for index in row_indices]
|
| 1974 |
+
sub_target_mask = torch.tensor(
|
| 1975 |
+
[label == concept for label in sub_labels],
|
| 1976 |
+
device=dense.device,
|
| 1977 |
+
dtype=torch.bool,
|
| 1978 |
+
)
|
| 1979 |
+
sub_other_mask = ~sub_target_mask
|
| 1980 |
+
sub_target = sub_dense[sub_target_mask]
|
| 1981 |
+
sub_other = sub_dense[sub_other_mask]
|
| 1982 |
if sub_target.shape[0] == 0 or sub_other.shape[0] == 0:
|
| 1983 |
return []
|
| 1984 |
sub_target_mean = sub_target.mean(dim=0)
|
|
|
|
| 2008 |
if n >= 2:
|
| 2009 |
half = max(1, n // 2)
|
| 2010 |
seen_by_concept: dict[str, int] = {}
|
| 2011 |
+
indices_a: list[int] = []
|
| 2012 |
+
indices_b: list[int] = []
|
| 2013 |
+
for row_idx, row_concept in enumerate(row_concepts):
|
|
|
|
| 2014 |
local_idx = seen_by_concept.get(row_concept, 0)
|
| 2015 |
seen_by_concept[row_concept] = local_idx + 1
|
| 2016 |
if local_idx < half:
|
| 2017 |
+
indices_a.append(row_idx)
|
| 2018 |
else:
|
| 2019 |
+
indices_b.append(row_idx)
|
| 2020 |
+
ids_a = _rank_indices(indices_a)
|
| 2021 |
+
ids_b = _rank_indices(indices_b)
|
| 2022 |
if ids_a and ids_b:
|
| 2023 |
set_a, set_b = set(ids_a), set(ids_b)
|
| 2024 |
shared = set_a & set_b
|
|
|
|
| 2027 |
split_half_shared_count = len(shared)
|
| 2028 |
split_half_jaccard = float(len(shared) / len(union)) if union else 1.0
|
| 2029 |
|
| 2030 |
+
pools: dict[str, list[int]] = {}
|
| 2031 |
+
for row_idx, row_concept in enumerate(row_concepts):
|
| 2032 |
+
pools.setdefault(row_concept, []).append(row_idx)
|
| 2033 |
+
seed = 13013 + int(layer) * 97 + n * 17 + sum(ord(ch) for ch in concept)
|
| 2034 |
+
rng = random.Random(seed)
|
| 2035 |
+
resample_replicates = 32
|
| 2036 |
+
for _ in range(resample_replicates):
|
| 2037 |
+
sampled_indices: list[int] = []
|
| 2038 |
+
for row_concept in sorted(pools):
|
| 2039 |
+
pool = pools[row_concept]
|
| 2040 |
+
sampled_indices.extend(rng.choice(pool) for _ in range(len(pool)))
|
| 2041 |
+
ranked = _rank_indices(sampled_indices)
|
| 2042 |
+
if ranked:
|
| 2043 |
+
resample_rank_lists.append(ranked)
|
| 2044 |
+
resample_replicates = len(resample_rank_lists)
|
| 2045 |
+
|
| 2046 |
eligible = (target_mean > 0) & (mean_diff > 0)
|
| 2047 |
if ranking_mode == 'causal_ready':
|
| 2048 |
if not current_context_available or resolved_current_idx is None:
|
|
|
|
| 2075 |
split_half_k=split_half_k,
|
| 2076 |
split_half_shared_count=split_half_shared_count,
|
| 2077 |
split_half_jaccard=split_half_jaccard,
|
| 2078 |
+
resample_replicates=resample_replicates,
|
| 2079 |
+
resample_mean_support=None,
|
| 2080 |
+
resample_high_support_count=0,
|
| 2081 |
)
|
| 2082 |
|
| 2083 |
order = torch.argsort(ranking_values[candidate_idx], descending=True)
|
| 2084 |
candidate_idx = candidate_idx[order[:top_n]]
|
| 2085 |
|
| 2086 |
+
resample_support: dict[int, float] = {}
|
| 2087 |
+
resample_median_rank: dict[int, float | None] = {}
|
| 2088 |
+
displayed_supports: list[float] = []
|
| 2089 |
+
if resample_replicates:
|
| 2090 |
+
for feature_tensor in candidate_idx:
|
| 2091 |
+
fid = int(feature_tensor.item())
|
| 2092 |
+
ranks = [ranked.index(fid) + 1 for ranked in resample_rank_lists if fid in ranked]
|
| 2093 |
+
support = len(ranks) / resample_replicates
|
| 2094 |
+
if ranks:
|
| 2095 |
+
ordered_ranks = sorted(ranks)
|
| 2096 |
+
midpoint = len(ordered_ranks) // 2
|
| 2097 |
+
if len(ordered_ranks) % 2:
|
| 2098 |
+
median_rank = float(ordered_ranks[midpoint])
|
| 2099 |
+
else:
|
| 2100 |
+
median_rank = float((ordered_ranks[midpoint - 1] + ordered_ranks[midpoint]) / 2)
|
| 2101 |
+
else:
|
| 2102 |
+
median_rank = None
|
| 2103 |
+
resample_support[fid] = float(support)
|
| 2104 |
+
resample_median_rank[fid] = median_rank
|
| 2105 |
+
displayed_supports.append(float(support))
|
| 2106 |
+
resample_mean_support = (
|
| 2107 |
+
float(sum(displayed_supports) / len(displayed_supports)) if displayed_supports else None
|
| 2108 |
+
)
|
| 2109 |
+
resample_high_support_count = sum(support >= 0.75 for support in displayed_supports)
|
| 2110 |
+
|
| 2111 |
table_rows: list[list[object]] = []
|
| 2112 |
chart_rows: list[list[object]] = []
|
| 2113 |
default_candidate_id: int | None = None
|
|
|
|
| 2131 |
current_max,
|
| 2132 |
current_token,
|
| 2133 |
bool(current_token > 0),
|
| 2134 |
+
resample_support.get(fid) if resample_replicates else None,
|
| 2135 |
+
resample_median_rank.get(fid) if resample_replicates else None,
|
| 2136 |
]
|
| 2137 |
table_rows.append(row)
|
| 2138 |
chart_rows.append([str(fid), score])
|
|
|
|
| 2152 |
default_candidate_id=default_candidate_id,
|
| 2153 |
current_context_available=current_context_available,
|
| 2154 |
current_token_index=resolved_current_idx,
|
| 2155 |
+
displayed_current_active_count=sum(bool(row[11]) for row in table_rows),
|
| 2156 |
split_half_k=split_half_k,
|
| 2157 |
split_half_shared_count=split_half_shared_count,
|
| 2158 |
split_half_jaccard=split_half_jaccard,
|
| 2159 |
+
resample_replicates=resample_replicates,
|
| 2160 |
+
resample_mean_support=resample_mean_support,
|
| 2161 |
+
resample_high_support_count=resample_high_support_count,
|
| 2162 |
)
|
| 2163 |
|
| 2164 |
@torch.inference_mode()
|
|
|
|
| 2594 |
by_feature[feature_id].append((target_text, mean_delta, js))
|
| 2595 |
|
| 2596 |
summary_rows: list[list[object]] = []
|
| 2597 |
+
pairwise_rows: list[list[object]] = []
|
| 2598 |
for feature_id in ids:
|
| 2599 |
items = by_feature[feature_id]
|
| 2600 |
strongest = max(items, key=lambda item: abs(item[1]))
|
|
|
|
| 2602 |
other_abs = [abs(float(item[1])) for item in items if item is not strongest]
|
| 2603 |
mean_other = float(sum(other_abs) / len(other_abs)) if other_abs else 0.0
|
| 2604 |
profile_ratio = float(strongest_abs / max(mean_other, 1e-12))
|
| 2605 |
+
deltas = [float(item[1]) for item in items]
|
| 2606 |
+
abs_values = [abs(delta) for delta in deltas]
|
| 2607 |
+
total_abs = float(sum(abs_values))
|
| 2608 |
+
if total_abs > 0 and len(abs_values) > 1:
|
| 2609 |
+
proportions = [value / total_abs for value in abs_values if value > 0]
|
| 2610 |
+
normalized_entropy = float(
|
| 2611 |
+
-sum(value * math.log(value) for value in proportions) / math.log(len(abs_values))
|
| 2612 |
+
)
|
| 2613 |
+
else:
|
| 2614 |
+
normalized_entropy = 0.0
|
| 2615 |
+
effect_concentration = float(1.0 - normalized_entropy)
|
| 2616 |
+
signed_bias = float(sum(deltas) / total_abs) if total_abs > 0 else 0.0
|
| 2617 |
+
signs = {1 if delta > 0 else -1 if delta < 0 else 0 for delta in deltas}
|
| 2618 |
nonzero_signs = {sign for sign in signs if sign != 0}
|
| 2619 |
sign_consistency = 'same sign' if len(nonzero_signs) <= 1 else 'mixed signs'
|
| 2620 |
+
if effect_concentration >= 0.25:
|
| 2621 |
+
profile_pattern = (
|
| 2622 |
+
'target-concentrated / mixed-sign'
|
| 2623 |
+
if sign_consistency == 'mixed signs'
|
| 2624 |
+
else 'target-concentrated / same-sign'
|
| 2625 |
+
)
|
| 2626 |
+
elif signed_bias <= -0.8:
|
| 2627 |
+
profile_pattern = 'broad same-sign suppression'
|
| 2628 |
+
elif signed_bias >= 0.8:
|
| 2629 |
+
profile_pattern = 'broad same-sign enhancement'
|
| 2630 |
+
else:
|
| 2631 |
+
profile_pattern = 'broad mixed-sign'
|
| 2632 |
summary_rows.append(
|
| 2633 |
[
|
| 2634 |
int(feature_id),
|
|
|
|
| 2638 |
mean_other,
|
| 2639 |
profile_ratio,
|
| 2640 |
sign_consistency,
|
| 2641 |
+
normalized_entropy,
|
| 2642 |
+
effect_concentration,
|
| 2643 |
+
signed_bias,
|
| 2644 |
+
profile_pattern,
|
| 2645 |
max(float(item[2]) for item in items),
|
| 2646 |
]
|
| 2647 |
)
|
| 2648 |
+
for left_idx in range(len(items)):
|
| 2649 |
+
for right_idx in range(left_idx + 1, len(items)):
|
| 2650 |
+
target_a, delta_a, _ = items[left_idx]
|
| 2651 |
+
target_b, delta_b, _ = items[right_idx]
|
| 2652 |
+
preference_shift = float(delta_a - delta_b)
|
| 2653 |
+
direction = (
|
| 2654 |
+
f'toward {target_a}'
|
| 2655 |
+
if preference_shift > 0
|
| 2656 |
+
else f'toward {target_b}' if preference_shift < 0 else 'no shift'
|
| 2657 |
+
)
|
| 2658 |
+
pairwise_rows.append(
|
| 2659 |
+
[
|
| 2660 |
+
int(feature_id),
|
| 2661 |
+
str(target_a),
|
| 2662 |
+
str(target_b),
|
| 2663 |
+
preference_shift,
|
| 2664 |
+
abs(preference_shift),
|
| 2665 |
+
direction,
|
| 2666 |
+
]
|
| 2667 |
+
)
|
| 2668 |
summary_rows.sort(key=lambda row: float(row[3]), reverse=True)
|
| 2669 |
+
pairwise_rows.sort(key=lambda row: float(row[4]), reverse=True)
|
| 2670 |
|
| 2671 |
return CandidateCrossTargetResult(
|
| 2672 |
feature_ids=ids,
|
|
|
|
| 2674 |
rows=rows,
|
| 2675 |
chart_rows=chart_rows,
|
| 2676 |
summary_rows=summary_rows,
|
| 2677 |
+
pairwise_rows=pairwise_rows,
|
| 2678 |
active_feature_count=sum(activation > 0 for activation in activations),
|
| 2679 |
)
|
| 2680 |
|
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.13.0"
|
| 4 |
description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
|
| 5 |
requires-python = ">=3.10"
|
| 6 |
|
research_config.json
CHANGED
|
@@ -169,5 +169,13 @@
|
|
| 169 |
"cross_target_candidate_profile",
|
| 170 |
"missing_discovery_alignment_fallback",
|
| 171 |
"gpu_budget_aware_touched_path_validation"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
]
|
| 173 |
}
|
|
|
|
| 169 |
"cross_target_candidate_profile",
|
| 170 |
"missing_discovery_alignment_fallback",
|
| 171 |
"gpu_budget_aware_touched_path_validation"
|
| 172 |
+
],
|
| 173 |
+
"discovery_resample_replicates": 32,
|
| 174 |
+
"live_features_v0_13": [
|
| 175 |
+
"balanced_bootstrap_candidate_support",
|
| 176 |
+
"cross_target_effect_concentration",
|
| 177 |
+
"pairwise_target_preference_shifts",
|
| 178 |
+
"zero_extra_gpu_evidence_synthesis",
|
| 179 |
+
"touched_path_only_hf_validation"
|
| 180 |
]
|
| 181 |
}
|
scripts/release_check.py
CHANGED
|
@@ -218,6 +218,21 @@ def check_config(config: dict) -> None:
|
|
| 218 |
raise SystemExit(
|
| 219 |
'research_config.json live_features_v0_12 mismatch: ' f'{sorted(actual_live_v12)}'
|
| 220 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
if config.get('cross_target_feature_limit') != 3 or config.get('cross_target_target_limit') != 5:
|
| 222 |
raise SystemExit('Cross-target live limits must be 3 features and 5 targets.')
|
| 223 |
|
|
@@ -311,16 +326,20 @@ def check_readme() -> None:
|
|
| 311 |
'split-half',
|
| 312 |
'cross-target',
|
| 313 |
'target-profile',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
]
|
| 315 |
missing = [value for value in required_strings if value.lower() not in readme.lower()]
|
| 316 |
if missing:
|
| 317 |
-
raise SystemExit(f'README.md is missing required v0.
|
| 318 |
|
| 319 |
|
| 320 |
def check_pyproject() -> None:
|
| 321 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 322 |
-
if 'version = "0.
|
| 323 |
-
raise SystemExit('pyproject.toml must declare version 0.
|
| 324 |
|
| 325 |
|
| 326 |
def main() -> None:
|
|
@@ -338,7 +357,7 @@ def main() -> None:
|
|
| 338 |
print(f' layers: {config["layers"]}')
|
| 339 |
print(f' feature-set sizes: {config["feature_set_sizes"]}')
|
| 340 |
print(f' random controls: {config["live_random_controls"]}')
|
| 341 |
-
print(' release: v0.
|
| 342 |
|
| 343 |
|
| 344 |
if __name__ == '__main__':
|
|
|
|
| 218 |
raise SystemExit(
|
| 219 |
'research_config.json live_features_v0_12 mismatch: ' f'{sorted(actual_live_v12)}'
|
| 220 |
)
|
| 221 |
+
|
| 222 |
+
required_live_v13 = {
|
| 223 |
+
'balanced_bootstrap_candidate_support',
|
| 224 |
+
'cross_target_effect_concentration',
|
| 225 |
+
'pairwise_target_preference_shifts',
|
| 226 |
+
'zero_extra_gpu_evidence_synthesis',
|
| 227 |
+
'touched_path_only_hf_validation',
|
| 228 |
+
}
|
| 229 |
+
actual_live_v13 = set(config.get('live_features_v0_13', []))
|
| 230 |
+
if actual_live_v13 != required_live_v13:
|
| 231 |
+
raise SystemExit(
|
| 232 |
+
'research_config.json live_features_v0_13 mismatch: ' f'{sorted(actual_live_v13)}'
|
| 233 |
+
)
|
| 234 |
+
if config.get('discovery_resample_replicates') != 32:
|
| 235 |
+
raise SystemExit('Discovery live resample count must be 32.')
|
| 236 |
if config.get('cross_target_feature_limit') != 3 or config.get('cross_target_target_limit') != 5:
|
| 237 |
raise SystemExit('Cross-target live limits must be 3 features and 5 targets.')
|
| 238 |
|
|
|
|
| 326 |
'split-half',
|
| 327 |
'cross-target',
|
| 328 |
'target-profile',
|
| 329 |
+
'resample shortlist support',
|
| 330 |
+
'pairwise target preference',
|
| 331 |
+
'effect concentration',
|
| 332 |
+
'signed bias',
|
| 333 |
]
|
| 334 |
missing = [value for value in required_strings if value.lower() not in readme.lower()]
|
| 335 |
if missing:
|
| 336 |
+
raise SystemExit(f'README.md is missing required v0.13 content: {missing}')
|
| 337 |
|
| 338 |
|
| 339 |
def check_pyproject() -> None:
|
| 340 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 341 |
+
if 'version = "0.13.0"' not in text:
|
| 342 |
+
raise SystemExit('pyproject.toml must declare version 0.13.0.')
|
| 343 |
|
| 344 |
|
| 345 |
def main() -> None:
|
|
|
|
| 357 |
print(f' layers: {config["layers"]}')
|
| 358 |
print(f' feature-set sizes: {config["feature_set_sizes"]}')
|
| 359 |
print(f' random controls: {config["live_random_controls"]}')
|
| 360 |
+
print(' release: v0.13.0')
|
| 361 |
|
| 362 |
|
| 363 |
if __name__ == '__main__':
|
tests/test_live_runtime_helpers.py
CHANGED
|
@@ -233,7 +233,7 @@ def test_concept_feature_discovery_runs_on_toy_runtime() -> None:
|
|
| 233 |
assert result.current_token_index == 2
|
| 234 |
assert len(result.rows) <= 3
|
| 235 |
assert result.candidate_ids == [int(row[1]) for row in result.rows]
|
| 236 |
-
assert all(len(row) ==
|
| 237 |
if result.rows:
|
| 238 |
assert result.default_candidate_id in result.candidate_ids
|
| 239 |
assert all(math.isfinite(float(row[2])) for row in result.rows)
|
|
@@ -253,7 +253,7 @@ def test_concept_feature_discovery_supports_raw_mean_difference() -> None:
|
|
| 253 |
assert result.ranking_mode == 'raw_mean_difference'
|
| 254 |
assert result.current_context_available is False
|
| 255 |
assert result.current_token_index is None
|
| 256 |
-
assert all(len(row) ==
|
| 257 |
|
| 258 |
|
| 259 |
|
|
@@ -272,7 +272,7 @@ def test_concept_feature_discovery_supports_causal_ready_mode() -> None:
|
|
| 272 |
assert result.ranking_mode == 'causal_ready'
|
| 273 |
assert result.current_context_available is True
|
| 274 |
assert result.displayed_current_active_count == len(result.rows)
|
| 275 |
-
assert all(bool(row[
|
| 276 |
|
| 277 |
|
| 278 |
def test_concept_feature_discovery_causal_ready_requires_workbench_context() -> None:
|
|
@@ -382,6 +382,29 @@ def test_concept_feature_discovery_reports_split_half_stability_without_extra_fo
|
|
| 382 |
assert 0 <= result.split_half_shared_count <= max(len(result.candidate_ids), result.split_half_k)
|
| 383 |
|
| 384 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
def test_candidate_cross_target_profile_runs_multiple_features_and_targets() -> None:
|
| 386 |
runtime = make_runtime()
|
| 387 |
result = runtime.candidate_cross_target_profile(
|
|
@@ -397,5 +420,11 @@ def test_candidate_cross_target_profile_runs_multiple_features_and_targets() ->
|
|
| 397 |
assert len(result.chart_rows) == 6
|
| 398 |
assert len(result.summary_rows) == 2
|
| 399 |
assert all(len(row) == 8 for row in result.rows)
|
| 400 |
-
assert all(len(row) ==
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
assert all(math.isfinite(float(row[5])) for row in result.rows)
|
|
|
|
| 233 |
assert result.current_token_index == 2
|
| 234 |
assert len(result.rows) <= 3
|
| 235 |
assert result.candidate_ids == [int(row[1]) for row in result.rows]
|
| 236 |
+
assert all(len(row) == 14 for row in result.rows)
|
| 237 |
if result.rows:
|
| 238 |
assert result.default_candidate_id in result.candidate_ids
|
| 239 |
assert all(math.isfinite(float(row[2])) for row in result.rows)
|
|
|
|
| 253 |
assert result.ranking_mode == 'raw_mean_difference'
|
| 254 |
assert result.current_context_available is False
|
| 255 |
assert result.current_token_index is None
|
| 256 |
+
assert all(len(row) == 14 for row in result.rows)
|
| 257 |
|
| 258 |
|
| 259 |
|
|
|
|
| 272 |
assert result.ranking_mode == 'causal_ready'
|
| 273 |
assert result.current_context_available is True
|
| 274 |
assert result.displayed_current_active_count == len(result.rows)
|
| 275 |
+
assert all(bool(row[11]) for row in result.rows)
|
| 276 |
|
| 277 |
|
| 278 |
def test_concept_feature_discovery_causal_ready_requires_workbench_context() -> None:
|
|
|
|
| 382 |
assert 0 <= result.split_half_shared_count <= max(len(result.candidate_ids), result.split_half_k)
|
| 383 |
|
| 384 |
|
| 385 |
+
def test_concept_feature_discovery_reports_resample_support_from_same_batch() -> None:
|
| 386 |
+
runtime = make_runtime()
|
| 387 |
+
result = runtime.concept_feature_discovery(
|
| 388 |
+
concept='mathematics',
|
| 389 |
+
layer=0,
|
| 390 |
+
prompts_per_concept=2,
|
| 391 |
+
top_n=3,
|
| 392 |
+
ranking_mode='balanced_selectivity',
|
| 393 |
+
current_text='abc',
|
| 394 |
+
current_token_index=-1,
|
| 395 |
+
)
|
| 396 |
+
assert 0 <= result.resample_replicates <= 32
|
| 397 |
+
if result.resample_replicates and result.rows:
|
| 398 |
+
assert result.resample_mean_support is not None
|
| 399 |
+
assert 0.0 <= result.resample_mean_support <= 1.0
|
| 400 |
+
assert 0 <= result.resample_high_support_count <= len(result.rows)
|
| 401 |
+
for row in result.rows:
|
| 402 |
+
assert row[12] is not None
|
| 403 |
+
assert 0.0 <= float(row[12]) <= 1.0
|
| 404 |
+
if row[13] is not None:
|
| 405 |
+
assert 1.0 <= float(row[13]) <= result.top_n
|
| 406 |
+
|
| 407 |
+
|
| 408 |
def test_candidate_cross_target_profile_runs_multiple_features_and_targets() -> None:
|
| 409 |
runtime = make_runtime()
|
| 410 |
result = runtime.candidate_cross_target_profile(
|
|
|
|
| 420 |
assert len(result.chart_rows) == 6
|
| 421 |
assert len(result.summary_rows) == 2
|
| 422 |
assert all(len(row) == 8 for row in result.rows)
|
| 423 |
+
assert all(len(row) == 12 for row in result.summary_rows)
|
| 424 |
+
assert len(result.pairwise_rows) == 2 * 3 # 2 features × C(3 targets, 2)
|
| 425 |
+
assert all(len(row) == 6 for row in result.pairwise_rows)
|
| 426 |
+
assert all(0.0 <= float(row[7]) <= 1.0 for row in result.summary_rows) # normalized entropy
|
| 427 |
+
assert all(0.0 <= float(row[8]) <= 1.0 for row in result.summary_rows) # concentration
|
| 428 |
+
assert all(-1.0 <= float(row[9]) <= 1.0 for row in result.summary_rows) # signed bias
|
| 429 |
+
assert all(isinstance(row[10], str) and row[10] for row in result.summary_rows)
|
| 430 |
assert all(math.isfinite(float(row[5])) for row in result.rows)
|
tests/test_ui_helpers.py
CHANGED
|
@@ -308,3 +308,58 @@ def test_cross_target_ui_has_independent_targets_and_small_feature_limit() -> No
|
|
| 308 |
assert '2x' in app.cross_target_text.value
|
| 309 |
assert app.cross_target_table.show_label is False
|
| 310 |
assert app.cross_target_summary_table.show_label is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
assert '2x' in app.cross_target_text.value
|
| 309 |
assert app.cross_target_table.show_label is False
|
| 310 |
assert app.cross_target_summary_table.show_label is False
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def test_v13_discovery_markdown_reports_resample_support() -> None:
|
| 314 |
+
app = _import_app()
|
| 315 |
+
result = SimpleNamespace(
|
| 316 |
+
candidate_ids=[16369, 5712, 26112],
|
| 317 |
+
concept='mathematics',
|
| 318 |
+
layer=14,
|
| 319 |
+
prompts_per_concept=4,
|
| 320 |
+
ranking_mode='causal_ready',
|
| 321 |
+
current_context_available=True,
|
| 322 |
+
current_token_index=5,
|
| 323 |
+
displayed_current_active_count=3,
|
| 324 |
+
split_half_k=3,
|
| 325 |
+
split_half_shared_count=2,
|
| 326 |
+
split_half_jaccard=0.5,
|
| 327 |
+
resample_replicates=32,
|
| 328 |
+
resample_mean_support=0.71875,
|
| 329 |
+
resample_high_support_count=2,
|
| 330 |
+
)
|
| 331 |
+
text = app._discovery_metrics_markdown(result)
|
| 332 |
+
assert '32 resamples' in text
|
| 333 |
+
assert '71.9%' in text
|
| 334 |
+
assert '2/3' in text
|
| 335 |
+
assert 'confidence interval' in text
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def test_v13_cross_target_markdown_reports_profile_and_pairwise_summary() -> None:
|
| 339 |
+
app = _import_app()
|
| 340 |
+
result = SimpleNamespace(
|
| 341 |
+
feature_ids=[25992, 16369],
|
| 342 |
+
targets=['2x', 'x', '0', 'x^2'],
|
| 343 |
+
active_feature_count=2,
|
| 344 |
+
summary_rows=[
|
| 345 |
+
[16369, 'x', 0.2883, 0.2883, 0.0513, 5.62, 'mixed signs', 0.58, 0.42, 0.92,
|
| 346 |
+
'target-concentrated / mixed-sign', 0.0017],
|
| 347 |
+
[25992, '0', -0.1657, 0.1657, 0.0936, 1.77, 'same sign', 0.95, 0.05, -1.0,
|
| 348 |
+
'broad same-sign suppression', 0.0011],
|
| 349 |
+
],
|
| 350 |
+
pairwise_rows=[
|
| 351 |
+
[16369, 'x', '0', 0.2969, 0.2969, 'toward x'],
|
| 352 |
+
],
|
| 353 |
+
)
|
| 354 |
+
text = app._cross_target_metrics_markdown(result)
|
| 355 |
+
assert 'target-concentrated / mixed-sign' in text
|
| 356 |
+
assert 'broad same-sign suppression' in text
|
| 357 |
+
assert 'pairwise preference shift' in text
|
| 358 |
+
assert "'x' vs '0'" in text
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def test_v13_pairwise_cross_target_ui_is_zero_extra_gpu_output() -> None:
|
| 362 |
+
app = _import_app()
|
| 363 |
+
assert app.cross_target_pairwise_table.show_label is False
|
| 364 |
+
assert app.cross_target_pairwise_plot.title == 'Pairwise target preference shifts'
|
| 365 |
+
assert app.cross_target_pairwise_plot.visible is True
|