Spaces:
Running on Zero
Running on Zero
Commit ·
3a2b2e4
1
Parent(s): 0481a55
Release FeatureLens v0.14.0
Browse files- CHANGELOG.md +14 -1
- README.md +63 -8
- app.py +72 -8
- artifacts/README.md +26 -5
- docs/METHODOLOGY.md +18 -0
- docs/OFFLINE_STUDY.md +77 -0
- docs/VALIDATION.md +36 -62
- experiments/analyze_stability.py +214 -0
- experiments/analyze_study.py +252 -0
- experiments/collect_activations.py +105 -22
- experiments/evaluate_features.py +5 -0
- experiments/make_report.py +68 -1
- experiments/run_all.py +76 -9
- experiments/run_analysis_only.py +26 -0
- featurelens/study.py +90 -0
- pyproject.toml +1 -1
- research_config.json +20 -1
- scripts/release_check.py +38 -4
- scripts/validate_artifacts.py +106 -0
- tests/test_offline_study.py +109 -0
CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
@@ -5,7 +19,6 @@
|
|
| 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
|
| 11 |
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
## v0.14.0
|
| 4 |
+
|
| 5 |
+
- Transitioned the project from live-feature expansion toward the full offline empirical study.
|
| 6 |
+
- Changed offline SAE concept evidence from final-token-only activations to **prompt-wide max-pooled activations across non-padding tokens**, while saving separate final-token sparse activation matrices for local diagnostics.
|
| 7 |
+
- Added `experiments/analyze_stability.py` with 128 deterministic balanced activation resamples and per-feature shortlist support/rank summaries.
|
| 8 |
+
- Added `experiments/analyze_study.py` to join held-out AUROC/F1, paraphrase robustness, candidate stability, feature activity, and random-normalized target/JS causal specificity by controlled concept.
|
| 9 |
+
- Added descriptive cross-concept association-vs-causality correlations and new association/candidate-stability report figures.
|
| 10 |
+
- Expanded the public **Offline study** tab into an artifact-backed results dashboard that remains explicitly empty until real study artifacts are committed.
|
| 11 |
+
- Added `python experiments/run_all.py --resume` for interrupted/preemptible GPU sessions and `python experiments/run_analysis_only.py` for CPU-only re-analysis once inference artifacts exist.
|
| 12 |
+
- Added `scripts/validate_artifacts.py` to verify prompt-wide activation provenance and public study artifact schemas before commit.
|
| 13 |
+
- Added dedicated offline-study methodology/validation documentation and automated tests for prompt-wide pooling, stability scoring, task-paired specificity, study UI states, and correlation guardrails.
|
| 14 |
+
|
| 15 |
## v0.13.0
|
| 16 |
|
| 17 |
- 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.
|
|
|
|
| 19 |
- 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.
|
| 20 |
- Kept HF acceptance quota-aware: only concept discovery and cross-target profiling are touched GPU paths.
|
| 21 |
|
|
|
|
| 22 |
|
| 23 |
## v0.12.0
|
| 24 |
|
README.md
CHANGED
|
@@ -13,7 +13,7 @@ license: mit
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
-
> **v0.
|
| 17 |
|
| 18 |
**Research question:**
|
| 19 |
|
|
@@ -253,9 +253,9 @@ The offline pipeline computes:
|
|
| 253 |
|
| 254 |
Feature selection is performed using the **training split only**. Paraphrase pairs never cross the train/test boundary.
|
| 255 |
|
| 256 |
-
## Run the offline
|
| 257 |
|
| 258 |
-
A CUDA machine is strongly recommended.
|
| 259 |
|
| 260 |
```bash
|
| 261 |
python -m venv .venv
|
|
@@ -264,33 +264,72 @@ pip install -r requirements.txt
|
|
| 264 |
python experiments/run_all.py
|
| 265 |
```
|
| 266 |
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
```text
|
| 270 |
build_dataset
|
| 271 |
→ collect_activations
|
|
|
|
|
|
|
| 272 |
→ evaluate_features
|
|
|
|
|
|
|
|
|
|
| 273 |
→ run_causal
|
| 274 |
→ run_feature_sets
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
→ make_report
|
|
|
|
| 276 |
```
|
| 277 |
|
| 278 |
Outputs are generated under `artifacts/`:
|
| 279 |
|
| 280 |
```text
|
| 281 |
artifacts/
|
| 282 |
-
├── activations/
|
| 283 |
├── feature_catalog.csv
|
| 284 |
├── layer_metrics.csv
|
| 285 |
├── stability.csv
|
|
|
|
| 286 |
├── causal_results.csv
|
| 287 |
├── feature_set_results.csv
|
|
|
|
|
|
|
| 288 |
├── summary.json
|
| 289 |
├── report.md
|
| 290 |
└── figures/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
```
|
| 292 |
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
## Hugging Face deployment
|
| 296 |
|
|
@@ -335,19 +374,24 @@ FeatureLens/
|
|
| 335 |
│ ├── metrics.py
|
| 336 |
│ ├── stats.py
|
| 337 |
│ ├── selection.py
|
| 338 |
-
│
|
|
|
|
| 339 |
├── experiments/
|
| 340 |
│ ├── build_dataset.py
|
| 341 |
│ ├── collect_activations.py
|
| 342 |
│ ├── evaluate_features.py
|
| 343 |
│ ├── run_causal.py
|
| 344 |
│ ├── run_feature_sets.py
|
|
|
|
|
|
|
| 345 |
│ ├── make_report.py
|
|
|
|
| 346 |
│ └── run_all.py
|
| 347 |
├── data/
|
| 348 |
├── tests/
|
| 349 |
├── scripts/
|
| 350 |
│ ├── release_check.py
|
|
|
|
| 351 |
│ └── ui_smoke.py
|
| 352 |
├── docs/
|
| 353 |
└── research_config.json
|
|
@@ -381,6 +425,17 @@ v0.12 bundles several related improvements instead of adding one isolated widget
|
|
| 381 |
- **Cross-target causal profile** screens up to three candidate ablations across two to five exact continuations. It reports target-wise mean/sequence log-probability deltas, next-token JS, the strongest target per feature, and a target-profile ratio. This stage intentionally omits random controls; controlled candidate specificity remains the matched-random causal test.
|
| 382 |
- HF validation remains GPU-budget-aware: rerun only the touched discovery path and the new cross-target path. Unchanged paraphrase, trajectory, set-size, dose-response, cue, and focus paths stay covered by automated tests.
|
| 383 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
## Validation
|
| 385 |
|
| 386 |
```bash
|
|
@@ -407,7 +462,7 @@ See [`docs/VALIDATION.md`](docs/VALIDATION.md). The v0.12 guide uses the **exact
|
|
| 407 |
## Resume-ready description
|
| 408 |
|
| 409 |
> **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
|
| 410 |
-
> Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, concept-guided candidate discovery, token/prompt-wide, completion-cue, and cue × context feature evidence, reconstruction-preserving single and multi-feature interventions, full-continuation and contrastive preference scoring, dose-response analysis, decoder-geometry/non-additivity diagnostics, discovery-to-causality rank analysis, multi-candidate random-controlled specificity screening, split-half discovery stability, cross-target causal profiling, controlled evidence-pattern synthesis, and norm-matched random-control ensembles.
|
| 411 |
|
| 412 |
## Acknowledgements
|
| 413 |
|
|
|
|
| 13 |
|
| 14 |
# FeatureLens — Causal Interpretability Workbench
|
| 15 |
|
| 16 |
+
> **v0.14:** an offline-study transition release: prompt-wide held-out SAE discovery, activation-resample stability, cross-concept association-vs-causality synthesis, a resume-safe study runner, and an artifact-backed public study dashboard.
|
| 17 |
|
| 18 |
**Research question:**
|
| 19 |
|
|
|
|
| 253 |
|
| 254 |
Feature selection is performed using the **training split only**. Paraphrase pairs never cross the train/test boundary.
|
| 255 |
|
| 256 |
+
## Run the offline study
|
| 257 |
|
| 258 |
+
A CUDA machine is strongly recommended for activation collection and causal intervention stages. v0.14 changes the offline SAE concept representation to **prompt-wide max-pooled activation across all non-padding prompt tokens**. Final-token sparse activations are saved separately for local diagnostics; the dense residual linear probe remains a final-token baseline.
|
| 259 |
|
| 260 |
```bash
|
| 261 |
python -m venv .venv
|
|
|
|
| 264 |
python experiments/run_all.py
|
| 265 |
```
|
| 266 |
|
| 267 |
+
For interruptible/preemptible GPU sessions, use:
|
| 268 |
+
|
| 269 |
+
```bash
|
| 270 |
+
python experiments/run_all.py --resume
|
| 271 |
+
```
|
| 272 |
+
|
| 273 |
+
Completed stages are skipped when their expected artifacts already exist. After the expensive model/SAE stages have completed, all CPU-only evaluation/report logic can be rerun without another inference pass:
|
| 274 |
+
|
| 275 |
+
```bash
|
| 276 |
+
python experiments/run_analysis_only.py
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
The full pipeline is:
|
| 280 |
|
| 281 |
```text
|
| 282 |
build_dataset
|
| 283 |
→ collect_activations
|
| 284 |
+
├─ prompt-wide SAE feature maxima
|
| 285 |
+
└─ final-token sparse features + final-token residuals
|
| 286 |
→ evaluate_features
|
| 287 |
+
├─ held-out AUROC/F1
|
| 288 |
+
├─ dense residual linear probe
|
| 289 |
+
└─ prompt-wide paraphrase stability
|
| 290 |
→ run_causal
|
| 291 |
→ run_feature_sets
|
| 292 |
+
→ analyze_stability
|
| 293 |
+
└─ 128 balanced activation resamples
|
| 294 |
+
→ analyze_study
|
| 295 |
+
└─ association/stability ↔ random-normalized causal evidence
|
| 296 |
→ make_report
|
| 297 |
+
→ validate_artifacts
|
| 298 |
```
|
| 299 |
|
| 300 |
Outputs are generated under `artifacts/`:
|
| 301 |
|
| 302 |
```text
|
| 303 |
artifacts/
|
| 304 |
+
├── activations/ # large; keep gitignored
|
| 305 |
├── feature_catalog.csv
|
| 306 |
├── layer_metrics.csv
|
| 307 |
├── stability.csv
|
| 308 |
+
├── selection_stability.csv
|
| 309 |
├── causal_results.csv
|
| 310 |
├── feature_set_results.csv
|
| 311 |
+
├── study_feature_summary.csv
|
| 312 |
+
├── study_summary.json
|
| 313 |
├── summary.json
|
| 314 |
├── report.md
|
| 315 |
└── figures/
|
| 316 |
+
├── feature_auroc.png
|
| 317 |
+
├── layer_diagnostics.png
|
| 318 |
+
├── causal_effects.png
|
| 319 |
+
├── feature_set_effects.png
|
| 320 |
+
├── association_vs_causality.png
|
| 321 |
+
└── candidate_stability.png
|
| 322 |
```
|
| 323 |
|
| 324 |
+
Validate the small public artifacts before committing them:
|
| 325 |
+
|
| 326 |
+
```bash
|
| 327 |
+
python -m scripts.validate_artifacts
|
| 328 |
+
```
|
| 329 |
+
|
| 330 |
+
`summary.json`, `study_summary.json`, and `report.md` are generated from measured artifacts. No scientific benchmark numbers are fabricated in the repository. The live **Offline study** tab automatically turns into a results dashboard once these small CSV/JSON/figure artifacts are committed.
|
| 331 |
+
|
| 332 |
+
See [`docs/OFFLINE_STUDY.md`](docs/OFFLINE_STUDY.md) for the staged workflow and interpretation guardrails.
|
| 333 |
|
| 334 |
## Hugging Face deployment
|
| 335 |
|
|
|
|
| 374 |
│ ├── metrics.py
|
| 375 |
│ ├── stats.py
|
| 376 |
│ ├── selection.py
|
| 377 |
+
│ ├── catalog.py
|
| 378 |
+
│ └── study.py
|
| 379 |
├── experiments/
|
| 380 |
│ ├── build_dataset.py
|
| 381 |
│ ├── collect_activations.py
|
| 382 |
│ ├── evaluate_features.py
|
| 383 |
│ ├── run_causal.py
|
| 384 |
│ ├── run_feature_sets.py
|
| 385 |
+
│ ├── analyze_stability.py
|
| 386 |
+
│ ├── analyze_study.py
|
| 387 |
│ ├── make_report.py
|
| 388 |
+
│ ├── run_analysis_only.py
|
| 389 |
│ └── run_all.py
|
| 390 |
├── data/
|
| 391 |
├── tests/
|
| 392 |
├── scripts/
|
| 393 |
│ ├── release_check.py
|
| 394 |
+
│ ├── validate_artifacts.py
|
| 395 |
│ └── ui_smoke.py
|
| 396 |
├── docs/
|
| 397 |
└── research_config.json
|
|
|
|
| 425 |
- **Cross-target causal profile** screens up to three candidate ablations across two to five exact continuations. It reports target-wise mean/sequence log-probability deltas, next-token JS, the strongest target per feature, and a target-profile ratio. This stage intentionally omits random controls; controlled candidate specificity remains the matched-random causal test.
|
| 426 |
- HF validation remains GPU-budget-aware: rerun only the touched discovery path and the new cross-target path. Unchanged paraphrase, trajectory, set-size, dose-response, cue, and focus paths stay covered by automated tests.
|
| 427 |
|
| 428 |
+
## v0.14 offline-study transition
|
| 429 |
+
|
| 430 |
+
v0.14 deliberately stops expanding the live intervention surface and strengthens the empirical study behind it.
|
| 431 |
+
|
| 432 |
+
- **Prompt-wide offline SAE evidence:** concept-feature discovery and paraphrase stability use each feature's maximum activation across all non-padding prompt tokens. This avoids treating an arbitrary final token as the semantic representation of an entire prompt. Final-token sparse activations remain available separately for local diagnostics.
|
| 433 |
+
- **Activation-resample candidate selection stability:** `analyze_stability.py` performs 128 deterministic balanced resamples of the saved prompt-wide activations and records shortlist support plus median/mean resample rank. No model inference is repeated.
|
| 434 |
+
- **Association vs random-normalized causality across concepts:** `analyze_study.py` joins held-out AUROC/F1, paraphrase stability, candidate resample support, causal active rate, target specificity, and JS specificity for the selected feature of every controlled concept. Cross-concept Spearman correlations are descriptive because there are only seven concepts.
|
| 435 |
+
- **Artifact-backed Offline study tab:** the public app remains honest when no benchmark has been run, then automatically displays committed study tables and figures when real artifacts exist.
|
| 436 |
+
- **Resume-safe full runner:** `python experiments/run_all.py --resume` skips completed stages; `python experiments/run_analysis_only.py` reruns only CPU evaluation/stability/report logic once expensive inference artifacts exist.
|
| 437 |
+
- **Artifact schema validation:** `python -m scripts.validate_artifacts` checks that public study outputs are non-empty, prompt-wide v0.14 activations were used, and required causal/stability columns exist.
|
| 438 |
+
|
| 439 |
## Validation
|
| 440 |
|
| 441 |
```bash
|
|
|
|
| 462 |
## Resume-ready description
|
| 463 |
|
| 464 |
> **FeatureLens — Causal Interpretability Workbench** | PyTorch, Qwen3, Sparse Autoencoders, Mechanistic Interpretability, Gradio
|
| 465 |
+
> Built an SAE-based interpretability system for Qwen3-1.7B with held-out concept discovery, concept-guided candidate discovery, token/prompt-wide, completion-cue, and cue × context feature evidence, reconstruction-preserving single and multi-feature interventions, full-continuation and contrastive preference scoring, dose-response analysis, decoder-geometry/non-additivity diagnostics, discovery-to-causality rank analysis, multi-candidate random-controlled specificity screening, split-half/resample discovery stability, cross-target causal profiling, controlled evidence-pattern synthesis, prompt-wide held-out SAE evaluation, association-vs-random-normalized-causality study synthesis, and norm-matched random-control ensembles.
|
| 466 |
|
| 467 |
## Acknowledgements
|
| 468 |
|
app.py
CHANGED
|
@@ -6,6 +6,7 @@ import pandas as pd
|
|
| 6 |
from featurelens.config import SETTINGS
|
| 7 |
from featurelens.hf_runtime import gpu
|
| 8 |
from featurelens.runtime import RUNTIME
|
|
|
|
| 9 |
|
| 10 |
# Restrained, print-inspired palette. The app deliberately avoids saturated dashboard colors.
|
| 11 |
INK_TEAL = "#708B86"
|
|
@@ -15,6 +16,8 @@ INK_PLUM = "#82768F"
|
|
| 15 |
INK_STONE = "#8E8A83"
|
| 16 |
INK_BLUEGREY = "#71808A"
|
| 17 |
|
|
|
|
|
|
|
| 18 |
CSS = r"""
|
| 19 |
.gradio-container {
|
| 20 |
width: min(96vw, 1600px) !important;
|
|
@@ -3094,14 +3097,75 @@ with gr.Blocks(title="FeatureLens — Causal Interpretability Workbench", fill_w
|
|
| 3094 |
height=330,
|
| 3095 |
)
|
| 3096 |
|
| 3097 |
-
with gr.Tab("Offline
|
| 3098 |
-
gr.Markdown(
|
| 3099 |
-
gr.Markdown(
|
| 3100 |
-
|
| 3101 |
-
|
| 3102 |
-
|
| 3103 |
-
|
| 3104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3105 |
|
| 3106 |
with gr.Tab("Method"):
|
| 3107 |
gr.Markdown(
|
|
|
|
| 6 |
from featurelens.config import SETTINGS
|
| 7 |
from featurelens.hf_runtime import gpu
|
| 8 |
from featurelens.runtime import RUNTIME
|
| 9 |
+
from featurelens.study import OfflineStudy
|
| 10 |
|
| 11 |
# Restrained, print-inspired palette. The app deliberately avoids saturated dashboard colors.
|
| 12 |
INK_TEAL = "#708B86"
|
|
|
|
| 16 |
INK_STONE = "#8E8A83"
|
| 17 |
INK_BLUEGREY = "#71808A"
|
| 18 |
|
| 19 |
+
STUDY = OfflineStudy()
|
| 20 |
+
|
| 21 |
CSS = r"""
|
| 22 |
.gradio-container {
|
| 23 |
width: min(96vw, 1600px) !important;
|
|
|
|
| 3097 |
height=330,
|
| 3098 |
)
|
| 3099 |
|
| 3100 |
+
with gr.Tab("Offline study"):
|
| 3101 |
+
gr.Markdown(STUDY.overview_markdown())
|
| 3102 |
+
gr.Markdown(STUDY.readiness_markdown(), elem_classes=["small-note"])
|
| 3103 |
+
|
| 3104 |
+
offline_study = STUDY.dataframe("study_feature_summary.csv")
|
| 3105 |
+
offline_stability = STUDY.dataframe("selection_stability.csv")
|
| 3106 |
+
offline_layers = STUDY.dataframe("layer_metrics.csv")
|
| 3107 |
+
|
| 3108 |
+
if not offline_study.empty:
|
| 3109 |
+
with gr.Row(equal_height=False):
|
| 3110 |
+
with gr.Column(scale=3):
|
| 3111 |
+
_table_heading("Selected feature evidence by concept")
|
| 3112 |
+
gr.Dataframe(
|
| 3113 |
+
value=offline_study,
|
| 3114 |
+
interactive=False,
|
| 3115 |
+
show_label=False,
|
| 3116 |
+
buttons=["fullscreen"],
|
| 3117 |
+
elem_classes=["result-table"],
|
| 3118 |
+
wrap=False,
|
| 3119 |
+
max_height=420,
|
| 3120 |
+
)
|
| 3121 |
+
with gr.Column(scale=2):
|
| 3122 |
+
gr.Image(
|
| 3123 |
+
value=STUDY.figure("association_vs_causality.png"),
|
| 3124 |
+
label="Association evidence vs causal specificity",
|
| 3125 |
+
interactive=False,
|
| 3126 |
+
show_label=True,
|
| 3127 |
+
height=360,
|
| 3128 |
+
)
|
| 3129 |
+
|
| 3130 |
+
with gr.Row(equal_height=False):
|
| 3131 |
+
with gr.Column(scale=3):
|
| 3132 |
+
_table_heading("Candidate selection stability")
|
| 3133 |
+
stability_preview = offline_stability.sort_values(
|
| 3134 |
+
["resample_support", "full_score"], ascending=[False, False]
|
| 3135 |
+
).head(40)
|
| 3136 |
+
gr.Dataframe(
|
| 3137 |
+
value=stability_preview,
|
| 3138 |
+
interactive=False,
|
| 3139 |
+
show_label=False,
|
| 3140 |
+
buttons=["fullscreen"],
|
| 3141 |
+
elem_classes=["result-table"],
|
| 3142 |
+
wrap=False,
|
| 3143 |
+
max_height=420,
|
| 3144 |
+
)
|
| 3145 |
+
with gr.Column(scale=2):
|
| 3146 |
+
gr.Image(
|
| 3147 |
+
value=STUDY.figure("candidate_stability.png"),
|
| 3148 |
+
label="Selected-feature resample support",
|
| 3149 |
+
interactive=False,
|
| 3150 |
+
show_label=True,
|
| 3151 |
+
height=360,
|
| 3152 |
+
)
|
| 3153 |
+
|
| 3154 |
+
_table_heading("Layer diagnostics")
|
| 3155 |
+
gr.Dataframe(
|
| 3156 |
+
value=offline_layers,
|
| 3157 |
+
interactive=False,
|
| 3158 |
+
show_label=False,
|
| 3159 |
+
buttons=["fullscreen"],
|
| 3160 |
+
elem_classes=["result-table"],
|
| 3161 |
+
wrap=False,
|
| 3162 |
+
max_height=300,
|
| 3163 |
+
)
|
| 3164 |
+
else:
|
| 3165 |
+
gr.Markdown(
|
| 3166 |
+
"The public study tables and figures appear automatically after the small offline artifacts "
|
| 3167 |
+
"are generated and committed. No placeholder scientific numbers are shown."
|
| 3168 |
+
)
|
| 3169 |
|
| 3170 |
with gr.Tab("Method"):
|
| 3171 |
gr.Markdown(
|
artifacts/README.md
CHANGED
|
@@ -2,22 +2,43 @@
|
|
| 2 |
|
| 3 |
This directory intentionally ships without invented empirical results.
|
| 4 |
|
| 5 |
-
Run:
|
| 6 |
|
| 7 |
```bash
|
| 8 |
python experiments/run_all.py
|
| 9 |
```
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
- `feature_catalog.csv`;
|
| 15 |
- `layer_metrics.csv`;
|
| 16 |
- `stability.csv`;
|
|
|
|
| 17 |
- `causal_results.csv`;
|
| 18 |
- `feature_set_results.csv`;
|
|
|
|
|
|
|
| 19 |
- `summary.json`;
|
| 20 |
- `report.md`;
|
| 21 |
-
- report figures.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
|
|
|
| 2 |
|
| 3 |
This directory intentionally ships without invented empirical results.
|
| 4 |
|
| 5 |
+
Run the full study on a CUDA machine:
|
| 6 |
|
| 7 |
```bash
|
| 8 |
python experiments/run_all.py
|
| 9 |
```
|
| 10 |
|
| 11 |
+
For an interruptible session:
|
| 12 |
|
| 13 |
+
```bash
|
| 14 |
+
python experiments/run_all.py --resume
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
If the expensive activation and causal artifacts already exist, regenerate only CPU analysis/report outputs with:
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
python experiments/run_analysis_only.py
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
v0.14 produces:
|
| 24 |
+
|
| 25 |
+
- prompt-wide activation caches plus separate final-token sparse activations;
|
| 26 |
- `feature_catalog.csv`;
|
| 27 |
- `layer_metrics.csv`;
|
| 28 |
- `stability.csv`;
|
| 29 |
+
- `selection_stability.csv`;
|
| 30 |
- `causal_results.csv`;
|
| 31 |
- `feature_set_results.csv`;
|
| 32 |
+
- `study_feature_summary.csv`;
|
| 33 |
+
- `study_summary.json`;
|
| 34 |
- `summary.json`;
|
| 35 |
- `report.md`;
|
| 36 |
+
- report figures including association-vs-causality and candidate-stability plots.
|
| 37 |
+
|
| 38 |
+
Validate the public artifact set with:
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
python -m scripts.validate_artifacts
|
| 42 |
+
```
|
| 43 |
|
| 44 |
+
`artifacts/activations/` contains large residual/sparse caches and stays gitignored. Commit only the small CSV/JSON/report/figure outputs if you want the live **Offline study** tab to display measured results and benchmark-derived feature hints.
|
docs/METHODOLOGY.md
CHANGED
|
@@ -583,3 +583,21 @@ For one feature with target effects `Δ_t`, v0.13 normalizes `|Δ_t|` into a pro
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 586 |
+
|
| 587 |
+
## v0.14 offline-study methodology
|
| 588 |
+
|
| 589 |
+
### Prompt-wide SAE concept evidence
|
| 590 |
+
|
| 591 |
+
Offline concept-feature evaluation now max-pools each SAE feature across every non-padding token in a prompt. This aligns the held-out study with the live prompt-wide diagnostics and avoids assigning an entire prompt's concept evidence to an arbitrary final token. Separate final-token SAE matrices are still saved for local analyses.
|
| 592 |
+
|
| 593 |
+
### Activation-resample selection stability
|
| 594 |
+
|
| 595 |
+
`experiments/analyze_stability.py` performs deterministic balanced bootstrap resamples over the already-saved prompt-wide activation matrix. Within each resample, candidates are ranked using the live-compatible balanced score `positive selectivity × target activation rate × log1p(target mean)`. The output records shortlist support and resample-rank summaries. This is a sensitivity analysis, not a confidence interval.
|
| 596 |
+
|
| 597 |
+
### Study-level association vs causality
|
| 598 |
+
|
| 599 |
+
`experiments/analyze_study.py` selects one feature per concept using the original train-only AUROC discipline and joins held-out AUROC/F1 with paraphrase robustness, activation-resample support, causal-task activity, target-specificity, and JS-specificity. Random-normalized causal quantities are paired at the task level before aggregation. Cross-concept Spearman correlations use only seven concepts and are therefore reported descriptively.
|
| 600 |
+
|
| 601 |
+
### Reproducibility and artifact boundary
|
| 602 |
+
|
| 603 |
+
The full GPU + CPU pipeline supports `--resume`. Once expensive inference outputs exist, `experiments/run_analysis_only.py` reruns only CPU evaluation/stability/report stages. `scripts/validate_artifacts.py` enforces the public artifact schema and confirms that v0.14 prompt-wide activation metadata was used before results are surfaced in the public Offline study tab.
|
docs/OFFLINE_STUDY.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FeatureLens offline study
|
| 2 |
+
|
| 3 |
+
## Goal
|
| 4 |
+
|
| 5 |
+
The offline study answers the project question at dataset scale:
|
| 6 |
+
|
| 7 |
+
> Do sparse features that predict a controlled concept on held-out prompts also produce behaviorally specific causal effects?
|
| 8 |
+
|
| 9 |
+
The live app is exploratory. The offline study is where FeatureLens makes held-out, random-controlled, uncertainty-aware claims.
|
| 10 |
+
|
| 11 |
+
## Representation choice
|
| 12 |
+
|
| 13 |
+
v0.14 uses **prompt-wide max-pooled SAE activation** for concept evidence. For each prompt and SAE feature, the stored value is the maximum activation across non-padding prompt tokens. This change is motivated by the live finding that a strong final-token activation can reflect a lexical cue rather than the prompt's semantic concept.
|
| 14 |
+
|
| 15 |
+
The collector also saves `features_final_layer{layer}.npz` so local final-token analyses remain reproducible. Dense residual linear probes continue to use the final prompt-token residual and are therefore reported as a separate baseline rather than as an identical pooling scheme.
|
| 16 |
+
|
| 17 |
+
## Full pipeline
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
python experiments/run_all.py
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
Stages:
|
| 24 |
+
|
| 25 |
+
1. `build_dataset` — materialize 224 discovery prompts and 28 causal tasks.
|
| 26 |
+
2. `collect_activations` — Qwen3 residual capture; prompt-wide and final-token SAE feature artifacts.
|
| 27 |
+
3. `evaluate_features` — grouped train/test split, train-only feature selection, held-out AUROC/F1, dense residual probe, paraphrase stability.
|
| 28 |
+
4. `run_causal` — selected-feature ablation/amplification with exact continuation scoring and norm-matched random ensembles.
|
| 29 |
+
5. `run_feature_sets` — top-1/3/5 joint ablations and random controls.
|
| 30 |
+
6. `analyze_stability` — 128 balanced activation resamples from saved prompt-wide features.
|
| 31 |
+
7. `analyze_study` — join predictive, robustness, stability, and random-normalized causal evidence by concept.
|
| 32 |
+
8. `make_report` — measured figures, summary JSON, and narrative report.
|
| 33 |
+
9. `validate_artifacts` — schema/completeness guard before committing public results.
|
| 34 |
+
|
| 35 |
+
## Resume after interruption
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
python experiments/run_all.py --resume
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
The runner checks expected stage outputs and skips completed stages. This is intended for preemptible or quota-limited GPU sessions.
|
| 42 |
+
|
| 43 |
+
## CPU-only re-analysis
|
| 44 |
+
|
| 45 |
+
After activation and causal inference artifacts exist:
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
python experiments/run_analysis_only.py
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
This reruns feature evaluation, candidate stability, study synthesis, figures, report generation, and artifact validation without another model forward pass.
|
| 52 |
+
|
| 53 |
+
## Main outputs
|
| 54 |
+
|
| 55 |
+
`study_feature_summary.csv` contains one selected feature per controlled concept with:
|
| 56 |
+
|
| 57 |
+
- held-out AUROC and F1;
|
| 58 |
+
- training activation rates;
|
| 59 |
+
- activation-resample selection support;
|
| 60 |
+
- paraphrase TopK Jaccard and sparse cosine;
|
| 61 |
+
- causal-task feature-active rate;
|
| 62 |
+
- mean absolute and signed target effect;
|
| 63 |
+
- norm-matched random mean absolute target effect;
|
| 64 |
+
- target-specificity ratio, paired advantage, bootstrap CI, sign-flip p-value;
|
| 65 |
+
- equivalent next-token JS specificity metrics.
|
| 66 |
+
|
| 67 |
+
`study_summary.json` adds descriptive cross-concept Spearman correlations such as held-out AUROC versus target-specificity ratio. There are only seven controlled concepts, so these correlations are **descriptive**, not significance claims.
|
| 68 |
+
|
| 69 |
+
## Interpretation guardrails
|
| 70 |
+
|
| 71 |
+
- Prompt-wide max pooling detects whether a feature appears anywhere in the prompt; it discards token order.
|
| 72 |
+
- A high held-out AUROC remains correlational evidence.
|
| 73 |
+
- Candidate resample support measures shortlist sensitivity under the configured activation-resampling scheme, not feature truth or semantic purity.
|
| 74 |
+
- A target-specificity ratio above 1 means the SAE edit moved the exact target more than the mean norm-matched random edit; uncertainty and task coverage still matter.
|
| 75 |
+
- JS specificity asks a different question from target specificity: a feature can reshape the local distribution without specifically controlling the chosen target.
|
| 76 |
+
- Cross-concept correlations have n=7 and are descriptive.
|
| 77 |
+
- Large activation caches should not be committed to the repository.
|
docs/VALIDATION.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
-
# FeatureLens v0.
|
| 2 |
|
| 3 |
-
v0.
|
| 4 |
|
| 5 |
-
## Local release gate
|
|
|
|
|
|
|
| 6 |
|
| 7 |
```bash
|
| 8 |
python3 -m pytest -q && \
|
|
@@ -12,78 +14,50 @@ python3 scripts/ui_smoke.py && \
|
|
| 12 |
python3 scripts/release_check.py
|
| 13 |
```
|
| 14 |
|
| 15 |
-
Expected
|
| 16 |
-
|
| 17 |
-
```text
|
| 18 |
-
FeatureLens release check: PASS
|
| 19 |
-
discovery prompts: 224
|
| 20 |
-
causal tasks: 28
|
| 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 |
-
|
| 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 |
-
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 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 |
-
|
| 51 |
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
| 59 |
-
- **Exact target continuations:**
|
| 60 |
|
| 61 |
-
```
|
| 62 |
-
|
| 63 |
-
x
|
| 64 |
-
0
|
| 65 |
-
x^2
|
| 66 |
```
|
| 67 |
|
| 68 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 77 |
|
| 78 |
-
|
| 79 |
|
| 80 |
-
-
|
| 81 |
-
-
|
| 82 |
-
-
|
| 83 |
-
-
|
| 84 |
-
-
|
| 85 |
-
-
|
| 86 |
-
- cue or cue × context tests;
|
| 87 |
-
- focus/zoom behavior.
|
| 88 |
|
| 89 |
-
|
|
|
|
| 1 |
+
# FeatureLens v0.14 validation
|
| 2 |
|
| 3 |
+
v0.14 changes the **offline study path and Offline study tab**. It does not change the live Qwen/SAE intervention callbacks validated in v0.13, so do not spend ZeroGPU quota rerunning paraphrase, dose-response, controlled specificity, cross-target, layer trajectory, or feature-set regressions.
|
| 4 |
|
| 5 |
+
## A. Local release gate
|
| 6 |
+
|
| 7 |
+
From the repository root:
|
| 8 |
|
| 9 |
```bash
|
| 10 |
python3 -m pytest -q && \
|
|
|
|
| 14 |
python3 scripts/release_check.py
|
| 15 |
```
|
| 16 |
|
| 17 |
+
Expected automated test count for v0.14: **74**.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
## B. HF acceptance — zero GPU calls
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
After pushing v0.14:
|
| 22 |
|
| 23 |
+
1. Open the Space and confirm startup is clean.
|
| 24 |
+
2. Open **Offline study**.
|
| 25 |
+
3. Before real artifacts are committed, verify it says **Offline study not materialized yet** and does not show fabricated tables or metrics.
|
| 26 |
+
4. Confirm the rest of the live tabs render normally. Do not run GPU experiments solely for this release.
|
| 27 |
|
| 28 |
+
## C. Offline-study synthetic/software acceptance
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
The automated suite covers:
|
| 31 |
|
| 32 |
+
- prompt-wide max pooling while ignoring padding;
|
| 33 |
+
- balanced candidate score behavior;
|
| 34 |
+
- random-ensemble pairing by causal task;
|
| 35 |
+
- descriptive Spearman guardrails;
|
| 36 |
+
- offline-study missing/complete UI state.
|
| 37 |
|
| 38 |
+
The development integration gate additionally runs the CPU evaluation/stability/study/report stack on synthetic saved activations and validates the resulting artifact schema.
|
| 39 |
|
| 40 |
+
## D. Real offline-study acceptance
|
| 41 |
|
| 42 |
+
When a CUDA run is available:
|
|
|
|
| 43 |
|
| 44 |
+
```bash
|
| 45 |
+
python experiments/run_all.py --resume
|
|
|
|
|
|
|
|
|
|
| 46 |
```
|
| 47 |
|
| 48 |
+
Then:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
```bash
|
| 51 |
+
python -m scripts.validate_artifacts
|
| 52 |
+
```
|
| 53 |
|
| 54 |
+
Required checks:
|
| 55 |
|
| 56 |
+
- activation metadata says `prompt-wide max activation across non-padding tokens`;
|
| 57 |
+
- `features_layer4/14/26.npz` exist for prompt-wide concept evidence;
|
| 58 |
+
- `features_final_layer4/14/26.npz` exist for local final-token diagnostics;
|
| 59 |
+
- `feature_catalog.csv`, `selection_stability.csv`, `causal_results.csv`, `feature_set_results.csv`, `study_feature_summary.csv`, `study_summary.json`, `summary.json`, and `report.md` are non-empty;
|
| 60 |
+
- report figures include `association_vs_causality.png` and `candidate_stability.png`;
|
| 61 |
+
- no large activation/model files are staged for Git.
|
|
|
|
|
|
|
| 62 |
|
| 63 |
+
After committing only the small study artifacts, restart the Space and verify **Offline study** automatically displays the measured study summary, selected-feature table, stability table, layer diagnostics, and figures.
|
experiments/analyze_stability.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import json
|
| 6 |
+
from collections import defaultdict
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import scipy.sparse as sp
|
| 11 |
+
|
| 12 |
+
from experiments.common import ARTIFACT_DIR
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def parse_args() -> argparse.Namespace:
|
| 16 |
+
parser = argparse.ArgumentParser(
|
| 17 |
+
description='Estimate prompt-wide candidate selection stability from saved SAE activations.'
|
| 18 |
+
)
|
| 19 |
+
parser.add_argument('--activation-dir', type=Path, default=ARTIFACT_DIR / 'activations')
|
| 20 |
+
parser.add_argument('--output', type=Path, default=ARTIFACT_DIR / 'selection_stability.csv')
|
| 21 |
+
parser.add_argument('--resamples', type=int, default=128)
|
| 22 |
+
parser.add_argument('--top-features', type=int, default=20)
|
| 23 |
+
parser.add_argument('--seed', type=int, default=42)
|
| 24 |
+
return parser.parse_args()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _vector_mean(matrix: sp.csr_matrix, indices: np.ndarray) -> np.ndarray:
|
| 28 |
+
if indices.size == 0:
|
| 29 |
+
return np.zeros(matrix.shape[1], dtype=np.float32)
|
| 30 |
+
return np.asarray(matrix[indices].mean(axis=0)).ravel().astype(np.float32, copy=False)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _vector_rate(binary: sp.csr_matrix, indices: np.ndarray) -> np.ndarray:
|
| 34 |
+
if indices.size == 0:
|
| 35 |
+
return np.zeros(binary.shape[1], dtype=np.float32)
|
| 36 |
+
return np.asarray(binary[indices].mean(axis=0)).ravel().astype(np.float32, copy=False)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def balanced_candidate_score(
|
| 40 |
+
target_mean: np.ndarray,
|
| 41 |
+
other_mean: np.ndarray,
|
| 42 |
+
target_rate: np.ndarray,
|
| 43 |
+
) -> np.ndarray:
|
| 44 |
+
"""Live-compatible selectivity × coverage × log-magnitude candidate score."""
|
| 45 |
+
target = np.asarray(target_mean, dtype=np.float64)
|
| 46 |
+
other = np.asarray(other_mean, dtype=np.float64)
|
| 47 |
+
rate = np.asarray(target_rate, dtype=np.float64)
|
| 48 |
+
difference = target - other
|
| 49 |
+
denom = np.abs(target) + np.abs(other) + 1e-12
|
| 50 |
+
selectivity = np.where(difference > 0.0, difference / denom, 0.0)
|
| 51 |
+
return selectivity * np.clip(rate, 0.0, 1.0) * np.log1p(np.maximum(target, 0.0))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _rank_top(scores: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]:
|
| 55 |
+
positive = np.flatnonzero(scores > 0.0)
|
| 56 |
+
if positive.size == 0:
|
| 57 |
+
return np.empty(0, dtype=int), np.empty(0, dtype=float)
|
| 58 |
+
k = min(int(k), int(positive.size))
|
| 59 |
+
candidate_scores = scores[positive]
|
| 60 |
+
local = np.argpartition(candidate_scores, -k)[-k:]
|
| 61 |
+
ids = positive[local]
|
| 62 |
+
ordered = np.argsort(scores[ids])[::-1]
|
| 63 |
+
ids = ids[ordered]
|
| 64 |
+
return ids.astype(int), scores[ids].astype(float)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _sample_indices(
|
| 68 |
+
concept_indices: dict[str, np.ndarray],
|
| 69 |
+
rng: np.random.Generator,
|
| 70 |
+
) -> dict[str, np.ndarray]:
|
| 71 |
+
return {
|
| 72 |
+
concept: rng.choice(indices, size=indices.size, replace=True)
|
| 73 |
+
for concept, indices in concept_indices.items()
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _concept_statistics(
|
| 78 |
+
matrix: sp.csr_matrix,
|
| 79 |
+
binary: sp.csr_matrix,
|
| 80 |
+
concept_indices: dict[str, np.ndarray],
|
| 81 |
+
) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
|
| 82 |
+
means = {
|
| 83 |
+
concept: _vector_mean(matrix, indices)
|
| 84 |
+
for concept, indices in concept_indices.items()
|
| 85 |
+
}
|
| 86 |
+
rates = {
|
| 87 |
+
concept: _vector_rate(binary, indices)
|
| 88 |
+
for concept, indices in concept_indices.items()
|
| 89 |
+
}
|
| 90 |
+
return means, rates
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _score_for_concept(
|
| 94 |
+
concept: str,
|
| 95 |
+
means: dict[str, np.ndarray],
|
| 96 |
+
rates: dict[str, np.ndarray],
|
| 97 |
+
) -> np.ndarray:
|
| 98 |
+
other_concepts = [name for name in means if name != concept]
|
| 99 |
+
other_mean = np.mean(np.stack([means[name] for name in other_concepts]), axis=0)
|
| 100 |
+
return balanced_candidate_score(means[concept], other_mean, rates[concept])
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def main() -> None:
|
| 104 |
+
args = parse_args()
|
| 105 |
+
if args.resamples < 1:
|
| 106 |
+
raise ValueError('--resamples must be at least 1.')
|
| 107 |
+
if args.top_features < 1:
|
| 108 |
+
raise ValueError('--top-features must be at least 1.')
|
| 109 |
+
|
| 110 |
+
metadata = json.loads((args.activation_dir / 'metadata.json').read_text(encoding='utf-8'))
|
| 111 |
+
pooling = metadata.get('feature_pooling', '')
|
| 112 |
+
if 'prompt-wide' not in pooling:
|
| 113 |
+
raise RuntimeError(
|
| 114 |
+
'Selection stability requires v0.14 prompt-wide activation artifacts. '
|
| 115 |
+
'Rerun experiments.collect_activations before this analysis.'
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
rows = metadata['rows']
|
| 119 |
+
layers = [int(layer) for layer in metadata['layers']]
|
| 120 |
+
concepts = sorted({row['concept'] for row in rows})
|
| 121 |
+
concept_indices = {
|
| 122 |
+
concept: np.array(
|
| 123 |
+
[idx for idx, row in enumerate(rows) if row['concept'] == concept],
|
| 124 |
+
dtype=int,
|
| 125 |
+
)
|
| 126 |
+
for concept in concepts
|
| 127 |
+
}
|
| 128 |
+
rng = np.random.default_rng(args.seed)
|
| 129 |
+
output_rows: list[dict] = []
|
| 130 |
+
|
| 131 |
+
for layer in layers:
|
| 132 |
+
matrix = sp.load_npz(args.activation_dir / f'features_layer{layer}.npz').tocsr()
|
| 133 |
+
binary = matrix.copy()
|
| 134 |
+
binary.data = np.ones_like(binary.data, dtype=np.float32)
|
| 135 |
+
|
| 136 |
+
full_means, full_rates = _concept_statistics(matrix, binary, concept_indices)
|
| 137 |
+
full_scores = {
|
| 138 |
+
concept: _score_for_concept(concept, full_means, full_rates)
|
| 139 |
+
for concept in concepts
|
| 140 |
+
}
|
| 141 |
+
full_orders: dict[str, np.ndarray] = {
|
| 142 |
+
concept: np.argsort(scores)[::-1]
|
| 143 |
+
for concept, scores in full_scores.items()
|
| 144 |
+
}
|
| 145 |
+
support: dict[tuple[str, int], int] = defaultdict(int)
|
| 146 |
+
ranks: dict[tuple[str, int], list[int]] = defaultdict(list)
|
| 147 |
+
|
| 148 |
+
for _ in range(args.resamples):
|
| 149 |
+
sampled = _sample_indices(concept_indices, rng)
|
| 150 |
+
means, rates = _concept_statistics(matrix, binary, sampled)
|
| 151 |
+
for concept in concepts:
|
| 152 |
+
scores = _score_for_concept(concept, means, rates)
|
| 153 |
+
ids, _ = _rank_top(scores, args.top_features)
|
| 154 |
+
for rank, feature_id in enumerate(ids.tolist(), start=1):
|
| 155 |
+
key = (concept, int(feature_id))
|
| 156 |
+
support[key] += 1
|
| 157 |
+
ranks[key].append(rank)
|
| 158 |
+
|
| 159 |
+
for concept in concepts:
|
| 160 |
+
full_rank_map = np.empty(matrix.shape[1], dtype=np.int32)
|
| 161 |
+
full_rank_map[full_orders[concept]] = np.arange(1, matrix.shape[1] + 1, dtype=np.int32)
|
| 162 |
+
seen = {
|
| 163 |
+
feature_id
|
| 164 |
+
for (seen_concept, feature_id), count in support.items()
|
| 165 |
+
if seen_concept == concept and count > 0
|
| 166 |
+
}
|
| 167 |
+
full_ids, _ = _rank_top(full_scores[concept], max(args.top_features, 50))
|
| 168 |
+
seen.update(int(feature_id) for feature_id in full_ids.tolist())
|
| 169 |
+
|
| 170 |
+
for feature_id in seen:
|
| 171 |
+
key = (concept, feature_id)
|
| 172 |
+
feature_ranks = ranks.get(key, [])
|
| 173 |
+
output_rows.append(
|
| 174 |
+
{
|
| 175 |
+
'layer': layer,
|
| 176 |
+
'concept': concept,
|
| 177 |
+
'feature_id': feature_id,
|
| 178 |
+
'full_score': float(full_scores[concept][feature_id]),
|
| 179 |
+
'full_rank': int(full_rank_map[feature_id]),
|
| 180 |
+
'resample_support': float(support.get(key, 0) / args.resamples),
|
| 181 |
+
'median_resample_rank': (
|
| 182 |
+
float(np.median(feature_ranks)) if feature_ranks else float('nan')
|
| 183 |
+
),
|
| 184 |
+
'mean_resample_rank': (
|
| 185 |
+
float(np.mean(feature_ranks)) if feature_ranks else float('nan')
|
| 186 |
+
),
|
| 187 |
+
'resamples': int(args.resamples),
|
| 188 |
+
'top_features_per_resample': int(args.top_features),
|
| 189 |
+
}
|
| 190 |
+
)
|
| 191 |
+
print(f'Stability analysis complete for layer {layer}', flush=True)
|
| 192 |
+
|
| 193 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 194 |
+
fieldnames = [
|
| 195 |
+
'layer',
|
| 196 |
+
'concept',
|
| 197 |
+
'feature_id',
|
| 198 |
+
'full_score',
|
| 199 |
+
'full_rank',
|
| 200 |
+
'resample_support',
|
| 201 |
+
'median_resample_rank',
|
| 202 |
+
'mean_resample_rank',
|
| 203 |
+
'resamples',
|
| 204 |
+
'top_features_per_resample',
|
| 205 |
+
]
|
| 206 |
+
with args.output.open('w', newline='', encoding='utf-8') as handle:
|
| 207 |
+
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
| 208 |
+
writer.writeheader()
|
| 209 |
+
writer.writerows(output_rows)
|
| 210 |
+
print(f'Wrote {len(output_rows)} selection-stability rows to {args.output}')
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
if __name__ == '__main__':
|
| 214 |
+
main()
|
experiments/analyze_study.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from scipy.stats import spearmanr
|
| 10 |
+
|
| 11 |
+
from experiments.common import ARTIFACT_DIR
|
| 12 |
+
from featurelens.stats import paired_bootstrap_difference_ci, paired_sign_flip_pvalue
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def parse_args() -> argparse.Namespace:
|
| 16 |
+
parser = argparse.ArgumentParser(
|
| 17 |
+
description='Aggregate held-out association, stability, and causal evidence by concept.'
|
| 18 |
+
)
|
| 19 |
+
parser.add_argument('--artifact-dir', type=Path, default=ARTIFACT_DIR)
|
| 20 |
+
parser.add_argument('--seed', type=int, default=42)
|
| 21 |
+
return parser.parse_args()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def selected_features(catalog: pd.DataFrame) -> pd.DataFrame:
|
| 25 |
+
scored = catalog.copy()
|
| 26 |
+
scored['activation_contrast'] = (
|
| 27 |
+
scored['activation_rate_pos'].astype(float) - scored['activation_rate_neg'].astype(float)
|
| 28 |
+
)
|
| 29 |
+
ordered = scored.sort_values(
|
| 30 |
+
['concept', 'train_auroc', 'activation_contrast'],
|
| 31 |
+
ascending=[True, False, False],
|
| 32 |
+
)
|
| 33 |
+
return ordered.groupby('concept', as_index=False).first()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _paired_specificity(
|
| 37 |
+
frame: pd.DataFrame,
|
| 38 |
+
*,
|
| 39 |
+
effect_column: str,
|
| 40 |
+
task_column: str = 'task_id',
|
| 41 |
+
seed: int,
|
| 42 |
+
) -> dict[str, float | list[float]]:
|
| 43 |
+
sae = (
|
| 44 |
+
frame[frame['condition'] == 'sae_feature']
|
| 45 |
+
.groupby(task_column, as_index=False)[effect_column]
|
| 46 |
+
.first()
|
| 47 |
+
.rename(columns={effect_column: 'sae_effect'})
|
| 48 |
+
)
|
| 49 |
+
random = (
|
| 50 |
+
frame[frame['condition'] == 'random_norm_matched']
|
| 51 |
+
.assign(_abs_effect=lambda data: np.abs(data[effect_column].astype(float)))
|
| 52 |
+
.groupby(task_column, as_index=False)['_abs_effect']
|
| 53 |
+
.mean()
|
| 54 |
+
.rename(columns={'_abs_effect': 'random_abs_effect'})
|
| 55 |
+
)
|
| 56 |
+
paired = sae.merge(random, on=task_column, how='inner')
|
| 57 |
+
if paired.empty:
|
| 58 |
+
return {
|
| 59 |
+
'sae_abs_mean': float('nan'),
|
| 60 |
+
'sae_signed_mean': float('nan'),
|
| 61 |
+
'random_abs_mean': float('nan'),
|
| 62 |
+
'specificity_ratio': float('nan'),
|
| 63 |
+
'paired_advantage': float('nan'),
|
| 64 |
+
'paired_advantage_ci_95': [float('nan'), float('nan')],
|
| 65 |
+
'paired_sign_flip_pvalue': float('nan'),
|
| 66 |
+
'n_tasks': 0,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
sae_values = paired['sae_effect'].to_numpy(dtype=float)
|
| 70 |
+
sae_abs = np.abs(sae_values)
|
| 71 |
+
random_abs = paired['random_abs_effect'].to_numpy(dtype=float)
|
| 72 |
+
ci_low, ci_high = paired_bootstrap_difference_ci(sae_abs, random_abs, seed=seed)
|
| 73 |
+
return {
|
| 74 |
+
'sae_abs_mean': float(np.mean(sae_abs)),
|
| 75 |
+
'sae_signed_mean': float(np.mean(sae_values)),
|
| 76 |
+
'random_abs_mean': float(np.mean(random_abs)),
|
| 77 |
+
'specificity_ratio': float(np.mean(sae_abs) / max(float(np.mean(random_abs)), 1e-12)),
|
| 78 |
+
'paired_advantage': float(np.mean(sae_abs - random_abs)),
|
| 79 |
+
'paired_advantage_ci_95': [float(ci_low), float(ci_high)],
|
| 80 |
+
'paired_sign_flip_pvalue': float(
|
| 81 |
+
paired_sign_flip_pvalue(sae_abs, random_abs, seed=seed + 1)
|
| 82 |
+
),
|
| 83 |
+
'n_tasks': int(len(paired)),
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _safe_spearman(x: pd.Series, y: pd.Series) -> dict[str, float | int]:
|
| 88 |
+
a = pd.to_numeric(x, errors='coerce').to_numpy(dtype=float)
|
| 89 |
+
b = pd.to_numeric(y, errors='coerce').to_numpy(dtype=float)
|
| 90 |
+
mask = np.isfinite(a) & np.isfinite(b)
|
| 91 |
+
if int(mask.sum()) < 3:
|
| 92 |
+
return {'rho': float('nan'), 'pvalue': float('nan'), 'n': int(mask.sum())}
|
| 93 |
+
if np.unique(a[mask]).size < 2 or np.unique(b[mask]).size < 2:
|
| 94 |
+
return {'rho': float('nan'), 'pvalue': float('nan'), 'n': int(mask.sum())}
|
| 95 |
+
result = spearmanr(a[mask], b[mask])
|
| 96 |
+
return {
|
| 97 |
+
'rho': float(result.statistic),
|
| 98 |
+
'pvalue': float(result.pvalue),
|
| 99 |
+
'n': int(mask.sum()),
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def main() -> None:
|
| 104 |
+
args = parse_args()
|
| 105 |
+
artifact_dir = args.artifact_dir
|
| 106 |
+
catalog = pd.read_csv(artifact_dir / 'feature_catalog.csv')
|
| 107 |
+
causal = pd.read_csv(artifact_dir / 'causal_results.csv')
|
| 108 |
+
paraphrase = pd.read_csv(artifact_dir / 'stability.csv')
|
| 109 |
+
stability_path = artifact_dir / 'selection_stability.csv'
|
| 110 |
+
selection_stability = pd.read_csv(stability_path) if stability_path.exists() else pd.DataFrame()
|
| 111 |
+
|
| 112 |
+
selected = selected_features(catalog)
|
| 113 |
+
rows: list[dict] = []
|
| 114 |
+
|
| 115 |
+
for concept_idx, selected_row in selected.sort_values('concept').reset_index(drop=True).iterrows():
|
| 116 |
+
concept = str(selected_row['concept'])
|
| 117 |
+
layer = int(selected_row['layer'])
|
| 118 |
+
feature_id = int(selected_row['feature_id'])
|
| 119 |
+
causal_concept = causal[
|
| 120 |
+
(causal['concept'] == concept) & (causal['intervention'] == 'ablate')
|
| 121 |
+
].copy()
|
| 122 |
+
target = _paired_specificity(
|
| 123 |
+
causal_concept,
|
| 124 |
+
effect_column='target_mean_logprob_delta',
|
| 125 |
+
seed=args.seed + 100 * concept_idx,
|
| 126 |
+
)
|
| 127 |
+
js = _paired_specificity(
|
| 128 |
+
causal_concept,
|
| 129 |
+
effect_column='js_divergence',
|
| 130 |
+
seed=args.seed + 100 * concept_idx + 17,
|
| 131 |
+
)
|
| 132 |
+
sae_rows = causal_concept[causal_concept['condition'] == 'sae_feature']
|
| 133 |
+
|
| 134 |
+
para_rows = paraphrase[
|
| 135 |
+
(paraphrase['concept'] == concept) & (paraphrase['layer'].astype(int) == layer)
|
| 136 |
+
]
|
| 137 |
+
selection_row = pd.DataFrame()
|
| 138 |
+
if not selection_stability.empty:
|
| 139 |
+
selection_row = selection_stability[
|
| 140 |
+
(selection_stability['concept'] == concept)
|
| 141 |
+
& (selection_stability['layer'].astype(int) == layer)
|
| 142 |
+
& (selection_stability['feature_id'].astype(int) == feature_id)
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
rows.append(
|
| 146 |
+
{
|
| 147 |
+
'concept': concept,
|
| 148 |
+
'layer': layer,
|
| 149 |
+
'feature_id': feature_id,
|
| 150 |
+
'train_auroc': float(selected_row['train_auroc']),
|
| 151 |
+
'heldout_auroc': float(selected_row['auroc']),
|
| 152 |
+
'heldout_f1': float(selected_row['f1']),
|
| 153 |
+
'activation_rate_pos_train': float(selected_row['activation_rate_pos']),
|
| 154 |
+
'activation_rate_neg_train': float(selected_row['activation_rate_neg']),
|
| 155 |
+
'candidate_resample_support': (
|
| 156 |
+
float(selection_row.iloc[0]['resample_support'])
|
| 157 |
+
if not selection_row.empty
|
| 158 |
+
else 0.0
|
| 159 |
+
),
|
| 160 |
+
'candidate_median_resample_rank': (
|
| 161 |
+
float(selection_row.iloc[0]['median_resample_rank'])
|
| 162 |
+
if not selection_row.empty
|
| 163 |
+
else float('nan')
|
| 164 |
+
),
|
| 165 |
+
'mean_paraphrase_topk_jaccard': (
|
| 166 |
+
float(para_rows['topk_jaccard'].mean()) if not para_rows.empty else float('nan')
|
| 167 |
+
),
|
| 168 |
+
'mean_paraphrase_sparse_cosine': (
|
| 169 |
+
float(para_rows['sparse_cosine'].mean()) if not para_rows.empty else float('nan')
|
| 170 |
+
),
|
| 171 |
+
'causal_feature_active_rate': (
|
| 172 |
+
float(np.mean(sae_rows['feature_activation'].astype(float) > 0.0))
|
| 173 |
+
if not sae_rows.empty
|
| 174 |
+
else float('nan')
|
| 175 |
+
),
|
| 176 |
+
'target_sae_abs_mean': target['sae_abs_mean'],
|
| 177 |
+
'target_sae_signed_mean': target['sae_signed_mean'],
|
| 178 |
+
'target_random_abs_mean': target['random_abs_mean'],
|
| 179 |
+
'target_specificity_ratio': target['specificity_ratio'],
|
| 180 |
+
'target_paired_advantage': target['paired_advantage'],
|
| 181 |
+
'target_paired_ci_low': float(target['paired_advantage_ci_95'][0]),
|
| 182 |
+
'target_paired_ci_high': float(target['paired_advantage_ci_95'][1]),
|
| 183 |
+
'target_sign_flip_pvalue': target['paired_sign_flip_pvalue'],
|
| 184 |
+
'js_sae_mean': js['sae_abs_mean'],
|
| 185 |
+
'js_random_mean': js['random_abs_mean'],
|
| 186 |
+
'js_specificity_ratio': js['specificity_ratio'],
|
| 187 |
+
'js_paired_advantage': js['paired_advantage'],
|
| 188 |
+
'js_paired_ci_low': float(js['paired_advantage_ci_95'][0]),
|
| 189 |
+
'js_paired_ci_high': float(js['paired_advantage_ci_95'][1]),
|
| 190 |
+
'js_sign_flip_pvalue': js['paired_sign_flip_pvalue'],
|
| 191 |
+
'causal_tasks': int(target['n_tasks']),
|
| 192 |
+
}
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
study = pd.DataFrame(rows)
|
| 196 |
+
study_path = artifact_dir / 'study_feature_summary.csv'
|
| 197 |
+
study.to_csv(study_path, index=False)
|
| 198 |
+
|
| 199 |
+
correlations = {
|
| 200 |
+
'heldout_auroc_vs_target_specificity': _safe_spearman(
|
| 201 |
+
study['heldout_auroc'], study['target_specificity_ratio']
|
| 202 |
+
),
|
| 203 |
+
'heldout_auroc_vs_js_specificity': _safe_spearman(
|
| 204 |
+
study['heldout_auroc'], study['js_specificity_ratio']
|
| 205 |
+
),
|
| 206 |
+
'heldout_f1_vs_target_specificity': _safe_spearman(
|
| 207 |
+
study['heldout_f1'], study['target_specificity_ratio']
|
| 208 |
+
),
|
| 209 |
+
'candidate_resample_support_vs_target_specificity': _safe_spearman(
|
| 210 |
+
study['candidate_resample_support'], study['target_specificity_ratio']
|
| 211 |
+
),
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
most_predictive = study.sort_values('heldout_auroc', ascending=False).iloc[0]
|
| 215 |
+
most_target_specific = study.sort_values('target_specificity_ratio', ascending=False).iloc[0]
|
| 216 |
+
most_js_specific = study.sort_values('js_specificity_ratio', ascending=False).iloc[0]
|
| 217 |
+
median_support = float(study['candidate_resample_support'].median())
|
| 218 |
+
|
| 219 |
+
summary = {
|
| 220 |
+
'n_concepts': int(len(study)),
|
| 221 |
+
'selected_feature_pooling': 'prompt-wide max SAE activation across non-padding prompt tokens',
|
| 222 |
+
'dense_probe_pooling': 'final prompt token residual',
|
| 223 |
+
'median_selected_feature_resample_support': median_support,
|
| 224 |
+
'most_predictive_concept': {
|
| 225 |
+
'concept': str(most_predictive['concept']),
|
| 226 |
+
'heldout_auroc': float(most_predictive['heldout_auroc']),
|
| 227 |
+
},
|
| 228 |
+
'highest_target_specificity': {
|
| 229 |
+
'concept': str(most_target_specific['concept']),
|
| 230 |
+
'ratio': float(most_target_specific['target_specificity_ratio']),
|
| 231 |
+
},
|
| 232 |
+
'highest_js_specificity': {
|
| 233 |
+
'concept': str(most_js_specific['concept']),
|
| 234 |
+
'ratio': float(most_js_specific['js_specificity_ratio']),
|
| 235 |
+
},
|
| 236 |
+
'correlations': correlations,
|
| 237 |
+
'guardrail': (
|
| 238 |
+
'Cross-concept Spearman correlations are descriptive because the study contains only seven '
|
| 239 |
+
'controlled concepts. Causal specificity remains evaluated per concept against matched random '
|
| 240 |
+
'controls rather than inferred from correlation alone.'
|
| 241 |
+
),
|
| 242 |
+
}
|
| 243 |
+
(artifact_dir / 'study_summary.json').write_text(
|
| 244 |
+
json.dumps(summary, indent=2),
|
| 245 |
+
encoding='utf-8',
|
| 246 |
+
)
|
| 247 |
+
print(f'Wrote {study_path}')
|
| 248 |
+
print(f'Wrote {artifact_dir / "study_summary.json"}')
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
if __name__ == '__main__':
|
| 252 |
+
main()
|
experiments/collect_activations.py
CHANGED
|
@@ -12,7 +12,7 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
| 12 |
from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
|
| 13 |
from featurelens.config import SETTINGS
|
| 14 |
from featurelens.metrics import reconstruction_metrics
|
| 15 |
-
from featurelens.sae import SAEStore
|
| 16 |
|
| 17 |
|
| 18 |
def parse_args() -> argparse.Namespace:
|
|
@@ -26,7 +26,7 @@ def parse_args() -> argparse.Namespace:
|
|
| 26 |
return parser.parse_args()
|
| 27 |
|
| 28 |
|
| 29 |
-
def _build_sparse(encodings: list, n_rows: int, width: int) -> sp.csr_matrix:
|
| 30 |
row_ids: list[int] = []
|
| 31 |
col_ids: list[int] = []
|
| 32 |
values: list[float] = []
|
|
@@ -40,6 +40,48 @@ def _build_sparse(encodings: list, n_rows: int, width: int) -> sp.csr_matrix:
|
|
| 40 |
return sp.csr_matrix((values, (row_ids, col_ids)), shape=(n_rows, width), dtype=np.float32)
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def _make_capture_hook(
|
| 44 |
captured: dict[int, torch.Tensor],
|
| 45 |
layer: int,
|
|
@@ -81,7 +123,8 @@ def main() -> None:
|
|
| 81 |
)
|
| 82 |
|
| 83 |
residuals: dict[int, list[np.ndarray]] = {layer: [] for layer in args.layers}
|
| 84 |
-
|
|
|
|
| 85 |
recon_stats: dict[int, list[dict[str, float]]] = {layer: [] for layer in args.layers}
|
| 86 |
|
| 87 |
for start in range(0, len(rows), args.batch_size):
|
|
@@ -110,19 +153,27 @@ def main() -> None:
|
|
| 110 |
|
| 111 |
for layer in args.layers:
|
| 112 |
sae = sae_store.get(layer)
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
for row_idx in range(final_token_residuals.shape[0]):
|
| 116 |
residual = final_token_residuals[row_idx]
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
indices=batch_encoding.indices[row_idx],
|
| 121 |
-
values=batch_encoding.values[row_idx],
|
| 122 |
)
|
| 123 |
-
reconstructed = sae.decode_sparse(
|
| 124 |
-
residuals[layer].append(
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
| 126 |
recon_stats[layer].append(reconstruction_metrics(residual, reconstructed))
|
| 127 |
|
| 128 |
print(f'Processed {min(start + args.batch_size, len(rows))}/{len(rows)} prompts', flush=True)
|
|
@@ -130,18 +181,43 @@ def main() -> None:
|
|
| 130 |
for layer in args.layers:
|
| 131 |
residual_array = np.stack(residuals[layer], axis=0)
|
| 132 |
np.save(args.output_dir / f'residuals_layer{layer}.npy', residual_array)
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
summary = {
|
| 136 |
'layer': layer,
|
| 137 |
'n_samples': len(rows),
|
| 138 |
-
'mean_cosine': float(np.mean([
|
| 139 |
-
'mean_nmse': float(np.mean([
|
| 140 |
-
'median_nmse': float(np.median([
|
| 141 |
-
'
|
|
|
|
| 142 |
}
|
|
|
|
|
|
|
| 143 |
(args.output_dir / f'reconstruction_layer{layer}.json').write_text(
|
| 144 |
-
json.dumps(summary, indent=2),
|
|
|
|
| 145 |
)
|
| 146 |
|
| 147 |
metadata = {
|
|
@@ -151,11 +227,18 @@ def main() -> None:
|
|
| 151 |
'top_k': SETTINGS.sae_top_k,
|
| 152 |
'width': SETTINGS.sae_width,
|
| 153 |
'n_samples': len(rows),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
'rows': rows,
|
| 155 |
}
|
| 156 |
-
(args.output_dir / 'metadata.json').write_text(
|
|
|
|
|
|
|
|
|
|
| 157 |
print(f'Activation artifacts written to {args.output_dir}')
|
| 158 |
|
| 159 |
|
| 160 |
if __name__ == '__main__':
|
| 161 |
-
main()
|
|
|
|
| 12 |
from experiments.common import ARTIFACT_DIR, DATA_DIR, load_jsonl, set_seed
|
| 13 |
from featurelens.config import SETTINGS
|
| 14 |
from featurelens.metrics import reconstruction_metrics
|
| 15 |
+
from featurelens.sae import SAEStore, SparseEncoding
|
| 16 |
|
| 17 |
|
| 18 |
def parse_args() -> argparse.Namespace:
|
|
|
|
| 26 |
return parser.parse_args()
|
| 27 |
|
| 28 |
|
| 29 |
+
def _build_sparse(encodings: list[SparseEncoding], n_rows: int, width: int) -> sp.csr_matrix:
|
| 30 |
row_ids: list[int] = []
|
| 31 |
col_ids: list[int] = []
|
| 32 |
values: list[float] = []
|
|
|
|
| 40 |
return sp.csr_matrix((values, (row_ids, col_ids)), shape=(n_rows, width), dtype=np.float32)
|
| 41 |
|
| 42 |
|
| 43 |
+
def _promptwide_max_encoding(
|
| 44 |
+
token_encoding: SparseEncoding,
|
| 45 |
+
attention_mask: torch.Tensor,
|
| 46 |
+
) -> list[SparseEncoding]:
|
| 47 |
+
"""Max-pool sparse feature activations across non-padding tokens for each prompt."""
|
| 48 |
+
indices = token_encoding.indices.detach().cpu()
|
| 49 |
+
values = token_encoding.values.detach().float().cpu()
|
| 50 |
+
mask = attention_mask.detach().bool().cpu()
|
| 51 |
+
pooled: list[SparseEncoding] = []
|
| 52 |
+
|
| 53 |
+
for row_idx in range(indices.shape[0]):
|
| 54 |
+
feature_max: dict[int, float] = {}
|
| 55 |
+
valid_positions = torch.nonzero(mask[row_idx], as_tuple=False).reshape(-1).tolist()
|
| 56 |
+
for token_idx in valid_positions:
|
| 57 |
+
token_ids = indices[row_idx, token_idx].reshape(-1).tolist()
|
| 58 |
+
token_values = values[row_idx, token_idx].reshape(-1).tolist()
|
| 59 |
+
for feature_id, activation in zip(token_ids, token_values, strict=True):
|
| 60 |
+
activation = float(activation)
|
| 61 |
+
if activation <= 0.0:
|
| 62 |
+
continue
|
| 63 |
+
feature_id = int(feature_id)
|
| 64 |
+
if activation > feature_max.get(feature_id, 0.0):
|
| 65 |
+
feature_max[feature_id] = activation
|
| 66 |
+
|
| 67 |
+
if feature_max:
|
| 68 |
+
ordered = sorted(feature_max.items())
|
| 69 |
+
pooled.append(
|
| 70 |
+
SparseEncoding(
|
| 71 |
+
indices=torch.tensor([item[0] for item in ordered], dtype=torch.long),
|
| 72 |
+
values=torch.tensor([item[1] for item in ordered], dtype=torch.float32),
|
| 73 |
+
)
|
| 74 |
+
)
|
| 75 |
+
else:
|
| 76 |
+
pooled.append(
|
| 77 |
+
SparseEncoding(
|
| 78 |
+
indices=torch.empty(0, dtype=torch.long),
|
| 79 |
+
values=torch.empty(0, dtype=torch.float32),
|
| 80 |
+
)
|
| 81 |
+
)
|
| 82 |
+
return pooled
|
| 83 |
+
|
| 84 |
+
|
| 85 |
def _make_capture_hook(
|
| 86 |
captured: dict[int, torch.Tensor],
|
| 87 |
layer: int,
|
|
|
|
| 123 |
)
|
| 124 |
|
| 125 |
residuals: dict[int, list[np.ndarray]] = {layer: [] for layer in args.layers}
|
| 126 |
+
final_encodings: dict[int, list[SparseEncoding]] = {layer: [] for layer in args.layers}
|
| 127 |
+
promptwide_encodings: dict[int, list[SparseEncoding]] = {layer: [] for layer in args.layers}
|
| 128 |
recon_stats: dict[int, list[dict[str, float]]] = {layer: [] for layer in args.layers}
|
| 129 |
|
| 130 |
for start in range(0, len(rows), args.batch_size):
|
|
|
|
| 153 |
|
| 154 |
for layer in args.layers:
|
| 155 |
sae = sae_store.get(layer)
|
| 156 |
+
hidden = captured[layer]
|
| 157 |
+
final_token_residuals = hidden[:, -1, :]
|
| 158 |
+
final_batch_encoding = sae.encode(final_token_residuals)
|
| 159 |
+
token_batch_encoding = sae.encode(hidden)
|
| 160 |
+
promptwide_batch = _promptwide_max_encoding(
|
| 161 |
+
token_batch_encoding,
|
| 162 |
+
batch['attention_mask'],
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
for row_idx in range(final_token_residuals.shape[0]):
|
| 166 |
residual = final_token_residuals[row_idx]
|
| 167 |
+
final_encoding = SparseEncoding(
|
| 168 |
+
indices=final_batch_encoding.indices[row_idx],
|
| 169 |
+
values=final_batch_encoding.values[row_idx],
|
|
|
|
|
|
|
| 170 |
)
|
| 171 |
+
reconstructed = sae.decode_sparse(final_encoding)
|
| 172 |
+
residuals[layer].append(
|
| 173 |
+
residual.detach().float().cpu().numpy().astype(np.float16)
|
| 174 |
+
)
|
| 175 |
+
final_encodings[layer].append(final_encoding)
|
| 176 |
+
promptwide_encodings[layer].append(promptwide_batch[row_idx])
|
| 177 |
recon_stats[layer].append(reconstruction_metrics(residual, reconstructed))
|
| 178 |
|
| 179 |
print(f'Processed {min(start + args.batch_size, len(rows))}/{len(rows)} prompts', flush=True)
|
|
|
|
| 181 |
for layer in args.layers:
|
| 182 |
residual_array = np.stack(residuals[layer], axis=0)
|
| 183 |
np.save(args.output_dir / f'residuals_layer{layer}.npy', residual_array)
|
| 184 |
+
|
| 185 |
+
promptwide_sparse = _build_sparse(
|
| 186 |
+
promptwide_encodings[layer],
|
| 187 |
+
len(rows),
|
| 188 |
+
SETTINGS.sae_width,
|
| 189 |
+
)
|
| 190 |
+
sp.save_npz(
|
| 191 |
+
args.output_dir / f'features_layer{layer}.npz',
|
| 192 |
+
promptwide_sparse,
|
| 193 |
+
compressed=True,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
final_sparse = _build_sparse(
|
| 197 |
+
final_encodings[layer],
|
| 198 |
+
len(rows),
|
| 199 |
+
SETTINGS.sae_width,
|
| 200 |
+
)
|
| 201 |
+
sp.save_npz(
|
| 202 |
+
args.output_dir / f'features_final_layer{layer}.npz',
|
| 203 |
+
final_sparse,
|
| 204 |
+
compressed=True,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
summary = {
|
| 208 |
'layer': layer,
|
| 209 |
'n_samples': len(rows),
|
| 210 |
+
'mean_cosine': float(np.mean([item['cosine'] for item in recon_stats[layer]])),
|
| 211 |
+
'mean_nmse': float(np.mean([item['nmse'] for item in recon_stats[layer]])),
|
| 212 |
+
'median_nmse': float(np.median([item['nmse'] for item in recon_stats[layer]])),
|
| 213 |
+
'mean_active_features_final_token': float(np.mean(np.diff(final_sparse.indptr))),
|
| 214 |
+
'mean_active_features_promptwide': float(np.mean(np.diff(promptwide_sparse.indptr))),
|
| 215 |
}
|
| 216 |
+
# Preserve the legacy key for report compatibility. It refers to final-token TopK activity.
|
| 217 |
+
summary['mean_active_features'] = summary['mean_active_features_final_token']
|
| 218 |
(args.output_dir / f'reconstruction_layer{layer}.json').write_text(
|
| 219 |
+
json.dumps(summary, indent=2),
|
| 220 |
+
encoding='utf-8',
|
| 221 |
)
|
| 222 |
|
| 223 |
metadata = {
|
|
|
|
| 227 |
'top_k': SETTINGS.sae_top_k,
|
| 228 |
'width': SETTINGS.sae_width,
|
| 229 |
'n_samples': len(rows),
|
| 230 |
+
'feature_pooling': 'prompt-wide max activation across non-padding tokens',
|
| 231 |
+
'feature_file_pattern': 'features_layer{layer}.npz',
|
| 232 |
+
'final_token_feature_file_pattern': 'features_final_layer{layer}.npz',
|
| 233 |
+
'dense_residual_pooling': 'final prompt token',
|
| 234 |
'rows': rows,
|
| 235 |
}
|
| 236 |
+
(args.output_dir / 'metadata.json').write_text(
|
| 237 |
+
json.dumps(metadata, indent=2),
|
| 238 |
+
encoding='utf-8',
|
| 239 |
+
)
|
| 240 |
print(f'Activation artifacts written to {args.output_dir}')
|
| 241 |
|
| 242 |
|
| 243 |
if __name__ == '__main__':
|
| 244 |
+
main()
|
experiments/evaluate_features.py
CHANGED
|
@@ -72,6 +72,11 @@ def main() -> None:
|
|
| 72 |
args = parse_args()
|
| 73 |
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 74 |
metadata = json.loads((args.activation_dir / 'metadata.json').read_text(encoding='utf-8'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
rows = metadata['rows']
|
| 76 |
layers = [int(x) for x in metadata['layers']]
|
| 77 |
train_idx, test_idx = grouped_concept_split(rows, seed=args.seed)
|
|
|
|
| 72 |
args = parse_args()
|
| 73 |
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 74 |
metadata = json.loads((args.activation_dir / 'metadata.json').read_text(encoding='utf-8'))
|
| 75 |
+
if 'prompt-wide' not in str(metadata.get('feature_pooling', '')):
|
| 76 |
+
raise RuntimeError(
|
| 77 |
+
'v0.14 evaluation requires prompt-wide activation artifacts. '
|
| 78 |
+
'Rerun experiments.collect_activations before evaluating features.'
|
| 79 |
+
)
|
| 80 |
rows = metadata['rows']
|
| 81 |
layers = [int(x) for x in metadata['layers']]
|
| 82 |
train_idx, test_idx = grouped_concept_split(rows, seed=args.seed)
|
experiments/make_report.py
CHANGED
|
@@ -118,6 +118,36 @@ def _save_plots(
|
|
| 118 |
plt.close(figure)
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
def _paired_stats(
|
| 122 |
frame: pd.DataFrame,
|
| 123 |
*,
|
|
@@ -175,8 +205,17 @@ def main() -> None:
|
|
| 175 |
causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
|
| 176 |
feature_set_path = args.artifact_dir / 'feature_set_results.csv'
|
| 177 |
feature_sets = pd.read_csv(feature_set_path) if feature_set_path.exists() else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
selected = _selected_features(catalog)
|
| 179 |
_save_plots(args.artifact_dir, selected, layers, causal, feature_sets)
|
|
|
|
| 180 |
|
| 181 |
mean_auc = float(selected['auroc'].mean())
|
| 182 |
median_auc = float(selected['auroc'].median())
|
|
@@ -299,6 +338,18 @@ def main() -> None:
|
|
| 299 |
f'sign-flip p={float(largest_set["pvalue"]):.4f}.'
|
| 300 |
)
|
| 301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
summary = {
|
| 303 |
'headline': headline,
|
| 304 |
'highlights': highlights,
|
|
@@ -321,6 +372,7 @@ def main() -> None:
|
|
| 321 |
'causal_prompt_feature_active_rate': active_rate,
|
| 322 |
'sae_top1_change_rate': top1_change,
|
| 323 |
'feature_set_results': {str(k): value for k, value in set_summary.items()},
|
|
|
|
| 324 |
},
|
| 325 |
}
|
| 326 |
(args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
|
|
@@ -347,7 +399,9 @@ def main() -> None:
|
|
| 347 |
'- Model: Qwen3-1.7B-Base.',
|
| 348 |
'- SAEs: Qwen-Scope residual-stream TopK SAEs at configured early/middle/late layers.',
|
| 349 |
'- Discovery set: controlled concept prompts with paired paraphrases.',
|
|
|
|
| 350 |
'- Split discipline: paraphrase groups stay entirely in train or held-out test.',
|
|
|
|
| 351 |
'- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
|
| 352 |
'- Linear baseline: multinomial logistic regression on the dense residual stream.',
|
| 353 |
'- Single-feature causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
|
|
@@ -367,6 +421,19 @@ def main() -> None:
|
|
| 367 |
]
|
| 368 |
if feature_sets is not None and not feature_sets.empty:
|
| 369 |
lines.extend(['', ''])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
lines.extend(
|
| 371 |
[
|
| 372 |
'',
|
|
@@ -376,7 +443,7 @@ def main() -> None:
|
|
| 376 |
'',
|
| 377 |
'## Reproducibility',
|
| 378 |
'',
|
| 379 |
-
'Run `python experiments/run_all.py` from the repository root.
|
| 380 |
'',
|
| 381 |
]
|
| 382 |
)
|
|
|
|
| 118 |
plt.close(figure)
|
| 119 |
|
| 120 |
|
| 121 |
+
|
| 122 |
+
def _save_study_plots(artifact_dir: Path, study: pd.DataFrame) -> None:
|
| 123 |
+
if study.empty:
|
| 124 |
+
return
|
| 125 |
+
fig_dir = artifact_dir / 'figures'
|
| 126 |
+
fig_dir.mkdir(parents=True, exist_ok=True)
|
| 127 |
+
|
| 128 |
+
figure = plt.figure(figsize=(7.2, 4.6))
|
| 129 |
+
ax = figure.add_subplot(111)
|
| 130 |
+
ax.scatter(study['heldout_auroc'], study['target_specificity_ratio'])
|
| 131 |
+
for row in study.itertuples():
|
| 132 |
+
ax.annotate(str(row.concept), (row.heldout_auroc, row.target_specificity_ratio), fontsize=8)
|
| 133 |
+
ax.set_xlabel('Held-out feature AUROC')
|
| 134 |
+
ax.set_ylabel('Target causal specificity ratio')
|
| 135 |
+
ax.set_title('Association evidence vs random-normalized causality')
|
| 136 |
+
figure.tight_layout()
|
| 137 |
+
figure.savefig(fig_dir / 'association_vs_causality.png', dpi=160)
|
| 138 |
+
plt.close(figure)
|
| 139 |
+
|
| 140 |
+
figure = plt.figure(figsize=(7.4, 4.6))
|
| 141 |
+
ax = figure.add_subplot(111)
|
| 142 |
+
ordered = study.sort_values('candidate_resample_support')
|
| 143 |
+
ax.barh(ordered['concept'], ordered['candidate_resample_support'])
|
| 144 |
+
ax.set_xlim(0, 1.02)
|
| 145 |
+
ax.set_xlabel('Selection support across activation resamples')
|
| 146 |
+
ax.set_title('Selected-feature candidate stability')
|
| 147 |
+
figure.tight_layout()
|
| 148 |
+
figure.savefig(fig_dir / 'candidate_stability.png', dpi=160)
|
| 149 |
+
plt.close(figure)
|
| 150 |
+
|
| 151 |
def _paired_stats(
|
| 152 |
frame: pd.DataFrame,
|
| 153 |
*,
|
|
|
|
| 205 |
causal = pd.read_csv(args.artifact_dir / 'causal_results.csv')
|
| 206 |
feature_set_path = args.artifact_dir / 'feature_set_results.csv'
|
| 207 |
feature_sets = pd.read_csv(feature_set_path) if feature_set_path.exists() else None
|
| 208 |
+
study_path = args.artifact_dir / 'study_feature_summary.csv'
|
| 209 |
+
study = pd.read_csv(study_path) if study_path.exists() else pd.DataFrame()
|
| 210 |
+
study_summary_path = args.artifact_dir / 'study_summary.json'
|
| 211 |
+
study_summary = (
|
| 212 |
+
json.loads(study_summary_path.read_text(encoding='utf-8'))
|
| 213 |
+
if study_summary_path.exists()
|
| 214 |
+
else {}
|
| 215 |
+
)
|
| 216 |
selected = _selected_features(catalog)
|
| 217 |
_save_plots(args.artifact_dir, selected, layers, causal, feature_sets)
|
| 218 |
+
_save_study_plots(args.artifact_dir, study)
|
| 219 |
|
| 220 |
mean_auc = float(selected['auroc'].mean())
|
| 221 |
median_auc = float(selected['auroc'].median())
|
|
|
|
| 338 |
f'sign-flip p={float(largest_set["pvalue"]):.4f}.'
|
| 339 |
)
|
| 340 |
|
| 341 |
+
if study_summary:
|
| 342 |
+
correlations = study_summary.get('correlations', {})
|
| 343 |
+
assoc_target = correlations.get('heldout_auroc_vs_target_specificity', {})
|
| 344 |
+
assoc_js = correlations.get('heldout_auroc_vs_js_specificity', {})
|
| 345 |
+
highlights.extend(
|
| 346 |
+
[
|
| 347 |
+
f'Selected-feature median activation-resample support: {float(study_summary.get("median_selected_feature_resample_support", float("nan"))):.1%}.',
|
| 348 |
+
f'Across concepts, held-out AUROC vs target-specificity Spearman ρ={float(assoc_target.get("rho", float("nan"))):+.3f} (n={int(assoc_target.get("n", 0))}); descriptive only.',
|
| 349 |
+
f'Across concepts, held-out AUROC vs JS-specificity Spearman ρ={float(assoc_js.get("rho", float("nan"))):+.3f} (n={int(assoc_js.get("n", 0))}); descriptive only.',
|
| 350 |
+
]
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
summary = {
|
| 354 |
'headline': headline,
|
| 355 |
'highlights': highlights,
|
|
|
|
| 372 |
'causal_prompt_feature_active_rate': active_rate,
|
| 373 |
'sae_top1_change_rate': top1_change,
|
| 374 |
'feature_set_results': {str(k): value for k, value in set_summary.items()},
|
| 375 |
+
'study_summary': study_summary,
|
| 376 |
},
|
| 377 |
}
|
| 378 |
(args.artifact_dir / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
|
|
|
|
| 399 |
'- Model: Qwen3-1.7B-Base.',
|
| 400 |
'- SAEs: Qwen-Scope residual-stream TopK SAEs at configured early/middle/late layers.',
|
| 401 |
'- Discovery set: controlled concept prompts with paired paraphrases.',
|
| 402 |
+
'- SAE concept evidence: prompt-wide maximum activation per feature across non-padding prompt tokens; final-token activations are saved separately for local diagnostics.',
|
| 403 |
'- Split discipline: paraphrase groups stay entirely in train or held-out test.',
|
| 404 |
+
'- Candidate stability: deterministic balanced activation resamples estimate how often selected features survive small changes in the discovery sample.',
|
| 405 |
'- Feature selection: training-split AUROC and activation contrast; held-out AUROC/F1 are reported separately.',
|
| 406 |
'- Linear baseline: multinomial logistic regression on the dense residual stream.',
|
| 407 |
'- Single-feature causal edit: reconstruction-preserving decoder-direction delta patched into the original residual.',
|
|
|
|
| 421 |
]
|
| 422 |
if feature_sets is not None and not feature_sets.empty:
|
| 423 |
lines.extend(['', ''])
|
| 424 |
+
if not study.empty:
|
| 425 |
+
lines.extend(
|
| 426 |
+
[
|
| 427 |
+
'',
|
| 428 |
+
'',
|
| 429 |
+
'',
|
| 430 |
+
'',
|
| 431 |
+
'',
|
| 432 |
+
'## Association vs causality across concepts',
|
| 433 |
+
'',
|
| 434 |
+
"The offline study joins each concept-selected feature's held-out AUROC/F1 and activation-resample support with random-normalized causal specificity on the held-out causal task set. Cross-concept correlations are descriptive because there are only seven controlled concepts.",
|
| 435 |
+
]
|
| 436 |
+
)
|
| 437 |
lines.extend(
|
| 438 |
[
|
| 439 |
'',
|
|
|
|
| 443 |
'',
|
| 444 |
'## Reproducibility',
|
| 445 |
'',
|
| 446 |
+
'Run `python experiments/run_all.py` from the repository root for the full GPU + CPU study. If model/SAE activations and causal rows already exist, run `python experiments/run_analysis_only.py` to regenerate CPU evaluation, stability, study synthesis, figures, validation, and this report without another model download or inference pass.',
|
| 447 |
'',
|
| 448 |
]
|
| 449 |
)
|
experiments/run_all.py
CHANGED
|
@@ -1,26 +1,93 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import subprocess
|
| 4 |
import sys
|
| 5 |
from pathlib import Path
|
| 6 |
|
|
|
|
|
|
|
| 7 |
ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
|
| 9 |
|
| 10 |
-
def
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
print('\n$', ' '.join(command), flush=True)
|
| 14 |
subprocess.run(command, cwd=ROOT, check=True)
|
| 15 |
|
| 16 |
|
| 17 |
def main() -> None:
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
run(
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
print('\nFeatureLens experiment pipeline complete. See artifacts/report.md')
|
| 25 |
|
| 26 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import argparse
|
| 4 |
import subprocess
|
| 5 |
import sys
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
+
from featurelens.config import SETTINGS
|
| 9 |
+
|
| 10 |
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
|
| 12 |
|
| 13 |
+
def parse_args() -> argparse.Namespace:
|
| 14 |
+
parser = argparse.ArgumentParser(description='Run the full FeatureLens offline study.')
|
| 15 |
+
parser.add_argument(
|
| 16 |
+
'--resume',
|
| 17 |
+
action='store_true',
|
| 18 |
+
help='Skip stages whose expected outputs already exist.',
|
| 19 |
+
)
|
| 20 |
+
return parser.parse_args()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def run(module: str, *, outputs: list[Path], resume: bool) -> None:
|
| 24 |
+
if resume and outputs and all(path.exists() for path in outputs):
|
| 25 |
+
print(f'\nSKIP {module}: expected outputs already exist.', flush=True)
|
| 26 |
+
return
|
| 27 |
+
command = [sys.executable, '-m', module]
|
| 28 |
print('\n$', ' '.join(command), flush=True)
|
| 29 |
subprocess.run(command, cwd=ROOT, check=True)
|
| 30 |
|
| 31 |
|
| 32 |
def main() -> None:
|
| 33 |
+
args = parse_args()
|
| 34 |
+
artifact_dir = ROOT / 'artifacts'
|
| 35 |
+
activation_dir = artifact_dir / 'activations'
|
| 36 |
+
|
| 37 |
+
run(
|
| 38 |
+
'experiments.build_dataset',
|
| 39 |
+
outputs=[ROOT / 'data' / 'prompts.jsonl', ROOT / 'data' / 'causal_tasks.jsonl'],
|
| 40 |
+
resume=args.resume,
|
| 41 |
+
)
|
| 42 |
+
run(
|
| 43 |
+
'experiments.collect_activations',
|
| 44 |
+
outputs=[
|
| 45 |
+
activation_dir / 'metadata.json',
|
| 46 |
+
*[activation_dir / f'features_layer{layer}.npz' for layer in SETTINGS.layers],
|
| 47 |
+
*[activation_dir / f'features_final_layer{layer}.npz' for layer in SETTINGS.layers],
|
| 48 |
+
],
|
| 49 |
+
resume=args.resume,
|
| 50 |
+
)
|
| 51 |
+
run(
|
| 52 |
+
'experiments.evaluate_features',
|
| 53 |
+
outputs=[
|
| 54 |
+
artifact_dir / 'feature_catalog.csv',
|
| 55 |
+
artifact_dir / 'layer_metrics.csv',
|
| 56 |
+
artifact_dir / 'stability.csv',
|
| 57 |
+
artifact_dir / 'split.json',
|
| 58 |
+
],
|
| 59 |
+
resume=args.resume,
|
| 60 |
+
)
|
| 61 |
+
run(
|
| 62 |
+
'experiments.run_causal',
|
| 63 |
+
outputs=[artifact_dir / 'causal_results.csv'],
|
| 64 |
+
resume=args.resume,
|
| 65 |
+
)
|
| 66 |
+
run(
|
| 67 |
+
'experiments.run_feature_sets',
|
| 68 |
+
outputs=[artifact_dir / 'feature_set_results.csv'],
|
| 69 |
+
resume=args.resume,
|
| 70 |
+
)
|
| 71 |
+
run(
|
| 72 |
+
'experiments.analyze_stability',
|
| 73 |
+
outputs=[artifact_dir / 'selection_stability.csv'],
|
| 74 |
+
resume=args.resume,
|
| 75 |
+
)
|
| 76 |
+
run(
|
| 77 |
+
'experiments.analyze_study',
|
| 78 |
+
outputs=[artifact_dir / 'study_feature_summary.csv', artifact_dir / 'study_summary.json'],
|
| 79 |
+
resume=args.resume,
|
| 80 |
+
)
|
| 81 |
+
run(
|
| 82 |
+
'experiments.make_report',
|
| 83 |
+
outputs=[artifact_dir / 'summary.json', artifact_dir / 'report.md'],
|
| 84 |
+
resume=args.resume,
|
| 85 |
+
)
|
| 86 |
+
subprocess.run(
|
| 87 |
+
[sys.executable, '-m', 'scripts.validate_artifacts'],
|
| 88 |
+
cwd=ROOT,
|
| 89 |
+
check=True,
|
| 90 |
+
)
|
| 91 |
print('\nFeatureLens experiment pipeline complete. See artifacts/report.md')
|
| 92 |
|
| 93 |
|
experiments/run_analysis_only.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def run(module: str, *args: str) -> None:
|
| 11 |
+
command = [sys.executable, '-m', module, *args]
|
| 12 |
+
print('\n$', ' '.join(command), flush=True)
|
| 13 |
+
subprocess.run(command, cwd=ROOT, check=True)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main() -> None:
|
| 17 |
+
run('experiments.evaluate_features')
|
| 18 |
+
run('experiments.analyze_stability')
|
| 19 |
+
run('experiments.analyze_study')
|
| 20 |
+
run('experiments.make_report')
|
| 21 |
+
run('scripts.validate_artifacts')
|
| 22 |
+
print('\nFeatureLens CPU analysis pipeline complete. See artifacts/report.md')
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
if __name__ == '__main__':
|
| 26 |
+
main()
|
featurelens/study.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class OfflineStudy:
|
| 10 |
+
"""Read committed offline-study artifacts without invoking the model."""
|
| 11 |
+
|
| 12 |
+
REQUIRED = (
|
| 13 |
+
'summary.json',
|
| 14 |
+
'study_summary.json',
|
| 15 |
+
'study_feature_summary.csv',
|
| 16 |
+
'selection_stability.csv',
|
| 17 |
+
'feature_catalog.csv',
|
| 18 |
+
'layer_metrics.csv',
|
| 19 |
+
'stability.csv',
|
| 20 |
+
'causal_results.csv',
|
| 21 |
+
'feature_set_results.csv',
|
| 22 |
+
'report.md',
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
def __init__(self, artifact_dir: str | Path = 'artifacts') -> None:
|
| 26 |
+
self.artifact_dir = Path(artifact_dir)
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def missing(self) -> list[str]:
|
| 30 |
+
return [name for name in self.REQUIRED if not (self.artifact_dir / name).exists()]
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def complete(self) -> bool:
|
| 34 |
+
return not self.missing
|
| 35 |
+
|
| 36 |
+
def _json(self, name: str) -> dict:
|
| 37 |
+
path = self.artifact_dir / name
|
| 38 |
+
if not path.exists():
|
| 39 |
+
return {}
|
| 40 |
+
return json.loads(path.read_text(encoding='utf-8'))
|
| 41 |
+
|
| 42 |
+
def dataframe(self, name: str) -> pd.DataFrame:
|
| 43 |
+
path = self.artifact_dir / name
|
| 44 |
+
if not path.exists():
|
| 45 |
+
return pd.DataFrame()
|
| 46 |
+
return pd.read_csv(path)
|
| 47 |
+
|
| 48 |
+
def figure(self, name: str) -> str | None:
|
| 49 |
+
path = self.artifact_dir / 'figures' / name
|
| 50 |
+
return str(path) if path.exists() else None
|
| 51 |
+
|
| 52 |
+
def overview_markdown(self) -> str:
|
| 53 |
+
if not self.complete:
|
| 54 |
+
missing = ', '.join(f'`{name}`' for name in self.missing[:6])
|
| 55 |
+
suffix = '…' if len(self.missing) > 6 else ''
|
| 56 |
+
return (
|
| 57 |
+
'### Offline study not materialized yet\n\n'
|
| 58 |
+
'The live workbench is usable now, but held-out study artifacts have not been committed. '
|
| 59 |
+
f'Missing: {missing}{suffix}\n\n'
|
| 60 |
+
'Run `python experiments/run_all.py` on a CUDA machine for the full study. If activation and '
|
| 61 |
+
'causal artifacts already exist, use `python experiments/run_analysis_only.py` to rerun the '
|
| 62 |
+
'CPU-only evaluation, stability analysis, evidence synthesis, figures, and report. '
|
| 63 |
+
'Then run `python -m scripts.validate_artifacts` before committing the small CSV/JSON/report '
|
| 64 |
+
'outputs. Do not commit `artifacts/activations/`.'
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
summary = self._json('summary.json')
|
| 68 |
+
study = self._json('study_summary.json')
|
| 69 |
+
correlations = study.get('correlations', {})
|
| 70 |
+
target_corr = correlations.get('heldout_auroc_vs_target_specificity', {})
|
| 71 |
+
js_corr = correlations.get('heldout_auroc_vs_js_specificity', {})
|
| 72 |
+
return (
|
| 73 |
+
'### Offline study results\n\n'
|
| 74 |
+
f"{summary.get('headline', 'Benchmark completed.')}\n\n"
|
| 75 |
+
f"{summary.get('interpretation', '')}\n\n"
|
| 76 |
+
'**Study-level diagnostics**\n\n'
|
| 77 |
+
f"- Selected-feature median resample support: **{float(study.get('median_selected_feature_resample_support', float('nan'))):.1%}**.\n"
|
| 78 |
+
f"- Held-out AUROC ↔ target-specificity Spearman ρ: **{float(target_corr.get('rho', float('nan'))):+.3f}** (n={int(target_corr.get('n', 0))}).\n"
|
| 79 |
+
f"- Held-out AUROC ↔ JS-specificity Spearman ρ: **{float(js_corr.get('rho', float('nan'))):+.3f}** (n={int(js_corr.get('n', 0))}).\n\n"
|
| 80 |
+
'The cross-concept correlations are descriptive because there are only seven controlled concepts. '
|
| 81 |
+
'Per-concept causal claims remain anchored to matched random-control comparisons.'
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
def readiness_markdown(self) -> str:
|
| 85 |
+
if self.complete:
|
| 86 |
+
return '**Artifact status:** complete and ready for the public study view.'
|
| 87 |
+
return (
|
| 88 |
+
f'**Artifact status:** {len(self.REQUIRED) - len(self.missing)}/{len(self.REQUIRED)} required '
|
| 89 |
+
f'files present; {len(self.missing)} missing.'
|
| 90 |
+
)
|
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.14.0"
|
| 4 |
description = "Causal sparse-feature interpretability workbench for Qwen3 and Qwen-Scope SAEs"
|
| 5 |
requires-python = ">=3.10"
|
| 6 |
|
research_config.json
CHANGED
|
@@ -90,7 +90,7 @@
|
|
| 90 |
"contrastive_continuation_preference_test",
|
| 91 |
"feature_decoder_geometry"
|
| 92 |
],
|
| 93 |
-
"concept_candidate_discovery_metric": "balanced exploratory score = selectivity
|
| 94 |
"completion_cue_scan": "final-token feature activation after controlled suffix/cue substitution",
|
| 95 |
"live_features_v0_6": [
|
| 96 |
"start_here_plain_language_onboarding",
|
|
@@ -177,5 +177,24 @@
|
|
| 177 |
"pairwise_target_preference_shifts",
|
| 178 |
"zero_extra_gpu_evidence_synthesis",
|
| 179 |
"touched_path_only_hf_validation"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
]
|
| 181 |
}
|
|
|
|
| 90 |
"contrastive_continuation_preference_test",
|
| 91 |
"feature_decoder_geometry"
|
| 92 |
],
|
| 93 |
+
"concept_candidate_discovery_metric": "balanced exploratory score = selectivity \u00d7 target activation rate \u00d7 log1p(target mean); causal-ready mode additionally requires current-token activity and log-scales that activation; raw mean-difference remains available as a scale-sensitive comparison",
|
| 94 |
"completion_cue_scan": "final-token feature activation after controlled suffix/cue substitution",
|
| 95 |
"live_features_v0_6": [
|
| 96 |
"start_here_plain_language_onboarding",
|
|
|
|
| 177 |
"pairwise_target_preference_shifts",
|
| 178 |
"zero_extra_gpu_evidence_synthesis",
|
| 179 |
"touched_path_only_hf_validation"
|
| 180 |
+
],
|
| 181 |
+
"offline_feature_pooling": "prompt-wide max SAE activation across non-padding prompt tokens; final-token sparse activations saved separately",
|
| 182 |
+
"offline_selection_resamples": 128,
|
| 183 |
+
"offline_study_outputs": [
|
| 184 |
+
"selection_stability.csv",
|
| 185 |
+
"study_feature_summary.csv",
|
| 186 |
+
"study_summary.json",
|
| 187 |
+
"summary.json",
|
| 188 |
+
"report.md"
|
| 189 |
+
],
|
| 190 |
+
"offline_features_v0_14": [
|
| 191 |
+
"promptwide_offline_sae_feature_pooling",
|
| 192 |
+
"separate_final_token_sparse_activation_artifacts",
|
| 193 |
+
"activation_resample_candidate_stability",
|
| 194 |
+
"cross_concept_association_vs_random_normalized_causality",
|
| 195 |
+
"offline_study_dashboard",
|
| 196 |
+
"resume_safe_full_study_runner",
|
| 197 |
+
"cpu_only_analysis_rerun",
|
| 198 |
+
"offline_artifact_schema_validation"
|
| 199 |
]
|
| 200 |
}
|
scripts/release_check.py
CHANGED
|
@@ -18,13 +18,20 @@ REQUIRED = [
|
|
| 18 |
'featurelens/interventions.py',
|
| 19 |
'featurelens/metrics.py',
|
| 20 |
'featurelens/stats.py',
|
|
|
|
| 21 |
'experiments/run_all.py',
|
| 22 |
'experiments/run_causal.py',
|
| 23 |
'experiments/run_feature_sets.py',
|
|
|
|
|
|
|
|
|
|
| 24 |
'data/prompts.jsonl',
|
| 25 |
'data/causal_tasks.jsonl',
|
| 26 |
'docs/VALIDATION.md',
|
|
|
|
| 27 |
'scripts/ui_smoke.py',
|
|
|
|
|
|
|
| 28 |
]
|
| 29 |
|
| 30 |
|
|
@@ -231,6 +238,27 @@ def check_config(config: dict) -> None:
|
|
| 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:
|
|
@@ -330,16 +358,22 @@ def check_readme() -> None:
|
|
| 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.
|
| 337 |
|
| 338 |
|
| 339 |
def check_pyproject() -> None:
|
| 340 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 341 |
-
if 'version = "0.
|
| 342 |
-
raise SystemExit('pyproject.toml must declare version 0.
|
| 343 |
|
| 344 |
|
| 345 |
def main() -> None:
|
|
@@ -357,7 +391,7 @@ 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.
|
| 361 |
|
| 362 |
|
| 363 |
if __name__ == '__main__':
|
|
|
|
| 18 |
'featurelens/interventions.py',
|
| 19 |
'featurelens/metrics.py',
|
| 20 |
'featurelens/stats.py',
|
| 21 |
+
'featurelens/study.py',
|
| 22 |
'experiments/run_all.py',
|
| 23 |
'experiments/run_causal.py',
|
| 24 |
'experiments/run_feature_sets.py',
|
| 25 |
+
'experiments/analyze_stability.py',
|
| 26 |
+
'experiments/analyze_study.py',
|
| 27 |
+
'experiments/run_analysis_only.py',
|
| 28 |
'data/prompts.jsonl',
|
| 29 |
'data/causal_tasks.jsonl',
|
| 30 |
'docs/VALIDATION.md',
|
| 31 |
+
'docs/OFFLINE_STUDY.md',
|
| 32 |
'scripts/ui_smoke.py',
|
| 33 |
+
'tests/test_offline_study.py',
|
| 34 |
+
'scripts/validate_artifacts.py',
|
| 35 |
]
|
| 36 |
|
| 37 |
|
|
|
|
| 238 |
raise SystemExit(
|
| 239 |
'research_config.json live_features_v0_13 mismatch: ' f'{sorted(actual_live_v13)}'
|
| 240 |
)
|
| 241 |
+
required_offline_v14 = {
|
| 242 |
+
'promptwide_offline_sae_feature_pooling',
|
| 243 |
+
'separate_final_token_sparse_activation_artifacts',
|
| 244 |
+
'activation_resample_candidate_stability',
|
| 245 |
+
'cross_concept_association_vs_random_normalized_causality',
|
| 246 |
+
'offline_study_dashboard',
|
| 247 |
+
'resume_safe_full_study_runner',
|
| 248 |
+
'cpu_only_analysis_rerun',
|
| 249 |
+
'offline_artifact_schema_validation',
|
| 250 |
+
}
|
| 251 |
+
actual_offline_v14 = set(config.get('offline_features_v0_14', []))
|
| 252 |
+
if actual_offline_v14 != required_offline_v14:
|
| 253 |
+
raise SystemExit(
|
| 254 |
+
'research_config.json offline_features_v0_14 mismatch: '
|
| 255 |
+
f'{sorted(actual_offline_v14)}'
|
| 256 |
+
)
|
| 257 |
+
if config.get('offline_selection_resamples') != 128:
|
| 258 |
+
raise SystemExit('Offline selection resamples must be 128.')
|
| 259 |
+
if 'prompt-wide' not in str(config.get('offline_feature_pooling', '')):
|
| 260 |
+
raise SystemExit('Offline feature pooling must be prompt-wide.')
|
| 261 |
+
|
| 262 |
if config.get('discovery_resample_replicates') != 32:
|
| 263 |
raise SystemExit('Discovery live resample count must be 32.')
|
| 264 |
if config.get('cross_target_feature_limit') != 3 or config.get('cross_target_target_limit') != 5:
|
|
|
|
| 358 |
'pairwise target preference',
|
| 359 |
'effect concentration',
|
| 360 |
'signed bias',
|
| 361 |
+
'prompt-wide',
|
| 362 |
+
'offline study',
|
| 363 |
+
'selection stability',
|
| 364 |
+
'run_analysis_only',
|
| 365 |
+
'--resume',
|
| 366 |
+
'validate_artifacts',
|
| 367 |
]
|
| 368 |
missing = [value for value in required_strings if value.lower() not in readme.lower()]
|
| 369 |
if missing:
|
| 370 |
+
raise SystemExit(f'README.md is missing required v0.14 content: {missing}')
|
| 371 |
|
| 372 |
|
| 373 |
def check_pyproject() -> None:
|
| 374 |
text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
|
| 375 |
+
if 'version = "0.14.0"' not in text:
|
| 376 |
+
raise SystemExit('pyproject.toml must declare version 0.14.0.')
|
| 377 |
|
| 378 |
|
| 379 |
def main() -> None:
|
|
|
|
| 391 |
print(f' layers: {config["layers"]}')
|
| 392 |
print(f' feature-set sizes: {config["feature_set_sizes"]}')
|
| 393 |
print(f' random controls: {config["live_random_controls"]}')
|
| 394 |
+
print(' release: v0.14.0')
|
| 395 |
|
| 396 |
|
| 397 |
if __name__ == '__main__':
|
scripts/validate_artifacts.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 9 |
+
ARTIFACT_DIR = ROOT / 'artifacts'
|
| 10 |
+
|
| 11 |
+
REQUIRED = [
|
| 12 |
+
'activations/metadata.json',
|
| 13 |
+
'feature_catalog.csv',
|
| 14 |
+
'layer_metrics.csv',
|
| 15 |
+
'stability.csv',
|
| 16 |
+
'selection_stability.csv',
|
| 17 |
+
'causal_results.csv',
|
| 18 |
+
'feature_set_results.csv',
|
| 19 |
+
'study_feature_summary.csv',
|
| 20 |
+
'study_summary.json',
|
| 21 |
+
'summary.json',
|
| 22 |
+
'report.md',
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _require_columns(path: Path, columns: set[str]) -> None:
|
| 27 |
+
frame = pd.read_csv(path)
|
| 28 |
+
missing = columns.difference(frame.columns)
|
| 29 |
+
if missing:
|
| 30 |
+
raise SystemExit(f'{path.relative_to(ROOT)} missing columns: {sorted(missing)}')
|
| 31 |
+
if frame.empty:
|
| 32 |
+
raise SystemExit(f'{path.relative_to(ROOT)} is empty.')
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def main() -> None:
|
| 36 |
+
missing = [name for name in REQUIRED if not (ARTIFACT_DIR / name).exists()]
|
| 37 |
+
if missing:
|
| 38 |
+
raise SystemExit(f'Missing offline-study artifacts: {missing}')
|
| 39 |
+
|
| 40 |
+
metadata = json.loads((ARTIFACT_DIR / 'activations' / 'metadata.json').read_text(encoding='utf-8'))
|
| 41 |
+
pooling = str(metadata.get('feature_pooling', ''))
|
| 42 |
+
if 'prompt-wide' not in pooling:
|
| 43 |
+
raise SystemExit(
|
| 44 |
+
'Activation metadata is not v0.14 prompt-wide. Rerun experiments.collect_activations.'
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
_require_columns(
|
| 48 |
+
ARTIFACT_DIR / 'feature_catalog.csv',
|
| 49 |
+
{'layer', 'concept', 'feature_id', 'train_auroc', 'auroc', 'f1'},
|
| 50 |
+
)
|
| 51 |
+
_require_columns(
|
| 52 |
+
ARTIFACT_DIR / 'selection_stability.csv',
|
| 53 |
+
{'layer', 'concept', 'feature_id', 'resample_support', 'median_resample_rank'},
|
| 54 |
+
)
|
| 55 |
+
_require_columns(
|
| 56 |
+
ARTIFACT_DIR / 'causal_results.csv',
|
| 57 |
+
{
|
| 58 |
+
'task_id',
|
| 59 |
+
'concept',
|
| 60 |
+
'feature_id',
|
| 61 |
+
'intervention',
|
| 62 |
+
'condition',
|
| 63 |
+
'target_mean_logprob_delta',
|
| 64 |
+
'js_divergence',
|
| 65 |
+
},
|
| 66 |
+
)
|
| 67 |
+
_require_columns(
|
| 68 |
+
ARTIFACT_DIR / 'study_feature_summary.csv',
|
| 69 |
+
{
|
| 70 |
+
'concept',
|
| 71 |
+
'layer',
|
| 72 |
+
'feature_id',
|
| 73 |
+
'heldout_auroc',
|
| 74 |
+
'heldout_f1',
|
| 75 |
+
'candidate_resample_support',
|
| 76 |
+
'target_specificity_ratio',
|
| 77 |
+
'js_specificity_ratio',
|
| 78 |
+
},
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
summary = json.loads((ARTIFACT_DIR / 'study_summary.json').read_text(encoding='utf-8'))
|
| 82 |
+
if int(summary.get('n_concepts', 0)) < 1:
|
| 83 |
+
raise SystemExit('study_summary.json has no concepts.')
|
| 84 |
+
|
| 85 |
+
required_figures = [
|
| 86 |
+
'feature_auroc.png',
|
| 87 |
+
'layer_diagnostics.png',
|
| 88 |
+
'causal_effects.png',
|
| 89 |
+
'feature_set_effects.png',
|
| 90 |
+
'association_vs_causality.png',
|
| 91 |
+
'candidate_stability.png',
|
| 92 |
+
]
|
| 93 |
+
missing_figures = [
|
| 94 |
+
name for name in required_figures if not (ARTIFACT_DIR / 'figures' / name).exists()
|
| 95 |
+
]
|
| 96 |
+
if missing_figures:
|
| 97 |
+
raise SystemExit(f'Missing report figures: {missing_figures}')
|
| 98 |
+
|
| 99 |
+
print('FeatureLens offline artifact validation: PASS')
|
| 100 |
+
print(f" concepts: {summary['n_concepts']}")
|
| 101 |
+
print(f" feature pooling: {summary['selected_feature_pooling']}")
|
| 102 |
+
print(' report: artifacts/report.md')
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
if __name__ == '__main__':
|
| 106 |
+
main()
|
tests/test_offline_study.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from experiments.analyze_stability import balanced_candidate_score
|
| 11 |
+
from experiments.analyze_study import _paired_specificity, _safe_spearman
|
| 12 |
+
from experiments.collect_activations import _promptwide_max_encoding
|
| 13 |
+
from featurelens.sae import SparseEncoding
|
| 14 |
+
from featurelens.study import OfflineStudy
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_promptwide_max_encoding_ignores_padding_and_max_pools() -> None:
|
| 18 |
+
encoding = SparseEncoding(
|
| 19 |
+
indices=torch.tensor(
|
| 20 |
+
[
|
| 21 |
+
[[1, 2], [1, 3], [2, 4]],
|
| 22 |
+
[[5, 6], [5, 7], [7, 8]],
|
| 23 |
+
]
|
| 24 |
+
),
|
| 25 |
+
values=torch.tensor(
|
| 26 |
+
[
|
| 27 |
+
[[1.0, 2.0], [4.0, 3.0], [5.0, 1.0]],
|
| 28 |
+
[[9.0, 9.0], [2.0, 4.0], [7.0, 6.0]],
|
| 29 |
+
]
|
| 30 |
+
),
|
| 31 |
+
)
|
| 32 |
+
mask = torch.tensor([[1, 1, 1], [0, 1, 1]])
|
| 33 |
+
pooled = _promptwide_max_encoding(encoding, mask)
|
| 34 |
+
|
| 35 |
+
row0 = dict(zip(pooled[0].indices.tolist(), pooled[0].values.tolist(), strict=True))
|
| 36 |
+
row1 = dict(zip(pooled[1].indices.tolist(), pooled[1].values.tolist(), strict=True))
|
| 37 |
+
assert row0 == {1: 4.0, 2: 5.0, 3: 3.0, 4: 1.0}
|
| 38 |
+
assert row1 == {5: 2.0, 7: 7.0, 8: 6.0}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_balanced_candidate_score_rewards_selectivity_not_raw_scale() -> None:
|
| 42 |
+
target = np.array([30.0, 1000.0])
|
| 43 |
+
other = np.array([0.0, 950.0])
|
| 44 |
+
rate = np.array([1.0, 1.0])
|
| 45 |
+
score = balanced_candidate_score(target, other, rate)
|
| 46 |
+
assert score[0] > score[1]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_paired_specificity_uses_random_ensemble_mean_per_task() -> None:
|
| 50 |
+
frame = pd.DataFrame(
|
| 51 |
+
[
|
| 52 |
+
{'task_id': 'a', 'condition': 'sae_feature', 'target_mean_logprob_delta': 0.4},
|
| 53 |
+
{'task_id': 'a', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': 0.1},
|
| 54 |
+
{'task_id': 'a', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': -0.1},
|
| 55 |
+
{'task_id': 'b', 'condition': 'sae_feature', 'target_mean_logprob_delta': -0.2},
|
| 56 |
+
{'task_id': 'b', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': 0.05},
|
| 57 |
+
{'task_id': 'b', 'condition': 'random_norm_matched', 'target_mean_logprob_delta': -0.15},
|
| 58 |
+
]
|
| 59 |
+
)
|
| 60 |
+
result = _paired_specificity(
|
| 61 |
+
frame,
|
| 62 |
+
effect_column='target_mean_logprob_delta',
|
| 63 |
+
seed=7,
|
| 64 |
+
)
|
| 65 |
+
assert np.isclose(result['sae_abs_mean'], 0.3)
|
| 66 |
+
assert np.isclose(result['random_abs_mean'], 0.1)
|
| 67 |
+
assert np.isclose(result['specificity_ratio'], 3.0)
|
| 68 |
+
assert result['n_tasks'] == 2
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_safe_spearman_handles_small_samples() -> None:
|
| 72 |
+
small = _safe_spearman(pd.Series([1.0, 2.0]), pd.Series([2.0, 1.0]))
|
| 73 |
+
assert small['n'] == 2
|
| 74 |
+
assert np.isnan(small['rho'])
|
| 75 |
+
enough = _safe_spearman(pd.Series([1.0, 2.0, 3.0]), pd.Series([3.0, 2.0, 1.0]))
|
| 76 |
+
assert enough['n'] == 3
|
| 77 |
+
assert np.isclose(enough['rho'], -1.0)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_offline_study_reports_missing_and_complete(tmp_path: Path) -> None:
|
| 81 |
+
study = OfflineStudy(tmp_path)
|
| 82 |
+
assert not study.complete
|
| 83 |
+
assert 'not materialized yet' in study.overview_markdown()
|
| 84 |
+
|
| 85 |
+
for name in OfflineStudy.REQUIRED:
|
| 86 |
+
path = tmp_path / name
|
| 87 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
if name.endswith('.json'):
|
| 89 |
+
if name == 'study_summary.json':
|
| 90 |
+
payload = {
|
| 91 |
+
'median_selected_feature_resample_support': 0.8,
|
| 92 |
+
'correlations': {
|
| 93 |
+
'heldout_auroc_vs_target_specificity': {'rho': -0.2, 'n': 7},
|
| 94 |
+
'heldout_auroc_vs_js_specificity': {'rho': 0.4, 'n': 7},
|
| 95 |
+
},
|
| 96 |
+
}
|
| 97 |
+
else:
|
| 98 |
+
payload = {'headline': 'Synthetic headline.', 'interpretation': 'Synthetic interpretation.'}
|
| 99 |
+
path.write_text(json.dumps(payload), encoding='utf-8')
|
| 100 |
+
elif name.endswith('.csv'):
|
| 101 |
+
path.write_text('x\n1\n', encoding='utf-8')
|
| 102 |
+
else:
|
| 103 |
+
path.write_text('# report\n', encoding='utf-8')
|
| 104 |
+
|
| 105 |
+
study = OfflineStudy(tmp_path)
|
| 106 |
+
assert study.complete
|
| 107 |
+
text = study.overview_markdown()
|
| 108 |
+
assert 'Synthetic headline.' in text
|
| 109 |
+
assert '80.0%' in text
|