RFC: stop cleaning the design matrix inside fit(); warn on rank deficiency - #470
RFC: stop cleaning the design matrix inside fit(); warn on rank deficiency#470ljchang wants to merge 2 commits into
Conversation
…iency
BREAKING: `BrainData.fit()` no longer accepts `design_clean`,
`design_clean_thresh`, `design_clean_exclude_confounds`, or
`design_clean_fill_na`, and no longer runs `DesignMatrix.clean()` implicitly.
It estimates exactly the design it is given. Callers who want columns dropped
call `DesignMatrix.clean()` explicitly. `fit()` now emits a UserWarning when
the design matrix is rank deficient.
fit(model='glm') defaulted to running DesignMatrix.clean() on X, dropping any
column correlating >= 0.95 with an earlier one. Three problems:
1. The criterion is a correlation heuristic, not a rank test, so it dropped
columns from designs that were perfectly estimable. A real dartbrains
first-level design lost poly_1 and poly_2 at rank 48 of 48 — full rank.
2. It is order-dependent. clean() keeps the first of a correlated pair, so
which regressor survives depends on the order the design was built in:
add_dct_basis().add_poly() -> drops poly_1, poly_2
add_poly().add_dct_basis() -> drops cosine_1, cosine_2
Same regressors, same data, two different fitted models.
3. It was silent. The only reporting was behind progress_bar, which fit()
leaves as None, so the estimated model differed from the specified one with
no indication. Downstream this surfaces as a confusing
"Contrast vector length (48) must match number of regressors (46)".
Dropping regressors changes the model, so it is the caller's decision.
DesignMatrix.clean() already exists as the explicit, discoverable way to do it.
Meanwhile the genuinely dangerous case passed silently in both modes: with
cleaning disabled, a singular design fits via pseudo-inverse and splits the
effect evenly across dependent columns, returning finite betas that are not
uniquely determined. fit() now checks the rank and warns with the rank, the
column count, the column names, and the suggested next step (.vif() / .clean()).
It warns rather than raises because over-parameterized designs can still have
estimable contrasts.
Tests written red-first: fit keeps every column of a full-rank design even at
r=0.99; the removed kwargs raise TypeError; a singular design warns with an
actionable message; a well-formed design does not warn.
API docs not regenerated here — docs/api is already drifted from master and
`poe docs-generate` is a separate step.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVDnsKutg6Yqv99dYmUeuP
…letion
The warning previously recommended `DesignMatrix.clean()` as the fix, which
recommends the weaker tool. Dropping a column does not make its variance
disappear — it reassigns it to whichever correlated column survived, and which
one survives depends on the order the design was built in. Ridge has a unique
solution even when X'X is singular, since (X'X + alpha*I) is always invertible,
and that solution is invariant to column order.
Measured on a design with two regressors at r=0.99:
ridge swap the collinear columns -> weights swap exactly
(equal to float32 solver precision, ~1e-6)
clean() order [a, b, c] keeps ['a', 'c']
order [b, a, c] keeps ['b', 'c'] <- different model
The warning now leads with `fit(model='ridge')`, suggests inspecting with
`.vif()` first, and still mentions `.clean()` as an option while naming its
order-dependence.
Adds test_ridge_is_order_invariant_where_clean_is_not, which pins the property
the recommendation rests on, and extends the warning-content test to assert the
guidance names ridge and vif so it can't silently regress.
The migration guide gains a "Prefer regularization to dropping columns" section
with the worked contrast, plus the caveat that regularization fixes the
estimation problem and not the identifiability one: for exactly collinear
regressors no method separates their individual contributions, so ridge buys a
stable answer rather than a recovered one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVDnsKutg6Yqv99dYmUeuP
Update: the warning now recommends regularization, not deletionReview feedback (@ljchang) — regularization should be encouraged over The argument in one line: ridge has a unique solution even when Measured on a design with two regressors at r=0.99:
That property is now pinned by New warning text: One caveat I made explicit in the migration guide rather than overselling ridge: regularization fixes the estimation problem, not the identifiability one. For exactly collinear regressors, no method can separate their individual contributions — that information isn't in the data. Ridge buys a stable, reproducible answer in place of an arbitrary one; it doesn't recover something that was never measured. Worth saying out loud so nobody reads the warning as "add ridge and the collinearity is handled." This also sharpens open question 1 above. If regularization is the recommended path out of rank deficiency, warn-don't-raise looks more clearly correct: the warning now names a concrete fix that keeps the full model, rather than telling people to delete regressors. Suites re-run: |
Added:
|
fit(design_clean=True) (removed) |
find_spikes(clean=True) (added) |
|
|---|---|---|
| what it drops | regressors the user chose | columns the function itself just created |
| are they identical? | no — merely correlated at ≥ 0.95 | yes — bitwise identical |
| is the choice determined? | no — depends on column order | yes — nothing distinguishes the duplicates |
| information lost | real, and reassigned to a survivor | none |
Dropping a regressor that correlates 0.95 with another is a modeling decision and belongs to the caller. Refusing to emit the same regressor twice is just not producing garbage.
Tests: no duplicates by default; full rank by default; clean=False preserves every detection; global_spike wins the tie-break; distinct TRs never merged; confound marking survives.
Suites: nltools/tests/stats + nltools/tests/data/braindata — 505 passed, poe lint clean.
One unrelated pre-existing failure noticed
TestFindSpikes::test_find_spikes_brain_data fails on unmodified origin/master (verified by checking out master's copy of both files). When no spikes are found, the empty-frame branch does pl.DataFrame({"_no_spikes": [0]*n}).drop("_no_spikes"), and dropping the only column loses the height — so the returned DM is (0, 0) rather than (n_tr, 0) and dm.shape[0] == len(data) fails.
Polars can't express "n rows, 0 columns", so a real fix needs DesignMatrix to carry an explicit height. I left it alone rather than widen this PR — happy to file it separately.
Also fixed: no-spike subjects no longer break the design buildPicked up the pre-existing failure I flagged rather than filing it separately (@ljchang). Pushed in 5f35067. It turned out not to be cosmetic. Finding no spikes is a perfectly normal outcome, but it took down the entire first-level design for that subject: spikes = bold.find_spikes(...) # clean subject, nothing detected
task.append(spikes, axis=1)
# ValueError: All Design Matrices must have the same number of rows!In a group loop, one well-behaved participant crashes the pipeline — and the error says nothing about spikes. CausePolars derives a frame's height from its columns, so a frame with no columns always reports 0 rows. pl.DataFrame({"_no_spikes": [0] * len(global_mn)}).drop("_no_spikes")Dropping the only column discards the height, so the result was FixA design matrix with no regressors still describes a specific number of timepoints, so that length has to be carried explicitly:
Belt and braces on purpose: the Result
Suites: This PR now carries four related changes, all circling the same theme of designs being silently malformed:
Happy to split (3) and (4) into their own PR if you'd rather review the |
5f35067 to
10da6d2
Compare
Split: the
|
|
@ejolly. I feel pretty strongly that we should never have a default enabled that automatically drops regressors in our .fit(). I do not think the clean kwargs belong in the fit. it can be a useful function for a design matrix, but not for model fitting. I think this will create way more unintentional problems than protecting people from rare issues. The ordering issue is a problem where different regressors will be dropped based on the order in which they are run. not true for regularization. Here is what i propose and have started the PRs for:
|
|
Lets make a loud warning and provide possible solutions and suggestions:
|
|
Superseded by #483, which merges this branch as-is (all |
Opening this as an RFC — the diff is one concrete proposal, but the design question is worth discussing before merging. Alternatives and open questions are at the bottom; I'm happy to dial this back to a narrower change.
The problem
fit(model='glm')runsDesignMatrix.clean()onXby default, dropping any column that correlates>= 0.95with an earlier one. I hit this porting the dartbrains course to 0.6, where it surfaced as a baffling error:Digging in turned up three separable issues.
1. It is a correlation heuristic, not a rank test. On a real first-level design from the course:
The design was full rank and perfectly estimable. The docstring justifies cleaning in terms of rank deficiency, but that is not the condition being tested.
2. It is order-dependent.
clean()keeps the first column of a correlated pair and drops the second, so which regressor survives depends on the order the design happened to be built in:Same regressors, same data, two different fitted models. With
design_clean_exclude_confounds=Falseas the default, regressors of interest are eligible too — not just nuisance.3. It is silent. The only reporting is behind
progress_bar, andfit()passesprogress_bar=None. So the estimated model differs from the specified one with no indication.The other half: the genuinely dangerous case was silent too
While testing
design_clean=FalseI found the reverse gap. A truly singular design fits without complaint:nilearn falls back to a pseudo-inverse and splits the effect across the dependent columns. The betas come back finite and plausible-looking, and any contrast touching that subspace is uninterpretable. So the status quo is unsafe in both directions: it intervenes where it shouldn't, and stays quiet where it should speak up.
What this PR does
design_clean,design_clean_thresh,design_clean_exclude_confounds,design_clean_fill_nafromfit(). It estimates exactly the design it is given.DesignMatrix.clean()already exists as the explicit, discoverable way to drop columns — having an explicit method and a silent implicit call with different defaults is the confusing part.Net effect:
fit()stops making modeling decisions for you, and starts telling you when your model is actually broken.Open questions — input wanted
Warn or raise on rank deficiency? I chose warn, because over-parameterized designs can still have estimable contrasts, and raising would break pipelines currently relying (silently) on the pseudo-inverse. Raising is a one-line change if you'd rather fail hard. There's also a middle option: raise only when the deficiency is exact duplication, warn on near-singularity.
Full removal, or keep the flag with
default=False? This PR removes the kwargs entirely. Keepingdesign_clean=Falseas an opt-in is less disruptive and preserves a convenience path — but it also keeps two ways to do the same thing.Rank tolerance. I use
np.linalg.matrix_rankdefaults. fMRI designs with DCT bases can be numerically borderline; a condition-number check might be more informative than a hard rank threshold. I did not want to invent a threshold without input.Should
BrainCollection.fit()warn too? It doesn't currently take these kwargs and routes through a different path — worth confirming the per-subject designs get the same diagnostic, since that's where an unnoticed rank problem would be most costly.Is anything relying on the implicit cleaning? Only one test did (
test_datasets.pypasseddesign_clean=Falseto defeat it); it now just callsfit(). If real pipelines depend on it, the deprecation path matters more than I've assumed.Testing
Red-first. The four new tests fail on
master:test_fit_estimates_every_column_given— a full-rank design with two regressors at r=0.99 keeps all three columnstest_design_clean_kwargs_are_rejected— all four removed kwargs raiseTypeErrortest_rank_deficient_design_warns/..._names_the_columns— singular design warns, actionablytest_full_rank_design_does_not_warn— no spurious warningsSuites:
nltools/tests/data/braindata,nltools/tests/models,nltools/tests/data/collection— 742 passed.nltools/tests/support— 88 passed.uv run poe lintclean.test_ridge.py::TestRidgeCore::test_vs_sklearnfails, but it also fails on unmodifiedorigin/master— pre-existing and unrelated.Migration guide updated. API docs not regenerated:
docs/apihas pre-existing drift from #465 andpoe docs-generateis a separate step, so including it would have mixed ~390 unrelated lines into this diff.🤖 Generated with Claude Code
https://claude.ai/code/session_01YVDnsKutg6Yqv99dYmUeuP