feat(sparse_probing): add leakage-safe k-sparse probing over activation tensors - #1774
janmenjayap wants to merge 9 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:
|
Thanks again for the careful read. Happy to adjust any of the wording or test shapes if you would prefer a different form. |
jlarson4
left a comment
There was a problem hiding this comment.
I appreciate you turning these around so quickly! The detailed replies are also great, having your thought process speeds up my reviewing process tremendously. The k metadata fix and the two new behavioural tests look great. I have a couple new comments below, this should hopefully be the last batch for this feature
| 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") | ||
| acceptance_threshold = gradient_tolerance * max(1.0, initial_gradient_inf_norm) |
There was a problem hiding this comment.
This is something I suggested in my initial review and I definitely need to adjust my recommendation here. Apologies for leading you in the wrong direction.
The gradient at the zero starting point measures label–feature correlation rather than feature magnitude, so the probe support deserves a larger threshold while the random-subset and label-shuffle controls should be smaller, and the smallest are pinned back to the bare absolute tolerance by the max(1.0, ...) floor. At activation scale a sweep still dies on a converged control fit, discarding every completed fit with it, even though every single fit_sparse_probe at the same scale now passes.
Can you please key the bound to the magnitude of the selected training columns instead, for example gradient_tolerance * max(1.0, k * max|X_train|) while leaving LBFGS's own tolerance_grad as it is, and add a sweep with both control arms at activation scale to the guard test?
| parameters = torch.zeros(features.shape[1] + 1, dtype=torch.float64, requires_grad=True) | ||
| optimizer = torch.optim.LBFGS( | ||
| [parameters], | ||
| max_iter=max_iter, |
There was a problem hiding this comment.
LBFGS is still told to stop at the absolute gradient_tolerance, which it cannot reach at scale, so it runs until max_iter or the evaluation budget binds, and the budget is never set here, so it is torch's undocumented max_iter * 5 // 4. The relative bound then accepts whatever the solver reached: at per-coordinate std in the thousands roughly half the accepted fits are such truncations, some of them far above the optimum in objective with coefficients well off the true ones even though their held-out metrics look fine, and a caller can only tell by comparing function_evaluations against a cap that appears nowhere in the signature or the guide. Please set max_eval explicitly and report the stop reason on the result, so a fit that ended on max_iter or the evaluation budget is distinguishable from one that met the tolerance.
| raise ValueError(f"seed must be an integer in [0, 2**63), got {seed!r}") | ||
| validated_max_iter = _positive_integer(max_iter, "max_iter") | ||
| validated_tolerance = _finite_positive_real(gradient_tolerance, "gradient_tolerance") | ||
| if validated_tolerance > 1: |
There was a problem hiding this comment.
At the top of the documented (0, 1] range the acceptance threshold is at least the initial gradient, which no fit can miss, and LBFGS's own tolerance_grad of 1.0 stops the solve before it takes a step whenever the initial gradient is at or below 1, so the call returns coefficients that are all zero and an objective of exactly ln 2, the untrained probe, as a successful fit. Please reject gradient_tolerance at or above 1 here, no value there can express a convergence requirement.
| result = fit_sparse_probe(features, labels, k=2, class_weight=None, seed=2) | ||
| sweep = sweep_sparse_probe(features, labels, ks=[1], class_weight=None, seed=2) | ||
|
|
||
| assert result.class_weight is None |
There was a problem hiding this comment.
This checks that the metadata round-trips, not that the weighting was applied, and the fixture is balanced, so balanced weights are all 1.0 and neither mode can be distinguished: forcing the weights to ones, and separately ignoring class_weight=None so the fit is always balanced, both leave every test green, while on an imbalanced fixture the two modes give visibly different accuracies and coefficients. Please fit an imbalanced fixture in both modes and assert the coefficient vectors differ, each against a Newton reference that takes the same sample weights.
| assert result.gradient_inf_norm <= 1e-7 | ||
|
|
||
|
|
||
| def test_metrics_match_an_independent_heldout_recompute_at_the_logit_zero_threshold(): |
There was a problem hiding this comment.
This recompute runs under the default preprocess="none", where preprocess_mean and preprocess_scale are identity, so standardizing the held-out rows with their own statistics instead of the training ones leaves every test green while changing the held-out metrics, which is train-test leakage in the module whose headline contract is leakage safety. Please run this recompute under preprocess="standardize" as well, applying the returned preprocess_mean and preprocess_scale to the raw held-out rows, so held-out rows standardized with their own statistics fail it.
| ) | ||
|
|
||
|
|
||
| def test_default_tolerance_accepts_large_scale_activations(): |
There was a problem hiding this comment.
This test's fixture converges to an achieved gradient roughly ten times under the plain absolute 1e-7, so the scale-relative bound is never exercised: reverting line 385 to the bare gradient_tolerance leaves every test green. Can we adjust to use a fixture whose accepted fit lands between the absolute and the relative thresholds? A per-coordinate scale in the thousands should give this without exhausting the evaluation budget. We will want to assert on the achieved gradient against the applied bound, so the test fails when the bound goes back to absolute.
| l2_strength: Positive coefficient penalty in the logistic objective. | ||
| seed: Local CPU-generator seed used only for the stratified split. | ||
| max_iter: Maximum LBFGS iterations. | ||
| gradient_tolerance: Required final objective-gradient infinity norm. |
There was a problem hiding this comment.
Both public docstrings (here and line 652) say gradient_tolerance is the "required final objective-gradient infinity norm", but since the fix the accepted quantity is that tolerance times max(1, initial gradient infinity norm), so the API reference now describes a bound the code does not apply while the guide describes the one it does. Please reword both to the scale-relative bound, in whatever form you settle on from my comment on line 385.
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: