Skip to content

perf(glm): make GLM fit faster - #442

Open
FelipeCybis wants to merge 11 commits into
mainfrom
feat/310-glm-n-jobs
Open

perf(glm): make GLM fit faster#442
FelipeCybis wants to merge 11 commits into
mainfrom
feat/310-glm-n-jobs

Conversation

@FelipeCybis

@FelipeCybis FelipeCybis commented Sep 1, 2026

Copy link
Copy Markdown
Member

In my machine, docs/examples/06_glm/01_first_level.py fits in about 55 seconds on this branch, against about 129 seconds on main. The larger part of the gain applies to the default noise_model="ar1". The z-maps are unchanged, except on voxels where rounding controls the value in both versions.

Closes #310.

The three changes

1. inv in place of pinv for the per-voxel normal equations

ARModel.fit inverts a (147k, 15, 15) stack. The pseudo-inverse is a batched SVD and costs about 6 times more than the LU inverse. The two results agree to 1e-14. pinv stays for a rank-deficient design, where inv returns a wrong answer without an error. The full-rank branch needs no guard of its own. Whitening multiplies the design by a unit lower triangular factor, so it preserves rank for every rho and XtX stays positive definite.

Before:

cov_beta = np.linalg.pinv(XtX)  # (V, K, K)

After:

if self.df_model == K:
    cov_beta = np.linalg.inv(XtX)  # (V, K, K)
else:
    cov_beta = np.linalg.pinv(XtX)  # (V, K, K)

2. No second pass over the OLS residuals

The residuals property returns self.Y - self.predicted, which allocates a second (n_volumes, n_voxels) array. OLS whitening is the identity, so whitened_residuals already holds those values.

Before:

ols_results = ols_model.fit(data_2d)
rho_per_voxel, _ = estimate_ar_coeffs(ols_results.residuals, order=ar_order)

After:

ols_results = ols_model.fit(data_2d)
ols_residuals = cast("npt.NDArray[np.floating]", ols_results.whitened_residuals)
rho_per_voxel, _ = estimate_ar_coeffs(ols_residuals, order=ar_order)

3. optimize=True on two numpy.einsum calls

The lagged cross-product goes to BLAS and runs about 30 times faster. The three-operand term contracts in pairs, in place of building a full (order, order, V, K, K) intermediate. The two-operand term between them is faster without the flag, so it keeps the default.

Before:

XtX = (
    A[np.newaxis]
    - np.einsum("iv,ikl->vkl", self.rho, B_sym)
    + np.einsum("iv,jv,ijkl->vkl", self.rho, self.rho, C)
)  # (V, K, K)
...
Q = np.einsum("itk,tv->ikv", L, whitened_Y)

After:

XtX = (
    A[np.newaxis]
    - np.einsum("iv,ikl->vkl", self.rho, B_sym)
    + np.einsum("iv,jv,ijkl->vkl", self.rho, self.rho, C, optimize=True)
)  # (V, K, K)
...
Q = np.einsum("itk,tv->ikv", L, whitened_Y, optimize=True)

Two smaller items in this PR

FirstLevelModel accepts show_progress, which shows a progress bar over the runs (defaults to False). The bar lives in confusius/_utils/progress.py, and decoding/searchlight.py now draws its own bar from that helper.

model = FirstLevelModel(noise_model="ar1", show_progress=True)

compute_contrast no longer prints a divide-by-zero RuntimeWarning. A recording that holds a voxel of no variance over time triggered this. An atlas grid leaves whole regions like that outside the recorded field of view.

Before:

return np.where(x == 0, 0, 1.0 / x)

After:

return np.divide(1.0, x, out=np.zeros_like(x), where=x != 0)

Fit the runs of a multi-run FirstLevelModel through joblib.Parallel
(loky workers), one run per task. Loky caps each worker's BLAS/OpenMP
thread pool at cpu_count() // n_jobs, so parallel runs do not
oversubscribe the CPU; n_jobs=1 keeps the sequential in-process path.
h5py-backed runs cannot be pickled to workers and raise TypeError when
n_jobs != 1, mirroring register_volumewise.

Closes #310
Replace the batched pseudoinverse of the per-voxel normal equations with a
batched inverse when the design is full rank. `pinv` runs an SVD per voxel and
dominated the fit; `inv` uses LU and agrees to ~1e-14. A rank-deficient design
still takes `pinv`, which is what produces the min-norm solution, and an
exactly singular batch falls back to it.

Contract the lagged cross-product and the three-operand XtX term with
`optimize=True` so they route through BLAS instead of einsum's own loop. The
remaining einsums in that block are no faster with it, and the two-operand XtX
term is slower, so they keep the default.

test_ar_degenerate_design only asserted degrees of freedom, so it passed
regardless of the coefficients returned. Replace it with a comparison against
an explicit per-voxel lstsq solve, parametrized over full-rank and collinear
designs so both inversion branches are covered.
Fitting runs in joblib worker processes made multi-run fits slower rather than
faster. joblib memmap-dumps every run to disk before any compute starts, and
loky caps each worker's BLAS pool at cpu_count() // n_jobs, so the single large
GEMM that the sequential path spreads over every core is split into narrow
ones instead. Measured on 20 CPUs with four 705 MB runs, n_jobs=4 took 27.3 s
against 7.6 s sequentially. Drop the parameter, its h5py pickling guard and
this module's joblib dependency, and fit the runs in a plain loop.

Add show_progress in its place, which is the part of the joblib path worth
keeping. joblib_progress advances its bar by patching
Parallel.print_progress, so it cannot tick without a Parallel to patch; add
_utils/progress.py exposing the same rich columns for loops driven by hand.
decoding/searchlight.py hand-rolls this same bar and can move onto it later.

Feed estimate_ar_coeffs the OLS whitened residuals instead of the `residuals`
property. OLS whitening is the identity, so the property recomputed
`Y - design @ theta` and allocated a second (n_volumes, n_voxels) array
holding values the fit had already stored.
With minimize_memory=True, which is the default, the fit built a whitened
residual array of (n_volumes, n_voxels), summed its squares, and then dropped
it again in _strip_heavy_fields. Since beta solves the normal equations, the
same sum is `||wY||² - beta·XtY`, computed from quantities the fit already
holds.

Add keep_residuals to OLSModel.fit and ARModel.fit so the caller says whether
the diagnostic arrays are wanted, and let RegressionResults take
whitened_residuals=None. sse/mse/residuals raise in that case with the message
they already raise after stripping. The first OLS pass of an AR fit always
keeps its residuals, since the AR coefficients are estimated from them.

Accumulate every sum of squares in float64 through _sum_of_squares. The
normal-equations form is a difference of two large, close sums, so a float32
accumulation on float32 recordings loses about five significant digits; it is
also faster than np.sum(a**2, axis=0), which materializes the squared array.

Measured back to back on one 600 x 147k run: ARModel.fit 3.83 s -> 2.29 s and
OLSModel.fit 0.88 s -> 0.59 s. Agreement with the explicit residual path is
within 8e-12 relative, worst case, at R² = 0.995.
…quations

Reverts 1b84795. The identity `rss = ||wY||² - beta·XtY` holds in exact
arithmetic, but atlas-resampled recordings carry large constant regions outside
the recorded field of view which the design's constant regressor explains
completely. There the two terms are equal, so the sign of their difference is
pure rounding: on the five-run example in docs/examples/06_glm, 448195 of
1203840 voxels came out with a negative residual sum of squares, hence a
negative dispersion and NaN across 39% of the contrast map.

It was buying 1.7 s of a 51 s fit, so the shortcut is not worth a guard. The
remaining changes on this branch already take that example from 129 s to 51 s.

Keep a regression test with half its voxels set to exact linear combinations of
the design, scaled so the cancellation dominates the true residual. Constant
voxels alone are not enough: with a single degenerate voxel the sign of the
difference is a coin flip, and the first version of this test passed against
the very code it was meant to reject.
`_positive_reciprocal` guarded against dividing by zero with `np.where`,
which selects a branch only after both have been evaluated, so the
reciprocal was still taken over the zeros the guard exists to discard.
Any recording carrying voxels the design explains exactly -- an atlas
grid leaves whole constant regions outside the recorded field of view --
printed a spurious divide-by-zero RuntimeWarning from every contrast.

`np.divide` with `where=` skips those elements instead. The returned
values are unchanged.
A progress bar on stdout is a surprise for a library call inside a
script or a notebook, so the default is now False.
…d helper

The column list was duplicated between searchlight and the GLM. The
searchlight keeps its own comment on why the bar is advanced by hand:
the inner cross_val_score builds a Parallel of its own, so joblib_progress
would count its folds against this bar.
@FelipeCybis FelipeCybis added bug Something isn't working enhancement New feature or request labels Sep 1, 2026
@FelipeCybis FelipeCybis self-assigned this Sep 1, 2026
@FelipeCybis FelipeCybis changed the title perf(glm): make FirstLevelModel.fit faster perf(glm): make GLM fit faster Sep 1, 2026
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

AR whitening left-multiplies the design by I - sum_i rho[i]*S^(i+1),
which is unit lower triangular and therefore invertible for every rho.
A full-rank design keeps every per-voxel XtX positive definite, so the
batched inv cannot raise and the pinv fallback behind it was dead code.

Extend the lstsq comparison with a rho far outside the stationary range,
which is the case the fallback was guarding.
@FelipeCybis

FelipeCybis commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

@sdiebolt this should be good to review (#445 to be merged before this one)

@FelipeCybis
FelipeCybis requested a review from sdiebolt September 1, 2026 15:37
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📖 Doc preview: https://confusius.tools/pr-preview/pr-442/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GLM fit with several runs should be done in parallel

1 participant