feat(sparse_probing): add leakage-safe k-sparse probing over activation tensors - #1774
janmenjayap wants to merge 4 commits into
Conversation
Add fit_sparse_probe: a stratified train/test split is computed before any learned statistic, features are selected by train-only mean difference, and a CPU-float64 LBFGS logistic fit reports explicit objective/gradient-norm convergence diagnostics instead of assuming success. Report held-out accuracy, precision, recall, and F1 so callers can judge decodability without the result implying causal model use, neuron monosemanticity, or superposition. Cover exact score/index selection, leakage isolation, deterministic ties/RNG, optimizer convergence and failure, constant columns, and invalid-input rejection. Sweep, controls, exports, and docs land in a follow-up commit.
Add sweep_sparse_probe: fits a shared-split k-grid and reports raw random-coordinate and shuffled-training-label control distributions alongside each k, so callers can judge coordinate concentration without the result implying an automatic significance test. Export fit_sparse_probe, sweep_sparse_probe, and the result dataclasses from transformer_lens.tools.analysis, and add the sparse_probing guide to the docs toctree, documenting the leakage-safe contract and a run_with_cache composition example.
fit_sparse_probe accepts feature matrices on any device, including MPS, where float64 is unsupported. Add a regression test that fits a probe over MPS-resident features and asserts the selected train/test tensors land on CPU with float64 dtype instead of raising when the float64 cast is attempted while still on the Metal device.
…type policy Rebasing onto origin/dev pulled in jaxtyping>=0.3 (TransformerLensOrg#1732), which re-raises type-check violations as jaxtyping.TypeCheckError instead of letting BeartypeCallHintParamViolation propagate, and now classifies float8_e4m3fn as a valid Float dtype at the annotation level. Use the project's tests/typecheck_errors.TYPECHECK_ERRORS convention for the annotation-level cases, and move the float8_e4m3fn case to the explicit ValueError dtype-rejection table, where the function's own dtype guard now catches it.
jlarson4
left a comment
There was a problem hiding this comment.
Thanks for putting this together @janmenjayap. A few comments below:
| gradient_inf_norm = float(gradient.abs().max().item()) | ||
| if not math.isfinite(objective) or not math.isfinite(gradient_inf_norm): | ||
| raise RuntimeError("sparse probe optimizer produced non-finite output") | ||
| if gradient_inf_norm > gradient_tolerance: |
There was a problem hiding this comment.
The objective is mean-reduced and scale-invariant, but its gradient grows with |X|, so the absolute 1e-7 default rejects fits that match a float64 Newton solve to a few ulp: on n=1000, d=768 activations with per-coordinate std 60, every default fit_sparse_probe raises and sweep_sparse_probe dies on its first such fit. Because line 359 derives tolerance_change from gradient_tolerance**2, relaxing the tolerance also stops LBFGS earlier and max_iter changes nothing. Please make the acceptance test scale-relative (for example gradient_inf_norm <= gradient_tolerance * max(1.0, initial_gradient_inf_norm)), set tolerance_change=0.0, and add a guard test that fits the defaults on a matrix with a coordinate at std in the hundreds.
| l2_strength=validated.l2_strength, | ||
| test_fraction=validated.test_fraction, | ||
| seed=validated.seed, | ||
| k=validated.k, |
There was a problem hiding this comment.
sweep_sparse_probe validates once with k=ks[-1] (line 668), so this line stamps that value on every result: for ks=[1, 2, 4] all three read k=4 while their supports are sized 1, 2, 4, and any (r.k, r.metrics.f1) curve comes out flat in k. Please pass the loop's k into _fit_result explicitly and assert tuple(r.k for r in sweep.results) == sweep.ks in the sweep test.
|
|
||
| def test_stratification_preserves_each_class_and_reports_realized_counts(): | ||
| generator = torch.Generator().manual_seed(0) | ||
| features = torch.randn(100, 5, generator=generator) |
There was a problem hiding this comment.
This range check is the only direct assertion on a returned metric, and every other metric check compares two paths mutated the same way: scoring the training rows instead of the held-out rows, permuting the labels the control arms are scored against, ignoring class_weight, holding the split seed constant, swapping precision and recall, and moving the decision threshold to 0.5 each leave all 53 tests green. Please add a test that recomputes the confusion counts from features[result.test_indices], labels[result.test_indices] and the returned coefficients and intercept and asserts equality with result.metrics, plus a shuffle-arm check that the scored labels are labels[test_indices].
| The fit raises when output is non-finite or the final objective-gradient infinity norm exceeds | ||
| `gradient_tolerance`, which must lie in `(0, 1]`. | ||
| Results retain the requested `k`, `max_iter`, and `gradient_tolerance` alongside the realized | ||
| objective, gradient norm, iteration count, and convergence flag. |
There was a problem hiding this comment.
SparseProbeResult has objective, gradient_inf_norm, iterations and function_evaluations, and a fit that misses the threshold raises rather than returning a flag, so result.converged is an AttributeError. Please replace "and convergence flag" with "and function-evaluation count" and say that a fit missing the threshold raises.
|
|
||
| The example selects the final sequence position, which is not appropriate for every dataset. | ||
| Choose the hook and position policy before interpreting selected coordinates. The API cannot detect | ||
| leakage already introduced into caller-provided `features`. |
There was a problem hiding this comment.
The row-level split does not know that several positions of one prompt share a label, so the common way of building features puts positions of the same prompt on both sides: on label-independent grouped data it scores 0.78 held-out accuracy where a group-held-out split scores 0.50. Please add one sentence here saying rows that share a source prompt must not straddle the split.
Description
Adds
transformer_lens/tools/analysis/sparse_probing.py, a dependency-free, model-free module for fitting binary k sparse probes to a supplied activation matrixXand label vectory, following the method in Gurnee et al., "Finding Neurons in a Haystack: Case Studies with Sparse Probing" (TMLR 2023).TransformerLens already exposes activations via
run_with_cache, but has no maintained probing primitive: users currently rebuild splitting, feature selection, fitting, and control logic themselves, and it is easy to leak test information into that process. This PR adds a leakage-safe, dependency-free core for that workflow. Activation extraction, model wrappers, plotting, notebooks, multiclass probing, and optimal (MIP) selection are deliberately deferred to follow-up work.Fixes #1728
What's included
fit_sparse_probe(X, y, k, ...)— deterministic stratified train/test split computed before any learned statistic, train-only raw mean-difference feature selection, and a CPU-float64 Torch LBFGS balanced-logistic fit with explicit objective/gradient-norm convergence diagnostics (raises rather than silently returning an unconverged fit).sweep_sparse_probe(X, y, ks, ...)— reuses one fixed split across a k-grid and reports raw random-coordinate and shuffled-training-label control distributions alongside eachk, without assigning an automatic "significance" label.SparseProbeResult/SparseProbeSweepresult dataclasses reporting held-out accuracy, precision, recall, and F1 (F1 primary), selected indices/scores, coefficients/intercept, preprocessing metadata, split indices/class counts, and control distributions.jaxtyping>=0.3compatibility fix: the rebase ontoorigin/devpulled in CI Warnings Cleanup #1732, which now raisesjaxtyping.TypeCheckErrorinstead of lettingBeartypeCallHintParamViolationpropagate, and reclassifiesfloat8_e4m3fnas a validFloatdtype at the annotation level. Updatedtest_runtime_typecheck_rejects_invalid_tensor_contractsto usetests/typecheck_errors.TYPECHECK_ERRORS, and moved the float8 case to this module's ownValueErrordtype-rejection table, where it's now actually caught.transformer_lens.tools.analysis, plusdocs/source/content/sparse_probing.md(contracts, claim boundaries, and arun_with_cachecomposition example) linked fromdocs/source/index.md.Design decisions
Issue #1728 asked maintainers to weigh in on five open questions before implementation. This PR takes the issue's own recommended position on each:
sklearndependency), and reports final gradient/objective metadata, raising on non-convergence."none"(matching the paper's reference code); optional train-only"standardize"is documented as intentionally changing the L2 objective.[example, k]matrices move to CPU float64 for the deterministic LBFGS fit.Non-goals (tracked as follow-ups)
Activation extraction and position-reduction helpers, model wrappers/downloads, multiclass/one-vs-rest probing, plotting and notebooks, an optimal/MIP selector, feature batteries, SAE-latent composition, and causal validation are all explicitly out of scope for this PR.
Claim boundaries
A high held-out F1 means the labeled feature is linearly decodable from the supplied activations — it does not by itself establish that the model uses that feature, that a selected coordinate is monosemantic, or that a smooth k-sweep curve is evidence of superposition. The docs and result contracts call this out explicitly, and the module exposes no
.plot()method or automatic "significant" label.Test plan
uv run pytest tests/unit/tools/test_sparse_probing.py tests/mps/test_mps_basic.py -q— 68 passed.make test-pr(unit + docstring + acceptance + integration): unit 5,827 passed, docstring 18 passed, acceptance 209 passed, integration 1,459 passed / 1 failed. The one failure,test_granite_eager_scan_device_correctness[mps], is an unrelated Granite MoE Hybrid eager/fused-scan divergence — reproduced identically on a cleanorigin/devcheckout in an isolated worktree, confirming it's pre-existing on trunk and not introduced by this branch.make formatanduv run mypy .clean.docs/source/index.mdincludes the new guide exactly once; docs build succeeds.Type of change
Checklist: