Spaces:
Running on Zero
Running on Zero
Commit ·
9f9fbec
1
Parent(s): 9d24374
Release FeatureLens v0.2.0
Browse files- .gitignore +2 -0
- CHANGELOG.md +26 -0
- README.md +20 -2
- app.py +352 -155
- docs/VALIDATION.md +76 -0
- experiments/make_report.py +28 -1
- featurelens/runtime.py +209 -17
- featurelens/stats.py +81 -0
- pyproject.toml +1 -1
- requirements.txt +1 -1
- research_config.json +27 -4
- scripts/release_check.py +239 -41
- tests/test_stats.py +33 -0
.gitignore
CHANGED
|
@@ -10,3 +10,5 @@ artifacts/*.npy
|
|
| 10 |
artifacts/*.npz
|
| 11 |
*.pt
|
| 12 |
*.safetensors
|
|
|
|
|
|
|
|
|
| 10 |
artifacts/*.npz
|
| 11 |
*.pt
|
| 12 |
*.safetensors
|
| 13 |
+
pycache/
|
| 14 |
+
venv/
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
## 0.2.0 — 2026-08-13
|
| 4 |
+
|
| 5 |
+
### Added
|
| 6 |
+
|
| 7 |
+
- Live norm-matched random residual control for causal interventions.
|
| 8 |
+
- Target-token and JS-divergence causal specificity ratios.
|
| 9 |
+
- Feature-scale dose-response sweep (0× through 3×).
|
| 10 |
+
- Cross-layer representation trajectory for layers 4, 14 and 26.
|
| 11 |
+
- Native Gradio activation, dose-response and trajectory plots.
|
| 12 |
+
- Bootstrap 95% confidence intervals in the generated offline report.
|
| 13 |
+
- Paired sign-flip randomization test for SAE-vs-random causal-effect differences.
|
| 14 |
+
- Hosted/local validation matrix.
|
| 15 |
+
|
| 16 |
+
### Changed
|
| 17 |
+
|
| 18 |
+
- Space SDK and dependency pin to Gradio 6.24.0.
|
| 19 |
+
- Explicit client-side rendering (`ssr_mode=False`) to avoid the observed SSR async-user warning.
|
| 20 |
+
- Shorter ZeroGPU callback durations and a smaller default generation budget.
|
| 21 |
+
- More explicit inactive-feature warning and error surfacing.
|
| 22 |
+
- Dark/light-mode-safe UI styling using Gradio theme variables.
|
| 23 |
+
|
| 24 |
+
### Removed
|
| 25 |
+
|
| 26 |
+
- Build/cache directories from release artifacts.
|
README.md
CHANGED
|
@@ -4,7 +4,8 @@ emoji: 🔬
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
-
python_version: "3.12"
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
|
@@ -12,6 +13,8 @@ license: mit
|
|
| 12 |
|
| 13 |
# FeatureLens — Causal Interpretability Workbench
|
| 14 |
|
|
|
|
|
|
|
| 15 |
**FeatureLens asks one concrete question:**
|
| 16 |
|
| 17 |
> Do sparse features that predict a concept also causally influence model behaviour?
|
|
@@ -41,6 +44,10 @@ The Gradio app supports:
|
|
| 41 |
- feature **ablation**, **scaling**, or **injection**;
|
| 42 |
- baseline vs modified greedy generation;
|
| 43 |
- first-token probability table and Jensen-Shannon divergence;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
- optional target-continuation probability / log-probability delta;
|
| 45 |
- optional concept hints loaded from real offline benchmark artifacts.
|
| 46 |
|
|
@@ -78,7 +85,9 @@ The evaluation computes:
|
|
| 78 |
- layer-wise multinomial linear probes on dense residual states;
|
| 79 |
- selected-feature ablation and 2× amplification;
|
| 80 |
- target first-token probability, log-probability, rank and JS divergence;
|
| 81 |
-
- **norm-matched random residual-direction controls**
|
|
|
|
|
|
|
| 82 |
|
| 83 |
Feature selection uses the **training split**. Held-out AUROC/F1 are reported afterward. Paraphrases from the same pair never cross the train/test boundary.
|
| 84 |
|
|
@@ -149,6 +158,7 @@ FeatureLens/
|
|
| 149 |
│ ├── interventions.py # causal deltas + random controls
|
| 150 |
│ ├── runtime.py # hooks, generation, probability deltas
|
| 151 |
│ ├── metrics.py # reconstruction + divergence metrics
|
|
|
|
| 152 |
│ └── catalog.py # offline result integration
|
| 153 |
├── experiments/
|
| 154 |
│ ├── build_dataset.py
|
|
@@ -174,6 +184,14 @@ FeatureLens/
|
|
| 174 |
- A target string may tokenize into multiple tokens. The live workbench explicitly labels its target metric as the **first-token** probability in that case.
|
| 175 |
- A causal effect can depend strongly on prompt, layer, feature scale, and downstream task. The report therefore includes raw per-task rows rather than only aggregate means.
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
## Reproducibility and checks
|
| 178 |
|
| 179 |
```bash
|
|
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
python_version: "3.12.12"
|
| 8 |
+
sdk_version: "6.24.0"
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
|
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
+
> **v0.2:** live norm-matched controls, causal dose-response sweeps, layer trajectories, statistical uncertainty, and a hardened ZeroGPU deployment.
|
| 17 |
+
|
| 18 |
**FeatureLens asks one concrete question:**
|
| 19 |
|
| 20 |
> Do sparse features that predict a concept also causally influence model behaviour?
|
|
|
|
| 44 |
- feature **ablation**, **scaling**, or **injection**;
|
| 45 |
- baseline vs modified greedy generation;
|
| 46 |
- first-token probability table and Jensen-Shannon divergence;
|
| 47 |
+
- **live norm-matched random residual control** for every causal edit;
|
| 48 |
+
- target-token causal specificity ratio against that matched control;
|
| 49 |
+
- **dose-response sweep** over 0×, 0.5×, 1×, 1.5×, 2× and 3× feature scaling;
|
| 50 |
+
- **layer trajectory** diagnostics across layers 4, 14 and 26;
|
| 51 |
- optional target-continuation probability / log-probability delta;
|
| 52 |
- optional concept hints loaded from real offline benchmark artifacts.
|
| 53 |
|
|
|
|
| 85 |
- layer-wise multinomial linear probes on dense residual states;
|
| 86 |
- selected-feature ablation and 2× amplification;
|
| 87 |
- target first-token probability, log-probability, rank and JS divergence;
|
| 88 |
+
- **norm-matched random residual-direction controls**;
|
| 89 |
+
- bootstrap 95% confidence intervals for aggregate metrics;
|
| 90 |
+
- a paired sign-flip randomization test for SAE-vs-random causal-effect differences.
|
| 91 |
|
| 92 |
Feature selection uses the **training split**. Held-out AUROC/F1 are reported afterward. Paraphrases from the same pair never cross the train/test boundary.
|
| 93 |
|
|
|
|
| 158 |
│ ├── interventions.py # causal deltas + random controls
|
| 159 |
│ ├── runtime.py # hooks, generation, probability deltas
|
| 160 |
│ ├── metrics.py # reconstruction + divergence metrics
|
| 161 |
+
│ ├── stats.py # bootstrap CIs + paired randomization test
|
| 162 |
│ └── catalog.py # offline result integration
|
| 163 |
├── experiments/
|
| 164 |
│ ├── build_dataset.py
|
|
|
|
| 184 |
- A target string may tokenize into multiple tokens. The live workbench explicitly labels its target metric as the **first-token** probability in that case.
|
| 185 |
- A causal effect can depend strongly on prompt, layer, feature scale, and downstream task. The report therefore includes raw per-task rows rather than only aggregate means.
|
| 186 |
|
| 187 |
+
## v0.2 deployment hardening
|
| 188 |
+
|
| 189 |
+
The Space pins **Gradio 6.24.0** in both the Space metadata and Python dependencies and launches with `ssr_mode=False`. This deliberately avoids the SSR execution path that produced an un-awaited `get_current_user` coroutine warning in the initial deployment while keeping the app fully functional in client-side rendering mode.
|
| 190 |
+
|
| 191 |
+
The ZeroGPU callbacks also use shorter declared GPU durations than v0.1 and generation defaults to 16 new tokens, reducing queue cost for ordinary demo usage.
|
| 192 |
+
|
| 193 |
+
See [`docs/VALIDATION.md`](docs/VALIDATION.md) for the pre-push and hosted test matrix.
|
| 194 |
+
|
| 195 |
## Reproducibility and checks
|
| 196 |
|
| 197 |
```bash
|
app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import gradio as gr
|
| 4 |
|
| 5 |
from featurelens.config import SETTINGS
|
|
@@ -7,69 +8,115 @@ from featurelens.hf_runtime import gpu
|
|
| 7 |
from featurelens.runtime import RUNTIME
|
| 8 |
|
| 9 |
CSS = """
|
| 10 |
-
|
| 11 |
-
.
|
| 12 |
-
.hero {
|
| 13 |
-
.hero
|
| 14 |
-
.
|
| 15 |
-
.
|
|
|
|
|
|
|
| 16 |
.token-wrap { display:flex; flex-wrap:wrap; gap:5px; padding:8px 2px; line-height:1.7; }
|
| 17 |
-
.token { background:
|
| 18 |
-
.token.selected {
|
| 19 |
.token sup { opacity:.55; margin-right:4px; }
|
| 20 |
-
.small-note {
|
|
|
|
| 21 |
"""
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
def _analysis_metrics_markdown(result) -> str:
|
| 25 |
return (
|
| 26 |
f"**Layer {result.layer} · token {result.token_index}** \n"
|
| 27 |
f"Active SAE features: **{int(result.metrics['active_features'])}/{SETTINGS.sae_top_k}** \n"
|
| 28 |
-
f"Reconstruction cosine: **{result.metrics['cosine']:.4f}**
|
| 29 |
-
f"
|
|
|
|
| 30 |
)
|
| 31 |
|
| 32 |
|
| 33 |
def _intervention_metrics_markdown(result) -> str:
|
| 34 |
-
target =
|
| 35 |
if result.baseline_target_prob is not None:
|
| 36 |
-
warning =
|
| 37 |
if result.target_token_count > 1:
|
| 38 |
warning = (
|
| 39 |
f" \n_Note: target text tokenizes to {result.target_token_count} tokens; "
|
| 40 |
-
|
| 41 |
)
|
| 42 |
target = (
|
| 43 |
f"Target first token: `{result.target_token}` \n"
|
| 44 |
f"Baseline p: **{result.baseline_target_prob:.6f}** · "
|
| 45 |
-
f"
|
| 46 |
-
f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
)
|
| 48 |
return (
|
| 49 |
-
f"Original feature activation: **{result.feature_activation:.4f}**
|
| 50 |
-
f"Δ
|
| 51 |
-
f"
|
| 52 |
-
f"Next-token JS
|
| 53 |
-
f"{
|
|
|
|
|
|
|
| 54 |
)
|
| 55 |
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
if
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
return (
|
| 65 |
-
|
| 66 |
-
result.
|
| 67 |
-
feature_update,
|
| 68 |
-
_analysis_metrics_markdown(result),
|
| 69 |
)
|
| 70 |
|
| 71 |
|
| 72 |
-
@gpu(duration=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
def run_intervention(
|
| 74 |
prompt: str,
|
| 75 |
layer: int,
|
|
@@ -80,209 +127,359 @@ def run_intervention(
|
|
| 80 |
target_text: str,
|
| 81 |
max_new_tokens: int,
|
| 82 |
):
|
| 83 |
-
if not prompt.strip():
|
| 84 |
-
raise gr.Error('Enter a prompt first.')
|
| 85 |
-
if feature_id is None or str(feature_id).strip() == '':
|
| 86 |
-
raise gr.Error('Choose or enter a feature id.')
|
| 87 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
fid = int(float(feature_id))
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
|
| 109 |
def mode_help(mode: str):
|
| 110 |
-
if mode ==
|
| 111 |
return gr.update(
|
| 112 |
value=0.0,
|
| 113 |
interactive=False,
|
| 114 |
-
label=
|
| 115 |
-
info=
|
| 116 |
)
|
| 117 |
-
if mode ==
|
| 118 |
return gr.update(
|
| 119 |
value=2.0,
|
| 120 |
interactive=True,
|
| 121 |
-
label=
|
| 122 |
-
info=
|
| 123 |
)
|
| 124 |
return gr.update(
|
| 125 |
value=5.0,
|
| 126 |
interactive=True,
|
| 127 |
-
label=
|
| 128 |
-
info=
|
| 129 |
)
|
| 130 |
|
| 131 |
|
| 132 |
-
with gr.Blocks(title=
|
| 133 |
gr.HTML(
|
| 134 |
-
'<div class="hero"><h1>FeatureLens</h1>'
|
| 135 |
-
'<p>Causal sparse-feature interpretability for Qwen3-1.7B
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
| 140 |
)
|
| 141 |
|
| 142 |
-
with gr.Tab(
|
|
|
|
| 143 |
with gr.Row(equal_height=False):
|
| 144 |
-
with gr.Column(scale=
|
| 145 |
prompt = gr.Textbox(
|
| 146 |
-
label=
|
| 147 |
-
lines=
|
| 148 |
-
value=
|
| 149 |
-
placeholder=
|
| 150 |
)
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
label='Token index',
|
| 161 |
-
info='Use -1 for the final prompt token. Analyze once to see all token indices.',
|
| 162 |
-
)
|
| 163 |
-
top_n = gr.Slider(5, 20, value=12, step=1, label='Top features')
|
| 164 |
-
analyze_btn = gr.Button('Inspect SAE features', variant='primary')
|
| 165 |
-
token_view = gr.HTML(
|
| 166 |
-
'<div class="small-note">Token positions will appear here after analysis.</div>'
|
| 167 |
)
|
| 168 |
-
analysis_metrics = gr.Markdown()
|
| 169 |
-
|
| 170 |
with gr.Column(scale=3):
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
wrap=True,
|
| 177 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
gr.
|
| 180 |
gr.Markdown(
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
)
|
| 185 |
with gr.Row(equal_height=False):
|
| 186 |
with gr.Column(scale=2):
|
| 187 |
feature_id = gr.Dropdown(
|
| 188 |
choices=[],
|
| 189 |
allow_custom_value=True,
|
| 190 |
-
label=
|
| 191 |
-
info=
|
| 192 |
)
|
| 193 |
mode = gr.Radio(
|
| 194 |
-
choices=[
|
| 195 |
-
value=
|
| 196 |
-
label=
|
| 197 |
)
|
| 198 |
coefficient = gr.Number(
|
| 199 |
value=0.0,
|
| 200 |
interactive=False,
|
| 201 |
-
label=
|
| 202 |
)
|
| 203 |
target_text = gr.Textbox(
|
| 204 |
-
label=
|
| 205 |
-
placeholder=
|
| 206 |
-
info=
|
| 207 |
)
|
| 208 |
max_new = gr.Slider(
|
| 209 |
4,
|
| 210 |
SETTINGS.max_new_tokens,
|
| 211 |
-
value=min(
|
| 212 |
step=1,
|
| 213 |
-
label=
|
| 214 |
)
|
| 215 |
-
intervene_btn = gr.Button(
|
| 216 |
intervention_metrics = gr.Markdown()
|
| 217 |
-
|
| 218 |
with gr.Column(scale=3):
|
| 219 |
with gr.Row():
|
| 220 |
-
baseline_out = gr.Textbox(label=
|
| 221 |
-
modified_out = gr.Textbox(label=
|
| 222 |
token_prob_table = gr.Dataframe(
|
| 223 |
-
headers=[
|
| 224 |
-
datatype=[
|
| 225 |
interactive=False,
|
| 226 |
-
label=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
)
|
| 228 |
|
| 229 |
analyze_btn.click(
|
| 230 |
analyze_prompt,
|
| 231 |
inputs=[prompt, layer, token_index, top_n],
|
| 232 |
-
outputs=[token_view, feature_table, feature_id, analysis_metrics],
|
| 233 |
)
|
| 234 |
mode.change(mode_help, inputs=[mode], outputs=[coefficient])
|
| 235 |
intervene_btn.click(
|
| 236 |
run_intervention,
|
| 237 |
-
inputs=[
|
| 238 |
-
prompt,
|
| 239 |
-
layer,
|
| 240 |
-
token_index,
|
| 241 |
-
feature_id,
|
| 242 |
-
mode,
|
| 243 |
-
coefficient,
|
| 244 |
-
target_text,
|
| 245 |
-
max_new,
|
| 246 |
-
],
|
| 247 |
outputs=[baseline_out, modified_out, intervention_metrics, token_prob_table],
|
| 248 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
-
with gr.Tab(
|
| 251 |
-
|
| 252 |
gr.Markdown(
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
)
|
| 258 |
|
| 259 |
-
with gr.Tab(
|
| 260 |
gr.Markdown(
|
| 261 |
r"""
|
| 262 |
-
###
|
| 263 |
|
| 264 |
-
For residual vector $h$, sparse
|
| 265 |
|
| 266 |
- **Ablate:** $h' = h - z_i d_i$
|
| 267 |
-
- **Scale
|
| 268 |
-
- **Inject
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
-
|
| 271 |
-
$h$ itself, so SAE reconstruction error is not injected as a confound. The modified residual is
|
| 272 |
-
patched at one selected **prompt token**; downstream generation is then allowed to evolve normally.
|
| 273 |
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
-
A high
|
| 277 |
-
under intervention, held-out prompts, and comparison with controls. The offline report is designed
|
| 278 |
-
to make a weak or null causal result visible rather than hide it.
|
| 279 |
"""
|
| 280 |
)
|
| 281 |
|
| 282 |
gr.Markdown(
|
| 283 |
-
|
| 284 |
-
|
| 285 |
)
|
| 286 |
|
| 287 |
-
if __name__ ==
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import pandas as pd
|
| 4 |
import gradio as gr
|
| 5 |
|
| 6 |
from featurelens.config import SETTINGS
|
|
|
|
| 8 |
from featurelens.runtime import RUNTIME
|
| 9 |
|
| 10 |
CSS = """
|
| 11 |
+
.gradio-container { max-width: 1320px !important; }
|
| 12 |
+
.hero { padding: 8px 2px 2px; }
|
| 13 |
+
.hero h1 { margin: 0; font-size: 2.35rem; letter-spacing: -0.045em; }
|
| 14 |
+
.hero p { margin: .35rem 0 0; opacity: .72; font-size: 1rem; }
|
| 15 |
+
.research-q { border-left: 4px solid var(--primary-500); padding: 10px 14px; margin: 10px 0 14px; border-radius: 0 10px 10px 0; background: var(--background-fill-secondary); }
|
| 16 |
+
.badges { display:flex; flex-wrap:wrap; gap:7px; margin:8px 0 3px; }
|
| 17 |
+
.badge { border:1px solid var(--border-color-primary); background:var(--background-fill-secondary); border-radius:999px; padding:4px 9px; font-size:12px; }
|
| 18 |
+
.step { font-size: .78rem; text-transform: uppercase; letter-spacing:.09em; opacity:.62; font-weight:700; margin-top:2px; }
|
| 19 |
.token-wrap { display:flex; flex-wrap:wrap; gap:5px; padding:8px 2px; line-height:1.7; }
|
| 20 |
+
.token { background:var(--background-fill-secondary); border:1px solid var(--border-color-primary); border-radius:7px; padding:2px 7px; font-family:ui-monospace,SFMono-Regular,monospace; font-size:12px; }
|
| 21 |
+
.token.selected { border:2px solid var(--primary-500); color:var(--primary-600); font-weight:600; }
|
| 22 |
.token sup { opacity:.55; margin-right:4px; }
|
| 23 |
+
.small-note { opacity:.65; font-size:12px; }
|
| 24 |
+
.callout { border:1px solid var(--border-color-primary); background:var(--background-fill-secondary); border-radius:12px; padding:10px 12px; }
|
| 25 |
"""
|
| 26 |
|
| 27 |
+
THEME = gr.themes.Soft(primary_hue="blue", neutral_hue="slate")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _raise_ui_error(exc: Exception) -> None:
|
| 31 |
+
raise gr.Error(f"{type(exc).__name__}: {exc}") from exc
|
| 32 |
+
|
| 33 |
|
| 34 |
def _analysis_metrics_markdown(result) -> str:
|
| 35 |
return (
|
| 36 |
f"**Layer {result.layer} · token {result.token_index}** \n"
|
| 37 |
f"Active SAE features: **{int(result.metrics['active_features'])}/{SETTINGS.sae_top_k}** \n"
|
| 38 |
+
f"Reconstruction cosine: **{result.metrics['cosine']:.4f}** · "
|
| 39 |
+
f"NMSE: **{result.metrics['nmse']:.4f}** \n"
|
| 40 |
+
f"Top-5 activation mass: **{result.metrics['top5_mass_fraction']:.1%}**"
|
| 41 |
)
|
| 42 |
|
| 43 |
|
| 44 |
def _intervention_metrics_markdown(result) -> str:
|
| 45 |
+
target = "No target continuation supplied — specificity is shown using JS divergence only."
|
| 46 |
if result.baseline_target_prob is not None:
|
| 47 |
+
warning = ""
|
| 48 |
if result.target_token_count > 1:
|
| 49 |
warning = (
|
| 50 |
f" \n_Note: target text tokenizes to {result.target_token_count} tokens; "
|
| 51 |
+
"the displayed causal metric is for its first token._"
|
| 52 |
)
|
| 53 |
target = (
|
| 54 |
f"Target first token: `{result.target_token}` \n"
|
| 55 |
f"Baseline p: **{result.baseline_target_prob:.6f}** · "
|
| 56 |
+
f"SAE edit p: **{result.modified_target_prob:.6f}** · "
|
| 57 |
+
f"Random-control p: **{result.random_target_prob:.6f}** \n"
|
| 58 |
+
f"SAE Δlog p: **{result.target_logprob_delta:+.4f}** · "
|
| 59 |
+
f"random Δlog p: **{result.random_target_logprob_delta:+.4f}** · "
|
| 60 |
+
f"specificity ratio: **{result.target_specificity_ratio:.2f}×**{warning}"
|
| 61 |
+
)
|
| 62 |
+
inactive = ""
|
| 63 |
+
if abs(result.feature_activation) < 1e-12:
|
| 64 |
+
inactive = (
|
| 65 |
+
" \n⚠️ **Selected feature is inactive at this token.** Ablate/scale therefore produces "
|
| 66 |
+
"a zero feature delta; use `inject` to test the decoder direction directly."
|
| 67 |
)
|
| 68 |
return (
|
| 69 |
+
f"Original feature activation: **{result.feature_activation:.4f}** · "
|
| 70 |
+
f"Δ coefficient: **{result.delta_activation:+.4f}** \n"
|
| 71 |
+
f"Perturbation L2: **{result.perturbation_norm:.4f}** \n"
|
| 72 |
+
f"Next-token JS: **{result.js_divergence:.6f}** · "
|
| 73 |
+
f"random-control JS: **{result.random_js_divergence:.6f}** · "
|
| 74 |
+
f"specificity: **{result.js_specificity_ratio:.2f}×** \n\n"
|
| 75 |
+
f"{target}{inactive}"
|
| 76 |
)
|
| 77 |
|
| 78 |
|
| 79 |
+
def _dose_metrics_markdown(result) -> str:
|
| 80 |
+
note = ""
|
| 81 |
+
if result.target_token_count > 1:
|
| 82 |
+
note = (
|
| 83 |
+
f" Target text spans {result.target_token_count} tokens; the curve measures its first token."
|
| 84 |
+
)
|
| 85 |
+
inactive = ""
|
| 86 |
+
if abs(result.feature_activation) < 1e-12:
|
| 87 |
+
inactive = " **The feature is inactive here, so a multiplicative sweep is flat by construction.**"
|
| 88 |
return (
|
| 89 |
+
f"Feature activation at baseline: **{result.feature_activation:.4f}** · "
|
| 90 |
+
f"target first token: `{result.target_token}`.{note}{inactive}"
|
|
|
|
|
|
|
| 91 |
)
|
| 92 |
|
| 93 |
|
| 94 |
+
@gpu(duration=30)
|
| 95 |
+
def analyze_prompt(prompt: str, layer: int, token_index: int, top_n: int):
|
| 96 |
+
try:
|
| 97 |
+
if not prompt.strip():
|
| 98 |
+
raise ValueError("Enter a prompt first.")
|
| 99 |
+
result = RUNTIME.analyze(prompt, int(layer), int(token_index), int(top_n))
|
| 100 |
+
choices = [str(int(row[1])) for row in result.rows]
|
| 101 |
+
feature_update = gr.update(choices=choices, value=choices[0] if choices else None)
|
| 102 |
+
chart_df = pd.DataFrame(
|
| 103 |
+
{
|
| 104 |
+
"Feature": [str(int(row[1])) for row in result.rows],
|
| 105 |
+
"Activation": [float(row[2]) for row in result.rows],
|
| 106 |
+
}
|
| 107 |
+
)
|
| 108 |
+
return (
|
| 109 |
+
RUNTIME.token_html(result.tokens, result.token_index),
|
| 110 |
+
result.rows,
|
| 111 |
+
chart_df,
|
| 112 |
+
feature_update,
|
| 113 |
+
_analysis_metrics_markdown(result),
|
| 114 |
+
)
|
| 115 |
+
except Exception as exc:
|
| 116 |
+
_raise_ui_error(exc)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@gpu(duration=45)
|
| 120 |
def run_intervention(
|
| 121 |
prompt: str,
|
| 122 |
layer: int,
|
|
|
|
| 127 |
target_text: str,
|
| 128 |
max_new_tokens: int,
|
| 129 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
try:
|
| 131 |
+
if not prompt.strip():
|
| 132 |
+
raise ValueError("Enter a prompt first.")
|
| 133 |
+
if feature_id is None or str(feature_id).strip() == "":
|
| 134 |
+
raise ValueError("Choose or enter a feature id.")
|
| 135 |
fid = int(float(feature_id))
|
| 136 |
+
result = RUNTIME.intervene(
|
| 137 |
+
text=prompt,
|
| 138 |
+
layer=int(layer),
|
| 139 |
+
token_index=int(token_index),
|
| 140 |
+
feature_id=fid,
|
| 141 |
+
mode=mode,
|
| 142 |
+
coefficient=float(coefficient),
|
| 143 |
+
target_text=target_text,
|
| 144 |
+
max_new_tokens=int(max_new_tokens),
|
| 145 |
+
)
|
| 146 |
+
return (
|
| 147 |
+
result.baseline_text,
|
| 148 |
+
result.modified_text,
|
| 149 |
+
_intervention_metrics_markdown(result),
|
| 150 |
+
result.top_token_rows,
|
| 151 |
+
)
|
| 152 |
+
except Exception as exc:
|
| 153 |
+
_raise_ui_error(exc)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@gpu(duration=40)
|
| 157 |
+
def run_dose_response(
|
| 158 |
+
prompt: str,
|
| 159 |
+
layer: int,
|
| 160 |
+
token_index: int,
|
| 161 |
+
feature_id: str,
|
| 162 |
+
target_text: str,
|
| 163 |
+
):
|
| 164 |
+
try:
|
| 165 |
+
if not prompt.strip():
|
| 166 |
+
raise ValueError("Enter a prompt first.")
|
| 167 |
+
if feature_id is None or str(feature_id).strip() == "":
|
| 168 |
+
raise ValueError("Choose or enter a feature id.")
|
| 169 |
+
if not target_text.strip():
|
| 170 |
+
raise ValueError("Enter a target continuation before running a dose-response sweep.")
|
| 171 |
+
result = RUNTIME.dose_response(
|
| 172 |
+
text=prompt,
|
| 173 |
+
layer=int(layer),
|
| 174 |
+
token_index=int(token_index),
|
| 175 |
+
feature_id=int(float(feature_id)),
|
| 176 |
+
target_text=target_text,
|
| 177 |
+
)
|
| 178 |
+
columns = [
|
| 179 |
+
"Multiplier",
|
| 180 |
+
"Δ feature coefficient",
|
| 181 |
+
"Perturbation L2",
|
| 182 |
+
"Baseline p(target)",
|
| 183 |
+
"Modified p(target)",
|
| 184 |
+
"Δ log p(target)",
|
| 185 |
+
"JS divergence",
|
| 186 |
+
]
|
| 187 |
+
table = pd.DataFrame(result.rows, columns=columns)
|
| 188 |
+
plot = table[["Multiplier", "Δ log p(target)"]].copy()
|
| 189 |
+
return table, plot, _dose_metrics_markdown(result)
|
| 190 |
+
except Exception as exc:
|
| 191 |
+
_raise_ui_error(exc)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
@gpu(duration=35)
|
| 195 |
+
def run_layer_sweep(prompt: str, token_index: int):
|
| 196 |
+
try:
|
| 197 |
+
if not prompt.strip():
|
| 198 |
+
raise ValueError("Enter a prompt first.")
|
| 199 |
+
result = RUNTIME.layer_sweep(prompt, int(token_index))
|
| 200 |
+
columns = [
|
| 201 |
+
"Layer",
|
| 202 |
+
"Reconstruction cosine",
|
| 203 |
+
"NMSE",
|
| 204 |
+
"Active features",
|
| 205 |
+
"Top activation",
|
| 206 |
+
"Top-5 mass",
|
| 207 |
+
"Activation entropy",
|
| 208 |
+
]
|
| 209 |
+
table = pd.DataFrame(result.rows, columns=columns)
|
| 210 |
+
long = table.melt(
|
| 211 |
+
id_vars=["Layer"],
|
| 212 |
+
value_vars=["Reconstruction cosine", "Top-5 mass", "Activation entropy"],
|
| 213 |
+
var_name="Metric",
|
| 214 |
+
value_name="Value",
|
| 215 |
+
)
|
| 216 |
+
return RUNTIME.token_html(result.tokens, result.token_index), table, long
|
| 217 |
+
except Exception as exc:
|
| 218 |
+
_raise_ui_error(exc)
|
| 219 |
|
| 220 |
|
| 221 |
def mode_help(mode: str):
|
| 222 |
+
if mode == "ablate":
|
| 223 |
return gr.update(
|
| 224 |
value=0.0,
|
| 225 |
interactive=False,
|
| 226 |
+
label="Coefficient (unused for ablation)",
|
| 227 |
+
info="Sets the selected active feature coefficient to zero.",
|
| 228 |
)
|
| 229 |
+
if mode == "scale":
|
| 230 |
return gr.update(
|
| 231 |
value=2.0,
|
| 232 |
interactive=True,
|
| 233 |
+
label="Feature multiplier",
|
| 234 |
+
info="1 = unchanged, 0 = ablate, 2 = double the original coefficient.",
|
| 235 |
)
|
| 236 |
return gr.update(
|
| 237 |
value=5.0,
|
| 238 |
interactive=True,
|
| 239 |
+
label="Additive feature coefficient",
|
| 240 |
+
info="Adds this amount along the decoder direction, even when the feature is inactive.",
|
| 241 |
)
|
| 242 |
|
| 243 |
|
| 244 |
+
with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench") as demo:
|
| 245 |
gr.HTML(
|
| 246 |
+
'<div class="hero"><h1>FeatureLens <span style="font-size:.42em;opacity:.55">v0.2</span></h1>'
|
| 247 |
+
'<p>Causal sparse-feature interpretability for Qwen3-1.7B — inspect → intervene → control → measure.</p></div>'
|
| 248 |
+
'<div class="research-q"><b>Research question:</b> Do sparse features that predict a concept also '
|
| 249 |
+
'causally influence the model’s behaviour?</div>'
|
| 250 |
+
'<div class="badges">'
|
| 251 |
+
'<span class="badge">Qwen3-1.7B-Base</span><span class="badge">Qwen-Scope SAE</span>'
|
| 252 |
+
'<span class="badge">32,768 features</span><span class="badge">TopK=50</span>'
|
| 253 |
+
'<span class="badge">norm-matched controls</span><span class="badge">ZeroGPU</span></div>'
|
| 254 |
)
|
| 255 |
|
| 256 |
+
with gr.Tab("Workbench"):
|
| 257 |
+
gr.HTML('<div class="step">Step 1 · Choose a prompt and residual location</div>')
|
| 258 |
with gr.Row(equal_height=False):
|
| 259 |
+
with gr.Column(scale=5):
|
| 260 |
prompt = gr.Textbox(
|
| 261 |
+
label="Prompt",
|
| 262 |
+
lines=5,
|
| 263 |
+
value="The derivative of x squared is",
|
| 264 |
+
placeholder="Enter a prompt to inspect…",
|
| 265 |
)
|
| 266 |
+
gr.Examples(
|
| 267 |
+
examples=[
|
| 268 |
+
["The derivative of x squared is"],
|
| 269 |
+
["In Python, reverse a list using"],
|
| 270 |
+
["Je voudrais réserver une table pour"],
|
| 271 |
+
["I am not fully certain, but the answer may be"],
|
| 272 |
+
],
|
| 273 |
+
inputs=[prompt],
|
| 274 |
+
label="Controlled examples",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
)
|
|
|
|
|
|
|
| 276 |
with gr.Column(scale=3):
|
| 277 |
+
layer = gr.Dropdown(
|
| 278 |
+
choices=list(SETTINGS.layers),
|
| 279 |
+
value=SETTINGS.layers[1] if len(SETTINGS.layers) > 1 else SETTINGS.layers[0],
|
| 280 |
+
label="Residual layer",
|
| 281 |
+
info="Early / middle / late checkpoints are intentionally sampled.",
|
|
|
|
| 282 |
)
|
| 283 |
+
token_index = gr.Number(
|
| 284 |
+
value=-1,
|
| 285 |
+
precision=0,
|
| 286 |
+
label="Prompt token index",
|
| 287 |
+
info="-1 = final prompt token. Inspect once to see all token positions.",
|
| 288 |
+
)
|
| 289 |
+
top_n = gr.Slider(5, 20, value=12, step=1, label="Top features")
|
| 290 |
+
analyze_btn = gr.Button("Inspect sparse features", variant="primary")
|
| 291 |
+
|
| 292 |
+
token_view = gr.HTML('<div class="small-note">Token positions appear here after inspection.</div>')
|
| 293 |
+
analysis_metrics = gr.Markdown()
|
| 294 |
+
with gr.Row(equal_height=False):
|
| 295 |
+
feature_table = gr.Dataframe(
|
| 296 |
+
headers=["Rank", "Feature id", "Activation", "Offline concept hint"],
|
| 297 |
+
datatype=["number", "number", "number", "str"],
|
| 298 |
+
interactive=False,
|
| 299 |
+
label="Strongest TopK features",
|
| 300 |
+
wrap=True,
|
| 301 |
+
scale=3,
|
| 302 |
+
)
|
| 303 |
+
feature_plot = gr.BarPlot(
|
| 304 |
+
x="Feature",
|
| 305 |
+
y="Activation",
|
| 306 |
+
title="Activation profile",
|
| 307 |
+
x_title="Feature id",
|
| 308 |
+
y_title="Activation",
|
| 309 |
+
scale=2,
|
| 310 |
+
)
|
| 311 |
|
| 312 |
+
gr.HTML('<div class="step">Step 2 · Intervene and compare against a matched control</div>')
|
| 313 |
gr.Markdown(
|
| 314 |
+
"FeatureLens changes only the selected SAE coefficient and adds that decoder-vector delta "
|
| 315 |
+
"to the **original** residual. The live negative control applies a deterministic random "
|
| 316 |
+
"residual perturbation with the **same L2 norm**."
|
| 317 |
)
|
| 318 |
with gr.Row(equal_height=False):
|
| 319 |
with gr.Column(scale=2):
|
| 320 |
feature_id = gr.Dropdown(
|
| 321 |
choices=[],
|
| 322 |
allow_custom_value=True,
|
| 323 |
+
label="Feature id",
|
| 324 |
+
info="Inspection populates the strongest active features; custom IDs are also allowed.",
|
| 325 |
)
|
| 326 |
mode = gr.Radio(
|
| 327 |
+
choices=["ablate", "scale", "inject"],
|
| 328 |
+
value="ablate",
|
| 329 |
+
label="Intervention",
|
| 330 |
)
|
| 331 |
coefficient = gr.Number(
|
| 332 |
value=0.0,
|
| 333 |
interactive=False,
|
| 334 |
+
label="Coefficient (unused for ablation)",
|
| 335 |
)
|
| 336 |
target_text = gr.Textbox(
|
| 337 |
+
label="Optional target continuation",
|
| 338 |
+
placeholder="e.g. 2x",
|
| 339 |
+
info="Supplying this enables target-token Δlog p and causal-specificity metrics.",
|
| 340 |
)
|
| 341 |
max_new = gr.Slider(
|
| 342 |
4,
|
| 343 |
SETTINGS.max_new_tokens,
|
| 344 |
+
value=min(16, SETTINGS.max_new_tokens),
|
| 345 |
step=1,
|
| 346 |
+
label="Max new tokens",
|
| 347 |
)
|
| 348 |
+
intervene_btn = gr.Button("Run causal test", variant="primary")
|
| 349 |
intervention_metrics = gr.Markdown()
|
|
|
|
| 350 |
with gr.Column(scale=3):
|
| 351 |
with gr.Row():
|
| 352 |
+
baseline_out = gr.Textbox(label="Baseline generation", lines=7, interactive=False)
|
| 353 |
+
modified_out = gr.Textbox(label="SAE-intervened generation", lines=7, interactive=False)
|
| 354 |
token_prob_table = gr.Dataframe(
|
| 355 |
+
headers=["Token", "Baseline p", "SAE-edit p", "Δ probability"],
|
| 356 |
+
datatype=["str", "number", "number", "number"],
|
| 357 |
interactive=False,
|
| 358 |
+
label="Next-token distribution shift",
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
gr.HTML('<div class="step">Step 3 · Test dose–response</div>')
|
| 362 |
+
with gr.Accordion("Causal dose–response sweep", open=False):
|
| 363 |
+
gr.Markdown(
|
| 364 |
+
"A single coefficient can be cherry-picked. This sweep scales the same active feature "
|
| 365 |
+
"through **0×, 0.5×, 1×, 1.5×, 2× and 3×** and measures target-token Δlog p. "
|
| 366 |
+
"A coherent monotonic response is stronger causal evidence than one isolated edit."
|
| 367 |
+
)
|
| 368 |
+
dose_btn = gr.Button("Run dose–response sweep")
|
| 369 |
+
dose_metrics = gr.Markdown()
|
| 370 |
+
with gr.Row():
|
| 371 |
+
dose_table = gr.Dataframe(interactive=False, label="Dose-response measurements", scale=3)
|
| 372 |
+
dose_plot = gr.LinePlot(
|
| 373 |
+
x="Multiplier",
|
| 374 |
+
y="Δ log p(target)",
|
| 375 |
+
title="Causal dose–response",
|
| 376 |
+
x_title="Feature multiplier",
|
| 377 |
+
y_title="Δ log p(target)",
|
| 378 |
+
scale=2,
|
| 379 |
)
|
| 380 |
|
| 381 |
analyze_btn.click(
|
| 382 |
analyze_prompt,
|
| 383 |
inputs=[prompt, layer, token_index, top_n],
|
| 384 |
+
outputs=[token_view, feature_table, feature_plot, feature_id, analysis_metrics],
|
| 385 |
)
|
| 386 |
mode.change(mode_help, inputs=[mode], outputs=[coefficient])
|
| 387 |
intervene_btn.click(
|
| 388 |
run_intervention,
|
| 389 |
+
inputs=[prompt, layer, token_index, feature_id, mode, coefficient, target_text, max_new],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
outputs=[baseline_out, modified_out, intervention_metrics, token_prob_table],
|
| 391 |
)
|
| 392 |
+
dose_btn.click(
|
| 393 |
+
run_dose_response,
|
| 394 |
+
inputs=[prompt, layer, token_index, feature_id, target_text],
|
| 395 |
+
outputs=[dose_table, dose_plot, dose_metrics],
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
with gr.Tab("Layer trajectory"):
|
| 399 |
+
gr.Markdown(
|
| 400 |
+
"### Follow the representation across early, middle and late residual streams\n"
|
| 401 |
+
"This is **not** a cross-layer feature-ID comparison — SAE dictionaries are layer-specific. "
|
| 402 |
+
"Instead, it compares reconstruction quality and sparsity/concentration statistics at the "
|
| 403 |
+
"same prompt token across layers 4, 14 and 26."
|
| 404 |
+
)
|
| 405 |
+
with gr.Row():
|
| 406 |
+
trajectory_prompt = gr.Textbox(
|
| 407 |
+
label="Prompt",
|
| 408 |
+
lines=5,
|
| 409 |
+
value="The derivative of x squared is",
|
| 410 |
+
scale=4,
|
| 411 |
+
)
|
| 412 |
+
trajectory_token = gr.Number(
|
| 413 |
+
value=-1,
|
| 414 |
+
precision=0,
|
| 415 |
+
label="Prompt token index",
|
| 416 |
+
info="-1 = final token",
|
| 417 |
+
scale=1,
|
| 418 |
+
)
|
| 419 |
+
trajectory_btn = gr.Button("Compare layers", variant="primary")
|
| 420 |
+
trajectory_tokens = gr.HTML()
|
| 421 |
+
with gr.Row():
|
| 422 |
+
trajectory_table = gr.Dataframe(interactive=False, label="Layer diagnostics", scale=3)
|
| 423 |
+
trajectory_plot = gr.LinePlot(
|
| 424 |
+
x="Layer",
|
| 425 |
+
y="Value",
|
| 426 |
+
color="Metric",
|
| 427 |
+
title="Representation trajectory",
|
| 428 |
+
x_title="Layer",
|
| 429 |
+
y_title="Normalized value",
|
| 430 |
+
scale=2,
|
| 431 |
+
)
|
| 432 |
+
trajectory_btn.click(
|
| 433 |
+
run_layer_sweep,
|
| 434 |
+
inputs=[trajectory_prompt, trajectory_token],
|
| 435 |
+
outputs=[trajectory_tokens, trajectory_table, trajectory_plot],
|
| 436 |
+
)
|
| 437 |
|
| 438 |
+
with gr.Tab("Offline benchmark"):
|
| 439 |
+
gr.Markdown(RUNTIME.catalog.benchmark_markdown())
|
| 440 |
gr.Markdown(
|
| 441 |
+
"The offline pipeline evaluates held-out feature/concept AUROC + F1, reconstruction quality, "
|
| 442 |
+
"paraphrase stability, dense residual linear probes, causal ablation/amplification and "
|
| 443 |
+
"norm-matched random-direction controls. Results are loaded from `artifacts/`; the app never "
|
| 444 |
+
"ships invented benchmark numbers."
|
| 445 |
)
|
| 446 |
|
| 447 |
+
with gr.Tab("Method"):
|
| 448 |
gr.Markdown(
|
| 449 |
r"""
|
| 450 |
+
### Reconstruction-preserving intervention
|
| 451 |
|
| 452 |
+
For residual vector $h$, selected sparse coefficient $z_i$, decoder direction $d_i$, and scale $\alpha$:
|
| 453 |
|
| 454 |
- **Ablate:** $h' = h - z_i d_i$
|
| 455 |
+
- **Scale:** $h' = h + (\alpha - 1) z_i d_i$
|
| 456 |
+
- **Inject:** $h' = h + \delta d_i$
|
| 457 |
+
|
| 458 |
+
The app edits the original residual rather than replacing it with the full SAE reconstruction, so SAE
|
| 459 |
+
reconstruction error is not introduced as a causal confound.
|
| 460 |
|
| 461 |
+
### Evidence ladder
|
|
|
|
|
|
|
| 462 |
|
| 463 |
+
1. **Reconstruction:** does the SAE represent the residual reasonably well?
|
| 464 |
+
2. **Prediction:** does a feature predict a controlled concept on held-out paraphrase groups?
|
| 465 |
+
3. **Intervention:** does changing it alter downstream behaviour?
|
| 466 |
+
4. **Specificity:** is that effect larger than a norm-matched random residual perturbation?
|
| 467 |
+
5. **Dose-response:** does effect size change coherently as the feature coefficient is varied?
|
| 468 |
|
| 469 |
+
A high AUROC alone remains correlational evidence.
|
|
|
|
|
|
|
| 470 |
"""
|
| 471 |
)
|
| 472 |
|
| 473 |
gr.Markdown(
|
| 474 |
+
"<small>Built with PyTorch, Transformers, Qwen3-1.7B-Base and Qwen-Scope residual-stream SAEs. "
|
| 475 |
+
"FeatureLens is independent of thesis code and thesis datasets.</small>"
|
| 476 |
)
|
| 477 |
|
| 478 |
+
if __name__ == "__main__":
|
| 479 |
+
# Explicit CSR avoids an HF/Gradio SSR auth-coroutine warning seen in 6.23.x-era Space builds.
|
| 480 |
+
demo.queue(default_concurrency_limit=1, max_size=8).launch(
|
| 481 |
+
css=CSS,
|
| 482 |
+
theme=THEME,
|
| 483 |
+
ssr_mode=False,
|
| 484 |
+
show_error=True,
|
| 485 |
+
)
|
docs/VALIDATION.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FeatureLens validation matrix
|
| 2 |
+
|
| 3 |
+
FeatureLens has two distinct validation targets: software correctness and scientific validity. Passing one does not imply the other.
|
| 4 |
+
|
| 5 |
+
## Before every push
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
python -m ruff check app.py featurelens experiments tests scripts
|
| 9 |
+
python -m pytest -q
|
| 10 |
+
python -m compileall -q app.py featurelens experiments scripts
|
| 11 |
+
python scripts/release_check.py
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
Also run `git status` and confirm that no `__pycache__/`, `pycache/`, `.pytest_cache/`, model weights, or `artifacts/activations/` files are staged.
|
| 15 |
+
|
| 16 |
+
## Local UI smoke test
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
python app.py
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
Verify that the app opens without an SSR banner and without `get_current_user ... was never awaited` warnings. The app intentionally launches with `ssr_mode=False`.
|
| 23 |
+
|
| 24 |
+
On a CUDA machine, test one example through each path:
|
| 25 |
+
|
| 26 |
+
1. Inspect features on layer 14, final token.
|
| 27 |
+
2. Ablate the strongest active feature.
|
| 28 |
+
3. Supply a target continuation and confirm SAE-edit and random-control metrics both appear.
|
| 29 |
+
4. Run the dose-response sweep and confirm the 1× row has approximately zero intervention effect.
|
| 30 |
+
5. Run the layer trajectory and confirm rows for 4, 14 and 26.
|
| 31 |
+
|
| 32 |
+
## Hugging Face Space smoke test
|
| 33 |
+
|
| 34 |
+
After a push/rebuild:
|
| 35 |
+
|
| 36 |
+
1. Confirm Space hardware is ZeroGPU and status reaches **Running**.
|
| 37 |
+
2. Check startup logs for model/SAE download or load failures.
|
| 38 |
+
3. Confirm the launch line does **not** say `with SSR`.
|
| 39 |
+
4. Run `The derivative of x squared is` at layer 14, token `-1`.
|
| 40 |
+
5. Confirm the feature dropdown is populated after inspection.
|
| 41 |
+
6. Run an ablation with `target continuation = 2x`.
|
| 42 |
+
7. Confirm baseline and modified generations render and random-control metrics appear.
|
| 43 |
+
8. Run a dose-response sweep once; confirm the curve renders.
|
| 44 |
+
9. Run the layer trajectory once; confirm all three layers render.
|
| 45 |
+
10. Refresh the page and repeat a short inspection to catch state/cold-start regressions.
|
| 46 |
+
|
| 47 |
+
## Scientific acceptance checks
|
| 48 |
+
|
| 49 |
+
Do not publish headline findings until the offline benchmark is run on the real Qwen3/Qwen-Scope weights.
|
| 50 |
+
|
| 51 |
+
Required checks:
|
| 52 |
+
|
| 53 |
+
- paraphrase groups do not cross train/test splits;
|
| 54 |
+
- feature selection uses training metrics only;
|
| 55 |
+
- held-out AUROC/F1 are reported separately;
|
| 56 |
+
- reconstruction metrics are reported per layer;
|
| 57 |
+
- causal tasks are separate from discovery prompts;
|
| 58 |
+
- SAE edits and random controls are paired by task/intervention;
|
| 59 |
+
- perturbation norms match within numerical tolerance;
|
| 60 |
+
- bootstrap confidence intervals are included;
|
| 61 |
+
- paired sign-flip p-value is reported as uncertainty evidence, not as proof of a mechanistic claim;
|
| 62 |
+
- raw causal rows remain available for inspection;
|
| 63 |
+
- null or mixed results are retained in the generated report.
|
| 64 |
+
|
| 65 |
+
## Recommended manual adversarial tests
|
| 66 |
+
|
| 67 |
+
Use prompts designed to expose failure modes rather than only attractive examples:
|
| 68 |
+
|
| 69 |
+
- a mathematics prompt with a code-like token;
|
| 70 |
+
- English content containing one French entity name;
|
| 71 |
+
- negation such as `This is not a positive review`;
|
| 72 |
+
- an uncertainty prompt that becomes certain in its final clause;
|
| 73 |
+
- a custom inactive feature ID with `ablate` versus `inject`;
|
| 74 |
+
- a multi-token target continuation to verify the first-token warning;
|
| 75 |
+
- token index `0`, `-1`, and an invalid out-of-range index;
|
| 76 |
+
- feature ID `0`, `32767`, and an invalid `32768`.
|
experiments/make_report.py
CHANGED
|
@@ -9,6 +9,11 @@ import numpy as np
|
|
| 9 |
import pandas as pd
|
| 10 |
|
| 11 |
from experiments.common import ARTIFACT_DIR
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def parse_args() -> argparse.Namespace:
|
|
@@ -84,6 +89,7 @@ def main() -> None:
|
|
| 84 |
|
| 85 |
mean_auc = float(selected['auroc'].mean())
|
| 86 |
median_auc = float(selected['auroc'].median())
|
|
|
|
| 87 |
best_layer_row = layers.sort_values('linear_probe_macro_auroc', ascending=False).iloc[0]
|
| 88 |
mean_jaccard = float(stability['topk_jaccard'].mean())
|
| 89 |
mean_sparse_cos = float(stability['sparse_cosine'].mean())
|
|
@@ -93,6 +99,21 @@ def main() -> None:
|
|
| 93 |
sae_abs = float(np.mean(np.abs(sae['target_logprob_delta'])))
|
| 94 |
random_abs = float(np.mean(np.abs(random['target_logprob_delta'])))
|
| 95 |
ratio = sae_abs / max(random_abs, 1e-12)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
active_rate = float(np.mean(sae['feature_activation'] > 0))
|
| 97 |
top1_change = float(sae['top1_changed'].mean())
|
| 98 |
|
|
@@ -128,11 +149,12 @@ def main() -> None:
|
|
| 128 |
'random residual controls.'
|
| 129 |
)
|
| 130 |
highlights = [
|
| 131 |
-
f'Median selected-feature held-out AUROC: {median_auc:.3f}.',
|
| 132 |
f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
|
| 133 |
f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
|
| 134 |
f'Selected feature active on {active_rate:.1%} of causal prompts; modified top-1 token on {top1_change:.1%}.',
|
| 135 |
f'Mean absolute causal effect / random-control effect ratio: {ratio:.2f}×.',
|
|
|
|
| 136 |
]
|
| 137 |
summary = {
|
| 138 |
'headline': headline,
|
|
@@ -140,6 +162,7 @@ def main() -> None:
|
|
| 140 |
'interpretation': interpretation,
|
| 141 |
'metrics': {
|
| 142 |
'mean_selected_feature_test_auroc': mean_auc,
|
|
|
|
| 143 |
'median_selected_feature_test_auroc': median_auc,
|
| 144 |
'best_linear_probe_layer': int(best_layer_row['layer']),
|
| 145 |
'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
|
|
@@ -148,6 +171,9 @@ def main() -> None:
|
|
| 148 |
'mean_abs_sae_target_logprob_delta': sae_abs,
|
| 149 |
'mean_abs_random_target_logprob_delta': random_abs,
|
| 150 |
'causal_to_random_effect_ratio': ratio,
|
|
|
|
|
|
|
|
|
|
| 151 |
'causal_prompt_feature_active_rate': active_rate,
|
| 152 |
'sae_top1_change_rate': top1_change,
|
| 153 |
},
|
|
@@ -181,6 +207,7 @@ def main() -> None:
|
|
| 181 |
'- Linear baseline: multinomial logistic regression on the dense residual stream.',
|
| 182 |
'- Causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
|
| 183 |
'- Negative control: deterministic random residual direction matched to the SAE perturbation L2 norm.',
|
|
|
|
| 184 |
'- Behavioural metric: first-token target probability/log-probability, rank, JS divergence, and top-1 changes.',
|
| 185 |
'',
|
| 186 |
'## Figures',
|
|
|
|
| 9 |
import pandas as pd
|
| 10 |
|
| 11 |
from experiments.common import ARTIFACT_DIR
|
| 12 |
+
from featurelens.stats import (
|
| 13 |
+
bootstrap_mean_ci,
|
| 14 |
+
paired_bootstrap_difference_ci,
|
| 15 |
+
paired_sign_flip_pvalue,
|
| 16 |
+
)
|
| 17 |
|
| 18 |
|
| 19 |
def parse_args() -> argparse.Namespace:
|
|
|
|
| 89 |
|
| 90 |
mean_auc = float(selected['auroc'].mean())
|
| 91 |
median_auc = float(selected['auroc'].median())
|
| 92 |
+
auc_ci_low, auc_ci_high = bootstrap_mean_ci(selected['auroc'].to_numpy(), seed=42)
|
| 93 |
best_layer_row = layers.sort_values('linear_probe_macro_auroc', ascending=False).iloc[0]
|
| 94 |
mean_jaccard = float(stability['topk_jaccard'].mean())
|
| 95 |
mean_sparse_cos = float(stability['sparse_cosine'].mean())
|
|
|
|
| 99 |
sae_abs = float(np.mean(np.abs(sae['target_logprob_delta'])))
|
| 100 |
random_abs = float(np.mean(np.abs(random['target_logprob_delta'])))
|
| 101 |
ratio = sae_abs / max(random_abs, 1e-12)
|
| 102 |
+
|
| 103 |
+
paired = causal.pivot_table(
|
| 104 |
+
index=['task_id', 'intervention'],
|
| 105 |
+
columns='condition',
|
| 106 |
+
values='target_logprob_delta',
|
| 107 |
+
aggfunc='first',
|
| 108 |
+
).dropna(subset=['sae_feature', 'random_norm_matched'])
|
| 109 |
+
paired_sae = np.abs(paired['sae_feature'].to_numpy(dtype=float))
|
| 110 |
+
paired_random = np.abs(paired['random_norm_matched'].to_numpy(dtype=float))
|
| 111 |
+
diff_mean = float(np.mean(paired_sae - paired_random))
|
| 112 |
+
diff_ci_low, diff_ci_high = paired_bootstrap_difference_ci(
|
| 113 |
+
paired_sae, paired_random, seed=43
|
| 114 |
+
)
|
| 115 |
+
sign_flip_p = paired_sign_flip_pvalue(paired_sae, paired_random, seed=44)
|
| 116 |
+
|
| 117 |
active_rate = float(np.mean(sae['feature_activation'] > 0))
|
| 118 |
top1_change = float(sae['top1_changed'].mean())
|
| 119 |
|
|
|
|
| 149 |
'random residual controls.'
|
| 150 |
)
|
| 151 |
highlights = [
|
| 152 |
+
f'Median selected-feature held-out AUROC: {median_auc:.3f}; mean AUROC 95% bootstrap CI [{auc_ci_low:.3f}, {auc_ci_high:.3f}].',
|
| 153 |
f'Best residual linear-probe layer: {int(best_layer_row["layer"])} with macro AUROC {best_layer_row["linear_probe_macro_auroc"]:.3f}.',
|
| 154 |
f'Mean paraphrase TopK Jaccard: {mean_jaccard:.3f}; sparse activation cosine: {mean_sparse_cos:.3f}.',
|
| 155 |
f'Selected feature active on {active_rate:.1%} of causal prompts; modified top-1 token on {top1_change:.1%}.',
|
| 156 |
f'Mean absolute causal effect / random-control effect ratio: {ratio:.2f}×.',
|
| 157 |
+
f'Paired mean |Δlog p| advantage over random: {diff_mean:+.3f}, 95% bootstrap CI [{diff_ci_low:+.3f}, {diff_ci_high:+.3f}], sign-flip p={sign_flip_p:.4f}.',
|
| 158 |
]
|
| 159 |
summary = {
|
| 160 |
'headline': headline,
|
|
|
|
| 162 |
'interpretation': interpretation,
|
| 163 |
'metrics': {
|
| 164 |
'mean_selected_feature_test_auroc': mean_auc,
|
| 165 |
+
'mean_selected_feature_test_auroc_bootstrap_ci_95': [auc_ci_low, auc_ci_high],
|
| 166 |
'median_selected_feature_test_auroc': median_auc,
|
| 167 |
'best_linear_probe_layer': int(best_layer_row['layer']),
|
| 168 |
'best_linear_probe_macro_auroc': float(best_layer_row['linear_probe_macro_auroc']),
|
|
|
|
| 171 |
'mean_abs_sae_target_logprob_delta': sae_abs,
|
| 172 |
'mean_abs_random_target_logprob_delta': random_abs,
|
| 173 |
'causal_to_random_effect_ratio': ratio,
|
| 174 |
+
'paired_mean_abs_effect_advantage': diff_mean,
|
| 175 |
+
'paired_mean_abs_effect_advantage_bootstrap_ci_95': [diff_ci_low, diff_ci_high],
|
| 176 |
+
'paired_sign_flip_pvalue': sign_flip_p,
|
| 177 |
'causal_prompt_feature_active_rate': active_rate,
|
| 178 |
'sae_top1_change_rate': top1_change,
|
| 179 |
},
|
|
|
|
| 207 |
'- Linear baseline: multinomial logistic regression on the dense residual stream.',
|
| 208 |
'- Causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
|
| 209 |
'- Negative control: deterministic random residual direction matched to the SAE perturbation L2 norm.',
|
| 210 |
+
'- Uncertainty: bootstrap 95% confidence intervals and a paired sign-flip randomization test for SAE-vs-control effect differences.',
|
| 211 |
'- Behavioural metric: first-token target probability/log-probability, rank, JS divergence, and top-1 changes.',
|
| 212 |
'',
|
| 213 |
'## Figures',
|
featurelens/runtime.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import html
|
|
|
|
| 4 |
import os
|
| 5 |
-
from collections.abc import Iterator
|
| 6 |
from contextlib import contextmanager
|
| 7 |
from dataclasses import dataclass
|
| 8 |
|
|
@@ -11,7 +13,11 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
| 11 |
|
| 12 |
from .catalog import FeatureCatalog
|
| 13 |
from .config import SETTINGS, Settings
|
| 14 |
-
from .interventions import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from .metrics import js_divergence_from_logits, reconstruction_metrics, safe_log_probability
|
| 16 |
from .sae import SAEStore, SparseEncoding
|
| 17 |
|
|
@@ -28,8 +34,8 @@ def _dtype_from_name(name: str) -> torch.dtype:
|
|
| 28 |
|
| 29 |
|
| 30 |
def _default_device() -> torch.device:
|
| 31 |
-
# ZeroGPU exposes CUDA emulation at module load time.
|
| 32 |
-
#
|
| 33 |
if os.getenv('SPACE_ID'):
|
| 34 |
return torch.device('cuda')
|
| 35 |
if torch.cuda.is_available():
|
|
@@ -55,15 +61,35 @@ class InterventionResult:
|
|
| 55 |
delta_activation: float
|
| 56 |
perturbation_norm: float
|
| 57 |
js_divergence: float
|
|
|
|
|
|
|
| 58 |
target_text: str
|
| 59 |
target_token: str
|
| 60 |
target_token_count: int
|
| 61 |
baseline_target_prob: float | None
|
| 62 |
modified_target_prob: float | None
|
|
|
|
| 63 |
target_logprob_delta: float | None
|
|
|
|
|
|
|
| 64 |
top_token_rows: list[list[object]]
|
| 65 |
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
class FeatureLensRuntime:
|
| 68 |
def __init__(self, settings: Settings = SETTINGS) -> None:
|
| 69 |
self.settings = settings
|
|
@@ -78,8 +104,13 @@ class FeatureLensRuntime:
|
|
| 78 |
self.catalog = FeatureCatalog()
|
| 79 |
self.load_error: str | None = None
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
def ensure_ready(self, preload_saes: bool = False) -> None:
|
| 82 |
-
if self.
|
|
|
|
| 83 |
if preload_saes:
|
| 84 |
self.sae_store.preload()
|
| 85 |
return
|
|
@@ -103,6 +134,7 @@ class FeatureLensRuntime:
|
|
| 103 |
)
|
| 104 |
if preload_saes:
|
| 105 |
self.sae_store.preload()
|
|
|
|
| 106 |
|
| 107 |
def token_choices(self, text: str) -> list[tuple[str, int]]:
|
| 108 |
self.ensure_ready(preload_saes=False)
|
|
@@ -146,6 +178,25 @@ class FeatureLensRuntime:
|
|
| 146 |
finally:
|
| 147 |
handle.remove()
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
@contextmanager
|
| 150 |
def _delta_hook(self, layer: int, token_index: int, delta: torch.Tensor) -> Iterator[None]:
|
| 151 |
assert self.model is not None
|
|
@@ -174,6 +225,20 @@ class FeatureLensRuntime:
|
|
| 174 |
finally:
|
| 175 |
handle.remove()
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
@torch.inference_mode()
|
| 178 |
def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
|
| 179 |
self.ensure_ready(preload_saes=False)
|
|
@@ -185,18 +250,18 @@ class FeatureLensRuntime:
|
|
| 185 |
with self._capture_hook(int(layer), bucket):
|
| 186 |
self.model(**inputs, use_cache=False)
|
| 187 |
hidden = bucket['hidden'][0]
|
| 188 |
-
|
| 189 |
-
idx = int(token_index)
|
| 190 |
-
if idx < 0:
|
| 191 |
-
idx = seq_len + idx
|
| 192 |
-
if idx < 0 or idx >= seq_len:
|
| 193 |
-
raise IndexError(f'Token index {token_index} outside prompt length {seq_len}.')
|
| 194 |
residual = hidden[idx]
|
| 195 |
sae = self.sae_store.get(int(layer))
|
| 196 |
encoding = sae.encode(residual)
|
| 197 |
reconstruction = sae.decode_sparse(encoding)
|
| 198 |
metrics = reconstruction_metrics(residual, reconstruction)
|
| 199 |
metrics['active_features'] = float(encoding.active_count)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
ids = inputs['input_ids'][0].tolist()
|
| 201 |
tokens = [self.tokenizer.decode([token_id]) for token_id in ids]
|
| 202 |
rows: list[list[object]] = []
|
|
@@ -216,6 +281,52 @@ class FeatureLensRuntime:
|
|
| 216 |
metrics=metrics,
|
| 217 |
)
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
def token_html(self, tokens: list[str], selected_index: int) -> str:
|
| 220 |
chips = []
|
| 221 |
for idx, token in enumerate(tokens):
|
|
@@ -255,11 +366,7 @@ class FeatureLensRuntime:
|
|
| 255 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 256 |
inputs = self._inputs(text)
|
| 257 |
prompt_len = int(inputs['input_ids'].shape[1])
|
| 258 |
-
idx = int(token_index)
|
| 259 |
-
if idx < 0:
|
| 260 |
-
idx = prompt_len + idx
|
| 261 |
-
if idx < 0 or idx >= prompt_len:
|
| 262 |
-
raise IndexError(f'Token index {token_index} outside prompt length {prompt_len}.')
|
| 263 |
|
| 264 |
sae = self.sae_store.get(int(layer))
|
| 265 |
capture: dict = {}
|
|
@@ -281,6 +388,15 @@ class FeatureLensRuntime:
|
|
| 281 |
with self._delta_hook(int(layer), idx, delta):
|
| 282 |
modified = self.model.generate(**inputs, **generation_kwargs)
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
baseline_ids = baseline.sequences[0, prompt_len:]
|
| 285 |
modified_ids = modified.sequences[0, prompt_len:]
|
| 286 |
baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
|
|
@@ -291,10 +407,12 @@ class FeatureLensRuntime:
|
|
| 291 |
baseline_logits = baseline.scores[0][0]
|
| 292 |
modified_logits = modified.scores[0][0]
|
| 293 |
js = js_divergence_from_logits(baseline_logits, modified_logits)
|
|
|
|
|
|
|
| 294 |
|
| 295 |
target_token = ''
|
| 296 |
target_count = 0
|
| 297 |
-
bp = mp = log_delta = None
|
| 298 |
if target_text.strip():
|
| 299 |
target_ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
|
| 300 |
target_count = len(target_ids)
|
|
@@ -303,9 +421,13 @@ class FeatureLensRuntime:
|
|
| 303 |
target_token = self.tokenizer.decode([target_id])
|
| 304 |
p = torch.softmax(baseline_logits.float(), dim=-1)
|
| 305 |
q = torch.softmax(modified_logits.float(), dim=-1)
|
|
|
|
| 306 |
bp = float(p[target_id].item())
|
| 307 |
mp = float(q[target_id].item())
|
|
|
|
| 308 |
log_delta = safe_log_probability(mp) - safe_log_probability(bp)
|
|
|
|
|
|
|
| 309 |
|
| 310 |
return InterventionResult(
|
| 311 |
baseline_text=baseline_text,
|
|
@@ -314,17 +436,87 @@ class FeatureLensRuntime:
|
|
| 314 |
delta_activation=float(spec.delta_activation(original_activation)),
|
| 315 |
perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
|
| 316 |
js_divergence=float(js),
|
|
|
|
|
|
|
| 317 |
target_text=target_text,
|
| 318 |
target_token=target_token,
|
| 319 |
target_token_count=target_count,
|
| 320 |
baseline_target_prob=bp,
|
| 321 |
modified_target_prob=mp,
|
|
|
|
| 322 |
target_logprob_delta=log_delta,
|
|
|
|
|
|
|
| 323 |
top_token_rows=self._top_token_rows(
|
| 324 |
self.tokenizer, baseline_logits, modified_logits, k=8
|
| 325 |
),
|
| 326 |
)
|
| 327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
RUNTIME = FeatureLensRuntime()
|
| 330 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import hashlib
|
| 4 |
import html
|
| 5 |
+
import math
|
| 6 |
import os
|
| 7 |
+
from collections.abc import Iterator, Sequence
|
| 8 |
from contextlib import contextmanager
|
| 9 |
from dataclasses import dataclass
|
| 10 |
|
|
|
|
| 13 |
|
| 14 |
from .catalog import FeatureCatalog
|
| 15 |
from .config import SETTINGS, Settings
|
| 16 |
+
from .interventions import (
|
| 17 |
+
InterventionSpec,
|
| 18 |
+
normalized_random_control,
|
| 19 |
+
residual_delta,
|
| 20 |
+
)
|
| 21 |
from .metrics import js_divergence_from_logits, reconstruction_metrics, safe_log_probability
|
| 22 |
from .sae import SAEStore, SparseEncoding
|
| 23 |
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
def _default_device() -> torch.device:
|
| 37 |
+
# ZeroGPU exposes CUDA emulation at module load time. Hugging Face recommends
|
| 38 |
+
# placing models on CUDA at module scope so startup transfers can be optimized.
|
| 39 |
if os.getenv('SPACE_ID'):
|
| 40 |
return torch.device('cuda')
|
| 41 |
if torch.cuda.is_available():
|
|
|
|
| 61 |
delta_activation: float
|
| 62 |
perturbation_norm: float
|
| 63 |
js_divergence: float
|
| 64 |
+
random_js_divergence: float
|
| 65 |
+
js_specificity_ratio: float
|
| 66 |
target_text: str
|
| 67 |
target_token: str
|
| 68 |
target_token_count: int
|
| 69 |
baseline_target_prob: float | None
|
| 70 |
modified_target_prob: float | None
|
| 71 |
+
random_target_prob: float | None
|
| 72 |
target_logprob_delta: float | None
|
| 73 |
+
random_target_logprob_delta: float | None
|
| 74 |
+
target_specificity_ratio: float | None
|
| 75 |
top_token_rows: list[list[object]]
|
| 76 |
|
| 77 |
|
| 78 |
+
@dataclass
|
| 79 |
+
class LayerSweepResult:
|
| 80 |
+
tokens: list[str]
|
| 81 |
+
token_index: int
|
| 82 |
+
rows: list[list[object]]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass
|
| 86 |
+
class DoseResponseResult:
|
| 87 |
+
feature_activation: float
|
| 88 |
+
target_token: str
|
| 89 |
+
target_token_count: int
|
| 90 |
+
rows: list[list[object]]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
class FeatureLensRuntime:
|
| 94 |
def __init__(self, settings: Settings = SETTINGS) -> None:
|
| 95 |
self.settings = settings
|
|
|
|
| 104 |
self.catalog = FeatureCatalog()
|
| 105 |
self.load_error: str | None = None
|
| 106 |
|
| 107 |
+
@property
|
| 108 |
+
def ready(self) -> bool:
|
| 109 |
+
return self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 110 |
+
|
| 111 |
def ensure_ready(self, preload_saes: bool = False) -> None:
|
| 112 |
+
if self.ready:
|
| 113 |
+
assert self.sae_store is not None
|
| 114 |
if preload_saes:
|
| 115 |
self.sae_store.preload()
|
| 116 |
return
|
|
|
|
| 134 |
)
|
| 135 |
if preload_saes:
|
| 136 |
self.sae_store.preload()
|
| 137 |
+
self.load_error = None
|
| 138 |
|
| 139 |
def token_choices(self, text: str) -> list[tuple[str, int]]:
|
| 140 |
self.ensure_ready(preload_saes=False)
|
|
|
|
| 178 |
finally:
|
| 179 |
handle.remove()
|
| 180 |
|
| 181 |
+
@contextmanager
|
| 182 |
+
def _capture_hooks(self, layers: Sequence[int], buckets: dict[int, dict]) -> Iterator[None]:
|
| 183 |
+
assert self.model is not None
|
| 184 |
+
handles = []
|
| 185 |
+
for layer in layers:
|
| 186 |
+
bucket = buckets[int(layer)]
|
| 187 |
+
|
| 188 |
+
def hook(_module, _inputs, output, *, target=bucket):
|
| 189 |
+
hidden = self._hidden_from_output(output)
|
| 190 |
+
if 'hidden' not in target:
|
| 191 |
+
target['hidden'] = hidden.detach()
|
| 192 |
+
|
| 193 |
+
handles.append(self.model.model.layers[int(layer)].register_forward_hook(hook))
|
| 194 |
+
try:
|
| 195 |
+
yield
|
| 196 |
+
finally:
|
| 197 |
+
for handle in handles:
|
| 198 |
+
handle.remove()
|
| 199 |
+
|
| 200 |
@contextmanager
|
| 201 |
def _delta_hook(self, layer: int, token_index: int, delta: torch.Tensor) -> Iterator[None]:
|
| 202 |
assert self.model is not None
|
|
|
|
| 225 |
finally:
|
| 226 |
handle.remove()
|
| 227 |
|
| 228 |
+
@staticmethod
|
| 229 |
+
def _resolve_index(token_index: int, seq_len: int) -> int:
|
| 230 |
+
idx = int(token_index)
|
| 231 |
+
if idx < 0:
|
| 232 |
+
idx = seq_len + idx
|
| 233 |
+
if idx < 0 or idx >= seq_len:
|
| 234 |
+
raise IndexError(f'Token index {token_index} outside prompt length {seq_len}.')
|
| 235 |
+
return idx
|
| 236 |
+
|
| 237 |
+
@staticmethod
|
| 238 |
+
def _control_seed(text: str, layer: int, feature_id: int, mode: str, coefficient: float) -> int:
|
| 239 |
+
payload = f'{text}\0{layer}\0{feature_id}\0{mode}\0{coefficient:.8g}'.encode('utf-8')
|
| 240 |
+
return int.from_bytes(hashlib.sha256(payload).digest()[:4], 'big', signed=False)
|
| 241 |
+
|
| 242 |
@torch.inference_mode()
|
| 243 |
def analyze(self, text: str, layer: int, token_index: int = -1, top_n: int = 12) -> AnalysisResult:
|
| 244 |
self.ensure_ready(preload_saes=False)
|
|
|
|
| 250 |
with self._capture_hook(int(layer), bucket):
|
| 251 |
self.model(**inputs, use_cache=False)
|
| 252 |
hidden = bucket['hidden'][0]
|
| 253 |
+
idx = self._resolve_index(int(token_index), hidden.shape[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
residual = hidden[idx]
|
| 255 |
sae = self.sae_store.get(int(layer))
|
| 256 |
encoding = sae.encode(residual)
|
| 257 |
reconstruction = sae.decode_sparse(encoding)
|
| 258 |
metrics = reconstruction_metrics(residual, reconstruction)
|
| 259 |
metrics['active_features'] = float(encoding.active_count)
|
| 260 |
+
values = encoding.values.float().clamp_min(0)
|
| 261 |
+
total = float(values.sum().item())
|
| 262 |
+
metrics['top5_mass_fraction'] = (
|
| 263 |
+
float(values[: min(5, values.numel())].sum().item()) / total if total > 0 else 0.0
|
| 264 |
+
)
|
| 265 |
ids = inputs['input_ids'][0].tolist()
|
| 266 |
tokens = [self.tokenizer.decode([token_id]) for token_id in ids]
|
| 267 |
rows: list[list[object]] = []
|
|
|
|
| 281 |
metrics=metrics,
|
| 282 |
)
|
| 283 |
|
| 284 |
+
@torch.inference_mode()
|
| 285 |
+
def layer_sweep(self, text: str, token_index: int = -1) -> LayerSweepResult:
|
| 286 |
+
self.ensure_ready(preload_saes=True)
|
| 287 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 288 |
+
inputs = self._inputs(text)
|
| 289 |
+
buckets = {int(layer): {} for layer in self.settings.layers}
|
| 290 |
+
with self._capture_hooks(self.settings.layers, buckets):
|
| 291 |
+
self.model(**inputs, use_cache=False)
|
| 292 |
+
|
| 293 |
+
seq_len = int(inputs['input_ids'].shape[1])
|
| 294 |
+
idx = self._resolve_index(int(token_index), seq_len)
|
| 295 |
+
rows: list[list[object]] = []
|
| 296 |
+
for layer in self.settings.layers:
|
| 297 |
+
residual = buckets[int(layer)]['hidden'][0, idx]
|
| 298 |
+
sae = self.sae_store.get(int(layer))
|
| 299 |
+
encoding = sae.encode(residual)
|
| 300 |
+
reconstruction = sae.decode_sparse(encoding)
|
| 301 |
+
metrics = reconstruction_metrics(residual, reconstruction)
|
| 302 |
+
values = encoding.values.float().clamp_min(0)
|
| 303 |
+
positive = values[values > 0]
|
| 304 |
+
total = positive.sum()
|
| 305 |
+
if positive.numel() <= 1 or float(total.item()) <= 0:
|
| 306 |
+
entropy = 0.0
|
| 307 |
+
else:
|
| 308 |
+
probs = positive / total
|
| 309 |
+
entropy = float((-(probs * torch.log(probs)).sum() / math.log(positive.numel())).item())
|
| 310 |
+
top5_fraction = (
|
| 311 |
+
float(values[: min(5, values.numel())].sum().item() / total.item())
|
| 312 |
+
if float(total.item()) > 0
|
| 313 |
+
else 0.0
|
| 314 |
+
)
|
| 315 |
+
rows.append(
|
| 316 |
+
[
|
| 317 |
+
int(layer),
|
| 318 |
+
float(metrics['cosine']),
|
| 319 |
+
float(metrics['nmse']),
|
| 320 |
+
int(encoding.active_count),
|
| 321 |
+
float(values[0].item()) if values.numel() else 0.0,
|
| 322 |
+
top5_fraction,
|
| 323 |
+
entropy,
|
| 324 |
+
]
|
| 325 |
+
)
|
| 326 |
+
ids = inputs['input_ids'][0].tolist()
|
| 327 |
+
tokens = [self.tokenizer.decode([token_id]) for token_id in ids]
|
| 328 |
+
return LayerSweepResult(tokens=tokens, token_index=idx, rows=rows)
|
| 329 |
+
|
| 330 |
def token_html(self, tokens: list[str], selected_index: int) -> str:
|
| 331 |
chips = []
|
| 332 |
for idx, token in enumerate(tokens):
|
|
|
|
| 366 |
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 367 |
inputs = self._inputs(text)
|
| 368 |
prompt_len = int(inputs['input_ids'].shape[1])
|
| 369 |
+
idx = self._resolve_index(int(token_index), prompt_len)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
sae = self.sae_store.get(int(layer))
|
| 372 |
capture: dict = {}
|
|
|
|
| 388 |
with self._delta_hook(int(layer), idx, delta):
|
| 389 |
modified = self.model.generate(**inputs, **generation_kwargs)
|
| 390 |
|
| 391 |
+
# Live negative control: one extra forward pass, not another full generation.
|
| 392 |
+
control_delta = normalized_random_control(
|
| 393 |
+
delta,
|
| 394 |
+
seed=self._control_seed(text, int(layer), int(feature_id), mode, float(coefficient)),
|
| 395 |
+
)
|
| 396 |
+
with self._delta_hook(int(layer), idx, control_delta):
|
| 397 |
+
random_out = self.model(**inputs, use_cache=False)
|
| 398 |
+
random_logits = random_out.logits[0, -1]
|
| 399 |
+
|
| 400 |
baseline_ids = baseline.sequences[0, prompt_len:]
|
| 401 |
modified_ids = modified.sequences[0, prompt_len:]
|
| 402 |
baseline_text = self.tokenizer.decode(baseline_ids, skip_special_tokens=True)
|
|
|
|
| 407 |
baseline_logits = baseline.scores[0][0]
|
| 408 |
modified_logits = modified.scores[0][0]
|
| 409 |
js = js_divergence_from_logits(baseline_logits, modified_logits)
|
| 410 |
+
random_js = js_divergence_from_logits(baseline_logits, random_logits)
|
| 411 |
+
js_ratio = abs(js) / max(abs(random_js), 1e-12)
|
| 412 |
|
| 413 |
target_token = ''
|
| 414 |
target_count = 0
|
| 415 |
+
bp = mp = rp = log_delta = random_log_delta = specificity = None
|
| 416 |
if target_text.strip():
|
| 417 |
target_ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
|
| 418 |
target_count = len(target_ids)
|
|
|
|
| 421 |
target_token = self.tokenizer.decode([target_id])
|
| 422 |
p = torch.softmax(baseline_logits.float(), dim=-1)
|
| 423 |
q = torch.softmax(modified_logits.float(), dim=-1)
|
| 424 |
+
r = torch.softmax(random_logits.float(), dim=-1)
|
| 425 |
bp = float(p[target_id].item())
|
| 426 |
mp = float(q[target_id].item())
|
| 427 |
+
rp = float(r[target_id].item())
|
| 428 |
log_delta = safe_log_probability(mp) - safe_log_probability(bp)
|
| 429 |
+
random_log_delta = safe_log_probability(rp) - safe_log_probability(bp)
|
| 430 |
+
specificity = abs(log_delta) / max(abs(random_log_delta), 1e-12)
|
| 431 |
|
| 432 |
return InterventionResult(
|
| 433 |
baseline_text=baseline_text,
|
|
|
|
| 436 |
delta_activation=float(spec.delta_activation(original_activation)),
|
| 437 |
perturbation_norm=float(torch.linalg.vector_norm(delta.float()).item()),
|
| 438 |
js_divergence=float(js),
|
| 439 |
+
random_js_divergence=float(random_js),
|
| 440 |
+
js_specificity_ratio=float(js_ratio),
|
| 441 |
target_text=target_text,
|
| 442 |
target_token=target_token,
|
| 443 |
target_token_count=target_count,
|
| 444 |
baseline_target_prob=bp,
|
| 445 |
modified_target_prob=mp,
|
| 446 |
+
random_target_prob=rp,
|
| 447 |
target_logprob_delta=log_delta,
|
| 448 |
+
random_target_logprob_delta=random_log_delta,
|
| 449 |
+
target_specificity_ratio=specificity,
|
| 450 |
top_token_rows=self._top_token_rows(
|
| 451 |
self.tokenizer, baseline_logits, modified_logits, k=8
|
| 452 |
),
|
| 453 |
)
|
| 454 |
|
| 455 |
+
@torch.inference_mode()
|
| 456 |
+
def dose_response(
|
| 457 |
+
self,
|
| 458 |
+
text: str,
|
| 459 |
+
layer: int,
|
| 460 |
+
token_index: int,
|
| 461 |
+
feature_id: int,
|
| 462 |
+
target_text: str,
|
| 463 |
+
multipliers: Sequence[float] = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0),
|
| 464 |
+
) -> DoseResponseResult:
|
| 465 |
+
if not target_text.strip():
|
| 466 |
+
raise ValueError('Dose-response requires a target continuation.')
|
| 467 |
+
self.ensure_ready(preload_saes=False)
|
| 468 |
+
assert self.model is not None and self.tokenizer is not None and self.sae_store is not None
|
| 469 |
+
inputs = self._inputs(text)
|
| 470 |
+
prompt_len = int(inputs['input_ids'].shape[1])
|
| 471 |
+
idx = self._resolve_index(int(token_index), prompt_len)
|
| 472 |
+
sae = self.sae_store.get(int(layer))
|
| 473 |
+
|
| 474 |
+
capture: dict = {}
|
| 475 |
+
with self._capture_hook(int(layer), capture):
|
| 476 |
+
baseline_out = self.model(**inputs, use_cache=False)
|
| 477 |
+
baseline_logits = baseline_out.logits[0, -1]
|
| 478 |
+
residual = capture['hidden'][0, idx]
|
| 479 |
+
encoding = sae.encode(residual)
|
| 480 |
+
original_activation = encoding.activation_for(int(feature_id))
|
| 481 |
+
direction = sae.decoder_direction(int(feature_id))
|
| 482 |
+
|
| 483 |
+
target_ids = self.tokenizer(target_text, add_special_tokens=False)['input_ids']
|
| 484 |
+
if not target_ids:
|
| 485 |
+
raise ValueError('Target continuation tokenized to an empty sequence.')
|
| 486 |
+
target_id = int(target_ids[0])
|
| 487 |
+
target_token = self.tokenizer.decode([target_id])
|
| 488 |
+
baseline_prob = float(torch.softmax(baseline_logits.float(), dim=-1)[target_id].item())
|
| 489 |
+
|
| 490 |
+
rows: list[list[object]] = []
|
| 491 |
+
for multiplier in multipliers:
|
| 492 |
+
spec = InterventionSpec('scale', float(multiplier))
|
| 493 |
+
delta = residual_delta(direction, original_activation, spec)
|
| 494 |
+
if float(torch.linalg.vector_norm(delta.float()).item()) == 0.0:
|
| 495 |
+
modified_logits = baseline_logits
|
| 496 |
+
else:
|
| 497 |
+
with self._delta_hook(int(layer), idx, delta):
|
| 498 |
+
output = self.model(**inputs, use_cache=False)
|
| 499 |
+
modified_logits = output.logits[0, -1]
|
| 500 |
+
modified_prob = float(torch.softmax(modified_logits.float(), dim=-1)[target_id].item())
|
| 501 |
+
log_delta = safe_log_probability(modified_prob) - safe_log_probability(baseline_prob)
|
| 502 |
+
rows.append(
|
| 503 |
+
[
|
| 504 |
+
float(multiplier),
|
| 505 |
+
float(spec.delta_activation(original_activation)),
|
| 506 |
+
float(torch.linalg.vector_norm(delta.float()).item()),
|
| 507 |
+
baseline_prob,
|
| 508 |
+
modified_prob,
|
| 509 |
+
float(log_delta),
|
| 510 |
+
float(js_divergence_from_logits(baseline_logits, modified_logits)),
|
| 511 |
+
]
|
| 512 |
+
)
|
| 513 |
+
return DoseResponseResult(
|
| 514 |
+
feature_activation=float(original_activation),
|
| 515 |
+
target_token=target_token,
|
| 516 |
+
target_token_count=len(target_ids),
|
| 517 |
+
rows=rows,
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
|
| 521 |
RUNTIME = FeatureLensRuntime()
|
| 522 |
|
featurelens/stats.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def bootstrap_mean_ci(
|
| 7 |
+
values: np.ndarray | list[float],
|
| 8 |
+
*,
|
| 9 |
+
confidence: float = 0.95,
|
| 10 |
+
n_resamples: int = 5000,
|
| 11 |
+
seed: int = 42,
|
| 12 |
+
) -> tuple[float, float]:
|
| 13 |
+
"""Percentile bootstrap CI for a sample mean."""
|
| 14 |
+
x = np.asarray(values, dtype=float)
|
| 15 |
+
x = x[np.isfinite(x)]
|
| 16 |
+
if x.size == 0:
|
| 17 |
+
return float('nan'), float('nan')
|
| 18 |
+
if x.size == 1:
|
| 19 |
+
value = float(x[0])
|
| 20 |
+
return value, value
|
| 21 |
+
rng = np.random.default_rng(seed)
|
| 22 |
+
sample_idx = rng.integers(0, x.size, size=(int(n_resamples), x.size))
|
| 23 |
+
means = x[sample_idx].mean(axis=1)
|
| 24 |
+
alpha = (1.0 - float(confidence)) / 2.0
|
| 25 |
+
low, high = np.quantile(means, [alpha, 1.0 - alpha])
|
| 26 |
+
return float(low), float(high)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def paired_bootstrap_difference_ci(
|
| 30 |
+
a: np.ndarray | list[float],
|
| 31 |
+
b: np.ndarray | list[float],
|
| 32 |
+
*,
|
| 33 |
+
confidence: float = 0.95,
|
| 34 |
+
n_resamples: int = 5000,
|
| 35 |
+
seed: int = 42,
|
| 36 |
+
) -> tuple[float, float]:
|
| 37 |
+
"""Bootstrap CI for mean(a - b), preserving paired rows."""
|
| 38 |
+
x = np.asarray(a, dtype=float)
|
| 39 |
+
y = np.asarray(b, dtype=float)
|
| 40 |
+
mask = np.isfinite(x) & np.isfinite(y)
|
| 41 |
+
x = x[mask]
|
| 42 |
+
y = y[mask]
|
| 43 |
+
if x.size == 0:
|
| 44 |
+
return float('nan'), float('nan')
|
| 45 |
+
diff = x - y
|
| 46 |
+
return bootstrap_mean_ci(
|
| 47 |
+
diff,
|
| 48 |
+
confidence=confidence,
|
| 49 |
+
n_resamples=n_resamples,
|
| 50 |
+
seed=seed,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def paired_sign_flip_pvalue(
|
| 55 |
+
a: np.ndarray | list[float],
|
| 56 |
+
b: np.ndarray | list[float],
|
| 57 |
+
*,
|
| 58 |
+
n_permutations: int = 20000,
|
| 59 |
+
seed: int = 42,
|
| 60 |
+
) -> float:
|
| 61 |
+
"""Two-sided paired randomization test for a non-zero mean difference."""
|
| 62 |
+
x = np.asarray(a, dtype=float)
|
| 63 |
+
y = np.asarray(b, dtype=float)
|
| 64 |
+
mask = np.isfinite(x) & np.isfinite(y)
|
| 65 |
+
diff = (x - y)[mask]
|
| 66 |
+
if diff.size == 0:
|
| 67 |
+
return float('nan')
|
| 68 |
+
observed = abs(float(diff.mean()))
|
| 69 |
+
if observed == 0.0:
|
| 70 |
+
return 1.0
|
| 71 |
+
rng = np.random.default_rng(seed)
|
| 72 |
+
extreme = 0
|
| 73 |
+
batch = 2000
|
| 74 |
+
remaining = int(n_permutations)
|
| 75 |
+
while remaining > 0:
|
| 76 |
+
n = min(batch, remaining)
|
| 77 |
+
signs = rng.choice(np.array([-1.0, 1.0]), size=(n, diff.size))
|
| 78 |
+
permuted = np.abs((signs * diff).mean(axis=1))
|
| 79 |
+
extreme += int(np.count_nonzero(permuted >= observed))
|
| 80 |
+
remaining -= n
|
| 81 |
+
return float((extreme + 1) / (int(n_permutations) + 1))
|
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.2.0"
|
| 4 |
description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
|
| 5 |
requires-python = ">=3.10"
|
| 6 |
|
requirements.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
torch>=2.8.0,<2.12.0
|
| 2 |
transformers>=4.51.0,<6.0.0
|
| 3 |
huggingface_hub>=0.34.0,<2.0.0
|
| 4 |
-
gradio
|
| 5 |
numpy>=2.0.0,<3.0.0
|
| 6 |
scipy>=1.14.0,<2.0.0
|
| 7 |
scikit-learn>=1.6.0,<2.0.0
|
|
|
|
| 1 |
torch>=2.8.0,<2.12.0
|
| 2 |
transformers>=4.51.0,<6.0.0
|
| 3 |
huggingface_hub>=0.34.0,<2.0.0
|
| 4 |
+
gradio==6.24.0
|
| 5 |
numpy>=2.0.0,<3.0.0
|
| 6 |
scipy>=1.14.0,<2.0.0
|
| 7 |
scikit-learn>=1.6.0,<2.0.0
|
research_config.json
CHANGED
|
@@ -2,7 +2,11 @@
|
|
| 2 |
"research_question": "Do sparse features that predict a concept also causally influence model behaviour?",
|
| 3 |
"model_id": "Qwen/Qwen3-1.7B-Base",
|
| 4 |
"sae_repo_id": "Qwen/SAE-Res-Qwen3-1.7B-Base-W32K-L0_50",
|
| 5 |
-
"layers": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"sae_top_k": 50,
|
| 7 |
"sae_width": 32768,
|
| 8 |
"concepts": [
|
|
@@ -19,8 +23,27 @@
|
|
| 19 |
"causal_tasks": 28,
|
| 20 |
"split_seed": 42,
|
| 21 |
"feature_selection": "training-split AUROC with activation-rate contrast tie-break",
|
| 22 |
-
"held_out_metrics": [
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
"negative_control": "norm-matched random residual direction",
|
| 25 |
-
"primary_causal_metric": "target first-token log-probability delta"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
|
|
|
| 2 |
"research_question": "Do sparse features that predict a concept also causally influence model behaviour?",
|
| 3 |
"model_id": "Qwen/Qwen3-1.7B-Base",
|
| 4 |
"sae_repo_id": "Qwen/SAE-Res-Qwen3-1.7B-Base-W32K-L0_50",
|
| 5 |
+
"layers": [
|
| 6 |
+
4,
|
| 7 |
+
14,
|
| 8 |
+
26
|
| 9 |
+
],
|
| 10 |
"sae_top_k": 50,
|
| 11 |
"sae_width": 32768,
|
| 12 |
"concepts": [
|
|
|
|
| 23 |
"causal_tasks": 28,
|
| 24 |
"split_seed": 42,
|
| 25 |
"feature_selection": "training-split AUROC with activation-rate contrast tie-break",
|
| 26 |
+
"held_out_metrics": [
|
| 27 |
+
"AUROC",
|
| 28 |
+
"F1"
|
| 29 |
+
],
|
| 30 |
+
"causal_interventions": [
|
| 31 |
+
"ablate",
|
| 32 |
+
"scale_2x"
|
| 33 |
+
],
|
| 34 |
"negative_control": "norm-matched random residual direction",
|
| 35 |
+
"primary_causal_metric": "target first-token log-probability delta",
|
| 36 |
+
"live_causal_controls": "norm-matched random residual direction",
|
| 37 |
+
"dose_response_multipliers": [
|
| 38 |
+
0.0,
|
| 39 |
+
0.5,
|
| 40 |
+
1.0,
|
| 41 |
+
1.5,
|
| 42 |
+
2.0,
|
| 43 |
+
3.0
|
| 44 |
+
],
|
| 45 |
+
"statistical_inference": [
|
| 46 |
+
"bootstrap_95_ci",
|
| 47 |
+
"paired_sign_flip_test"
|
| 48 |
+
]
|
| 49 |
}
|
scripts/release_check.py
CHANGED
|
@@ -1,64 +1,262 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
from collections import Counter
|
| 5 |
from pathlib import Path
|
| 6 |
|
|
|
|
| 7 |
ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
REQUIRED = [
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
| 20 |
]
|
| 21 |
|
| 22 |
|
| 23 |
def load_jsonl(path: Path) -> list[dict]:
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
missing = [path for path in REQUIRED if not (ROOT / path).exists()]
|
|
|
|
| 29 |
if missing:
|
| 30 |
-
raise SystemExit(f
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
if oversized:
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
assert 'sdk: gradio' in readme
|
| 54 |
-
assert 'Qwen/Qwen3-1.7B-Base' in readme
|
| 55 |
-
assert 'norm-matched random' in readme.lower()
|
| 56 |
|
| 57 |
-
print(
|
| 58 |
-
print(f
|
| 59 |
-
print(f
|
| 60 |
print(f' layers: {config["layers"]}')
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
-
if __name__ ==
|
| 64 |
-
main()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
import subprocess
|
| 5 |
from collections import Counter
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
+
|
| 9 |
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
|
| 11 |
+
# Any non-ignored repository file larger than this is suspicious.
|
| 12 |
+
# FeatureLens should not contain model weights, SAE checkpoints,
|
| 13 |
+
# activation dumps, virtual environments, etc.
|
| 14 |
+
MAX_FILE_SIZE_BYTES = 5_000_000 # 5 MB
|
| 15 |
+
|
| 16 |
REQUIRED = [
|
| 17 |
+
"README.md",
|
| 18 |
+
"app.py",
|
| 19 |
+
"requirements.txt",
|
| 20 |
+
"research_config.json",
|
| 21 |
+
"featurelens/runtime.py",
|
| 22 |
+
"featurelens/sae.py",
|
| 23 |
+
"featurelens/interventions.py",
|
| 24 |
+
"featurelens/stats.py",
|
| 25 |
+
"experiments/run_all.py",
|
| 26 |
+
"data/prompts.jsonl",
|
| 27 |
+
"data/causal_tasks.jsonl",
|
| 28 |
]
|
| 29 |
|
| 30 |
|
| 31 |
def load_jsonl(path: Path) -> list[dict]:
|
| 32 |
+
"""Load a JSONL file into a list of dictionaries."""
|
| 33 |
+
return [
|
| 34 |
+
json.loads(line)
|
| 35 |
+
for line in path.read_text(encoding="utf-8").splitlines()
|
| 36 |
+
if line.strip()
|
| 37 |
+
]
|
| 38 |
|
| 39 |
|
| 40 |
+
def repository_candidates() -> list[Path]:
|
| 41 |
+
"""
|
| 42 |
+
Return files that are either:
|
| 43 |
+
|
| 44 |
+
- already tracked by Git, or
|
| 45 |
+
- untracked but not ignored by .gitignore.
|
| 46 |
+
|
| 47 |
+
This deliberately excludes files such as .venv contents when .venv/
|
| 48 |
+
is correctly listed in .gitignore.
|
| 49 |
+
|
| 50 |
+
Including untracked, non-ignored files is useful because it catches
|
| 51 |
+
accidental large files before someone runs `git add .`.
|
| 52 |
+
"""
|
| 53 |
+
try:
|
| 54 |
+
result = subprocess.run(
|
| 55 |
+
[
|
| 56 |
+
"git",
|
| 57 |
+
"ls-files",
|
| 58 |
+
"--cached",
|
| 59 |
+
"--others",
|
| 60 |
+
"--exclude-standard",
|
| 61 |
+
],
|
| 62 |
+
cwd=ROOT,
|
| 63 |
+
capture_output=True,
|
| 64 |
+
text=True,
|
| 65 |
+
check=True,
|
| 66 |
+
)
|
| 67 |
+
except FileNotFoundError as exc:
|
| 68 |
+
raise SystemExit(
|
| 69 |
+
"Git is required to run the FeatureLens release check."
|
| 70 |
+
) from exc
|
| 71 |
+
except subprocess.CalledProcessError as exc:
|
| 72 |
+
stderr = exc.stderr.strip()
|
| 73 |
+
raise SystemExit(
|
| 74 |
+
f"Could not inspect repository files with Git: {stderr}"
|
| 75 |
+
) from exc
|
| 76 |
+
|
| 77 |
+
candidates: list[Path] = []
|
| 78 |
+
|
| 79 |
+
for relative_path in result.stdout.splitlines():
|
| 80 |
+
relative_path = relative_path.strip()
|
| 81 |
+
|
| 82 |
+
if not relative_path:
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
path = ROOT / relative_path
|
| 86 |
+
|
| 87 |
+
# A tracked file may have been deleted locally but not yet committed.
|
| 88 |
+
# Such a path should not be size-checked.
|
| 89 |
+
if path.is_file():
|
| 90 |
+
candidates.append(path)
|
| 91 |
+
|
| 92 |
+
return candidates
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def check_required_files() -> None:
|
| 96 |
+
"""Ensure the repository contains all files required for a release."""
|
| 97 |
missing = [path for path in REQUIRED if not (ROOT / path).exists()]
|
| 98 |
+
|
| 99 |
if missing:
|
| 100 |
+
raise SystemExit(f"Missing required files: {missing}")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def check_config(config: dict) -> None:
|
| 104 |
+
"""Validate the research configuration expected by FeatureLens v0.2."""
|
| 105 |
+
if config.get("layers") != [4, 14, 26]:
|
| 106 |
+
raise SystemExit(
|
| 107 |
+
f'Unexpected layers: {config.get("layers")}. '
|
| 108 |
+
"Expected [4, 14, 26]."
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
if config.get("model_id") != "Qwen/Qwen3-1.7B-Base":
|
| 112 |
+
raise SystemExit(
|
| 113 |
+
f'Unexpected model_id: {config.get("model_id")}.'
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
if config.get("sae_width") != 32768:
|
| 117 |
+
raise SystemExit(
|
| 118 |
+
f'Unexpected sae_width: {config.get("sae_width")}. '
|
| 119 |
+
"Expected 32768."
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
expected_multipliers = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0]
|
| 123 |
+
|
| 124 |
+
if config.get("dose_response_multipliers") != expected_multipliers:
|
| 125 |
+
raise SystemExit(
|
| 126 |
+
"Unexpected dose_response_multipliers: "
|
| 127 |
+
f'{config.get("dose_response_multipliers")}. '
|
| 128 |
+
f"Expected {expected_multipliers}."
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def check_datasets(config: dict) -> tuple[list[dict], list[dict]]:
|
| 133 |
+
"""Validate discovery and causal benchmark datasets."""
|
| 134 |
+
prompts = load_jsonl(ROOT / "data" / "prompts.jsonl")
|
| 135 |
+
causal = load_jsonl(ROOT / "data" / "causal_tasks.jsonl")
|
| 136 |
+
|
| 137 |
+
expected_prompt_count = config.get("discovery_prompts")
|
| 138 |
+
expected_causal_count = config.get("causal_tasks")
|
| 139 |
+
|
| 140 |
+
if len(prompts) != expected_prompt_count:
|
| 141 |
+
raise SystemExit(
|
| 142 |
+
"Discovery prompt count mismatch: "
|
| 143 |
+
f"found {len(prompts)}, expected {expected_prompt_count}."
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
if len(causal) != expected_causal_count:
|
| 147 |
+
raise SystemExit(
|
| 148 |
+
"Causal task count mismatch: "
|
| 149 |
+
f"found {len(causal)}, expected {expected_causal_count}."
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
concept_counts = Counter(row["concept"] for row in prompts)
|
| 153 |
+
|
| 154 |
+
expected_concepts = config.get("concepts", [])
|
| 155 |
+
|
| 156 |
+
if set(concept_counts) != set(expected_concepts):
|
| 157 |
+
raise SystemExit(
|
| 158 |
+
"Discovery dataset concepts do not match research_config.json.\n"
|
| 159 |
+
f"Dataset concepts: {sorted(concept_counts)}\n"
|
| 160 |
+
f"Config concepts: {sorted(expected_concepts)}"
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# The controlled discovery benchmark is intentionally balanced.
|
| 164 |
+
if len(set(concept_counts.values())) != 1:
|
| 165 |
+
raise SystemExit(
|
| 166 |
+
f"Discovery concepts are not balanced: {dict(concept_counts)}"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
return prompts, causal
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def check_oversized_files() -> None:
|
| 173 |
+
"""
|
| 174 |
+
Reject unexpectedly large files that could be committed/pushed.
|
| 175 |
+
|
| 176 |
+
Importantly, this does NOT recursively scan .venv or other ignored
|
| 177 |
+
directories. Git decides what counts as a repository candidate.
|
| 178 |
+
"""
|
| 179 |
+
oversized: list[str] = []
|
| 180 |
+
|
| 181 |
+
for path in repository_candidates():
|
| 182 |
+
size_bytes = path.stat().st_size
|
| 183 |
+
|
| 184 |
+
if size_bytes > MAX_FILE_SIZE_BYTES:
|
| 185 |
+
relative_path = path.relative_to(ROOT)
|
| 186 |
+
size_mb = size_bytes / 1_000_000
|
| 187 |
+
|
| 188 |
+
oversized.append(
|
| 189 |
+
f"{relative_path} ({size_mb:.1f} MB)"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
if oversized:
|
| 193 |
+
formatted = "\n - ".join(oversized)
|
| 194 |
+
|
| 195 |
+
raise SystemExit(
|
| 196 |
+
"Repository contains unexpectedly large tracked/unignored "
|
| 197 |
+
"candidates:\n"
|
| 198 |
+
f" - {formatted}\n\n"
|
| 199 |
+
"If a file is a legitimate local artifact, add it to .gitignore. "
|
| 200 |
+
"Model weights, SAE checkpoints, activation dumps, virtual "
|
| 201 |
+
"environments, and caches should not be committed."
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def check_readme() -> None:
|
| 206 |
+
"""Validate important Hugging Face Space/release metadata."""
|
| 207 |
+
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
| 208 |
+
|
| 209 |
+
required_strings = [
|
| 210 |
+
"sdk: gradio",
|
| 211 |
+
'sdk_version: "6.24.0"',
|
| 212 |
+
"Qwen/Qwen3-1.7B-Base",
|
| 213 |
+
]
|
| 214 |
+
|
| 215 |
+
missing = [
|
| 216 |
+
value
|
| 217 |
+
for value in required_strings
|
| 218 |
+
if value not in readme
|
| 219 |
+
]
|
| 220 |
+
|
| 221 |
+
if missing:
|
| 222 |
+
raise SystemExit(
|
| 223 |
+
f"README.md is missing required metadata/content: {missing}"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
readme_lower = readme.lower()
|
| 227 |
+
|
| 228 |
+
if "norm-matched random" not in readme_lower:
|
| 229 |
+
raise SystemExit(
|
| 230 |
+
'README.md should describe the "norm-matched random" control.'
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
if "dose" not in readme_lower:
|
| 234 |
+
raise SystemExit(
|
| 235 |
+
"README.md should describe the dose-response experiment."
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def main() -> None:
|
| 240 |
+
check_required_files()
|
| 241 |
+
|
| 242 |
+
config = json.loads(
|
| 243 |
+
(ROOT / "research_config.json").read_text(encoding="utf-8")
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
check_config(config)
|
| 247 |
+
|
| 248 |
+
prompts, causal = check_datasets(config)
|
| 249 |
+
|
| 250 |
+
check_oversized_files()
|
| 251 |
|
| 252 |
+
check_readme()
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
+
print("FeatureLens release check: PASS")
|
| 255 |
+
print(f" discovery prompts: {len(prompts)}")
|
| 256 |
+
print(f" causal tasks: {len(causal)}")
|
| 257 |
print(f' layers: {config["layers"]}')
|
| 258 |
+
print(" release: v0.2.0")
|
| 259 |
|
| 260 |
|
| 261 |
+
if __name__ == "__main__":
|
| 262 |
+
main()
|
tests/test_stats.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
|
| 3 |
+
from featurelens.stats import (
|
| 4 |
+
bootstrap_mean_ci,
|
| 5 |
+
paired_bootstrap_difference_ci,
|
| 6 |
+
paired_sign_flip_pvalue,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_bootstrap_mean_ci_contains_sample_mean():
|
| 11 |
+
values = np.array([0.7, 0.8, 0.9, 0.85, 0.75])
|
| 12 |
+
low, high = bootstrap_mean_ci(values, n_resamples=1000, seed=1)
|
| 13 |
+
assert low <= values.mean() <= high
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_paired_bootstrap_detects_positive_difference():
|
| 17 |
+
a = np.array([2.0, 2.2, 1.8, 2.1, 2.4])
|
| 18 |
+
b = np.array([0.5, 0.6, 0.4, 0.7, 0.5])
|
| 19 |
+
low, high = paired_bootstrap_difference_ci(a, b, n_resamples=1000, seed=2)
|
| 20 |
+
assert low > 0
|
| 21 |
+
assert high > low
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_sign_flip_small_for_consistent_effect():
|
| 25 |
+
a = np.array([2.0, 2.1, 2.3, 2.2, 2.4, 2.5, 2.1, 2.2])
|
| 26 |
+
b = np.array([0.2, 0.4, 0.3, 0.5, 0.2, 0.4, 0.3, 0.2])
|
| 27 |
+
p = paired_sign_flip_pvalue(a, b, n_permutations=5000, seed=3)
|
| 28 |
+
assert p < 0.05
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_sign_flip_one_for_identical_pairs():
|
| 32 |
+
x = np.array([1.0, 2.0, 3.0])
|
| 33 |
+
assert paired_sign_flip_pvalue(x, x, n_permutations=1000, seed=4) == 1.0
|