Skip to content

refactor: Compute the sklearn Ridge decoder in factorized form - #7

Open
KenyaOtsuka wants to merge 2 commits into
KamitaniLab:devfrom
KenyaOtsuka:refactor/factorized-sklearn-ridge-decoder
Open

KenyaOtsuka wants to merge 2 commits into
KamitaniLab:devfrom
KenyaOtsuka:refactor/factorized-sklearn-ridge-decoder

Conversation

@KenyaOtsuka

@KenyaOtsuka KenyaOtsuka commented Sep 9, 2026 •

Copy link
Copy Markdown

Why

The sklearn Ridge decoder stores coefficients that scale with the DNN feature
dimension, making DeepRecon-scale decoders very large and expensive to train.

This PR factorizes the same Ridge estimator as

brain → coefficients over training stimuli → decoded feature

Repeated fMRI trials are still used individually; only the target representation
changes.

What

  • Fit and store one Ridge model per (subject, ROI).
  • Store an M × n_voxels model instead of coefficients that scale with the DNN
    feature dimension.
  • Store the factorized model, ordered training-stimulus labels, format/version,
    and fit metadata together in a single model.pkl.gz artifact.
  • Reconstruct features at prediction time from the predicted stimulus
    coefficients and the training features.
  • Training no longer reads DNN features.
  • Previously trained sklearn Ridge decoders remain usable through the legacy
    prediction path.
  • evaluation.py and the decoded-feature output format are unchanged.

Compatibility / behavior changes

  • Factorized prediction requires the training feature directory.
  • Constant feature units now decode instead of failing on zero variance.
  • The training Python API no longer takes feature-side arguments.

Test

101 tests pass on Python 3.10 and 3.11, including direct-vs-factorized
equivalence, regression against the previous implementation, legacy decoder
compatibility, and the unchanged evaluation pipeline.

Benchmark results and scripts:
https://github.com/KenyaOtsuka/feature-decoding/tree/bench/pr7-benchmarks

Reviewer details

For training targets Y = R F, Ridge is affine in the target, so

Ridge(alpha).fit(X, R @ F).predict(X_test)
==
Ridge(alpha).fit(X, R).predict(X_test) @ F

including the intercept. The existing feature normalization also cancels
exactly. The full derivation is in ridge_factorization.py.

The factorized model is stored once at

<decoder>/<subject>/<roi>/model/

while the existing per-layer y_mean.mat / y_norm.mat files remain under

<decoder>/<layer>/<subject>/<roi>/model/

for evaluation.py.

The fitted estimator and the ordered training-stimulus labels are stored
together in model.pkl.gz. At prediction time, the training features are loaded
in that recorded order, and their row count is checked against the predicted
coefficient dimension before they are combined.

Legacy decoders are detected automatically and use the previous prediction
path.

@KenyaOtsuka
KenyaOtsuka force-pushed the refactor/factorized-sklearn-ridge-decoder branch 4 times, most recently from b3a795d to 8c47da0 Compare September 10, 2026 05:35
@KenyaOtsuka

KenyaOtsuka commented Sep 10, 2026 •

Copy link
Copy Markdown
Author

Benchmarks

I measured a complete train → predict run for the legacy and factorized
decoders. Training and prediction were run in separate processes, with a cold
page cache. Full scripts, methodology, and results are available on
bench/pr7-benchmarks.

For the shipped fc6 / fc7 / fc8 configuration (1200 training stimuli ×
5 repetitions, 10,000 voxels, 2 subjects × 2 ROIs):

legacy factorized change
training time 248.5 s 57.9 s 4.3× faster
prediction time 10.3 s 11.3 s about the same
total time 258.8 s 69.2 s 3.7× faster
peak memory (training) 2.9 GB 3.0 GB about the same
peak memory (prediction) 362 MB 290 MB 1.2× lower
total bytes read 2.6 GB 1.6 GB 1.7× less
total bytes written 1.4 GB 194 MB 7.3× less
decoder size 1.4 GB 184 MB 7.7× smaller

The difference becomes much larger for convolutional features. At full
conv5_1 size (100,352 units, 6000 trials, 10,000 voxels), legacy training was
OOM-killed on a 15.7 GB machine. The factorized pipeline completed on the
same machine: training took 14.0 s with 2.5 GB peak RSS, prediction took
12.5 s with 1.1 GB peak RSS, and the decoder was 45.9 MB.

For a scaled conv5_1 run where both variants fit in memory, total time was
51.0 → 9.6 s (5.3× faster); prediction itself was also slightly faster
(6.6 → 5.2 s), while training peak memory fell from 3.7 GB to 781 MB.

At full DeepRecon scale, the feature/model/output I/O implied by the production
access pattern is 51.7 TB for the legacy implementation vs 236 GB for the
factorized one
(arithmetic from array sizes, not a timing measurement).

The benchmark also verifies that both implementations produce matching decoded
features (rtol=1e-4) and matching normalization statistics.

Note: these benchmarks were run on the original factorized implementation preserved in bench/pr7-benchmarks. The subsequent consolidation of the factorized decoder into a single model.pkl.gz artifact does not change the factorization or feature-loading strategy measured here.

@KenyaOtsuka
KenyaOtsuka force-pushed the refactor/factorized-sklearn-ridge-decoder branch 2 times, most recently from 8d2c06b to cfdc71e Compare September 30, 2026 04:53
The decoder regressed brain activity directly onto DNN features and
pickled the fitted Ridge, so the stored coefficient matrix was
d_out x n_voxels: ~120 GB for one VGG19 conv1_1 decoder of one
(subject, ROI) at DeepRecon scale, and one Ridge solve per layer and per
feature chunk.

None of that is intrinsic. Each stimulus is presented in several trials,
so the training target is not one independent vector per trial but the
features of the M unique stimuli, repeated: Y = R F with R the one-hot
trial-to-stimulus assignment. Ridge prediction is linear in the target
and R has unit row sums, so

  Ridge(a).fit(X, R F).predict(Xt) == Ridge(a).fit(X, R).predict(Xt) @ F

exactly, including the intercept, and the feature mean/SD round trip
cancels rather than being dropped. The full derivation is in the module
docstring of ridge_factorization.py; tests/test_ridge_factorization.py
pins it in float64 at rtol=1e-9 across alphas, repetition patterns,
solvers and chunk axes.

So the decoder is trained and stored as brain -> training stimulus
basis, and prediction combines the coefficients with F. The stored model
is M x n_voxels, it no longer depends on the feature layer, and the
repeated trials are *not* averaged: all N trials enter the fit exactly
as before, only the target is re-expressed.

Artifact layout. The model is the same for every layer, so it is stored
once per (subject, ROI); the training feature statistics do depend on
the layer, and evaluation.py reads them from where it always has:

  <decoder>/<subject>/<roi>/model/          model.pkl.gz, x_mean.mat,
                                            x_norm.mat, info.yaml
  <decoder>/<layer>/<subject>/<roi>/model/  y_mean.mat, y_norm.mat

model.pkl.gz is the decoder, whole: the fitted estimator, the ordered
training labels its coefficient columns are indexed by, the format name
and version, and the provenance of the fit, in one pickle. bdpy's
ModelTest reads only 'model' and 'y_shape' from such a file and ignores
the rest, so the extra keys ride along at no cost -- and there is no
second file that could disagree with the coefficients.

y_mean/y_norm are compatibility artifacts for the evaluation pipeline,
not parameters of the model -- prediction never reads them, because the
normalization cancels. Training cannot produce them (it opens no feature
file), so predict_feature.py writes them on its first run and never
overwrites what is there: a decoder directory carries the statistics of
one training feature configuration, and another feature set or feature
index is another decoder directory.

The fit is on R, built from the trial labels, so training takes no
feature-side argument at all: no feature paths, no feature index, no
chunk_axis (which only blocks the coefficient-feature product in memory
at prediction), not even the layer names. Over a DeepRecon run that
removes ~3 TB of reads that only ever recomputed identical statistics.

What the artifact does pin is the correspondence the coefficients need:

  coefficient column i is multiplied by the feature row of stimulus i

The ordered labels sit in the same pickle as the coefficients they
index, so the two cannot be reordered, renamed or replaced against each
other. What is left is the assembly of F: TrainingFeatureLoader.get
requires exactly one feature row from exactly one store per requested
label (bdpy's get_multi_features appends a row for every store holding a
label and skips labels no store holds, so a duplicate and a missing
label cancel out in the total row count while every row after them is
shifted), and check_column_correspondence compares the widths at the
contraction.

Prediction resolves the decoder by looking at the shared directory
first: a factorized decoder's per-layer directory holds nothing but the
statistics sidecars, so from the outside it looks exactly like a legacy
decoder directory. A model.pkl.gz on the shared path is the
discriminator -- the direct implementation stores its model per layer,
never there -- and the format tag inside it is checked when it is
loaded; decoders written by the direct implementation keep their layout
and their previous code path, unchanged.

Deliberate behavior change: a feature unit that is constant across the
training stimuli made the direct implementation fail (0/0 = NaN targets
that ModelTraining's inf clean-up does not catch, which scikit-learn
rejects) and now decodes exactly, since C·1 = 1. That is a bug fix, not
a consequence of the refactor, and tests/test_zero_variance_features.py
pins both behaviors side by side.

Also fixes feature_index_file, which could not run at all: the feature
stores resolved the index inside the feature directory while the copy
into the decoder resolved it against the current directory. It is now
prediction-side unit selection, resolved once through
resolve_feature_index_path and copied next to the decoded features. The
same unresolved-path bug remains in train_decoder_fastl2lir.py,
predict_feature_fastl2lir.py, evaluation.py and the two cv_* scripts,
left alone here.

No bdpy changes and no change to evaluation.py or cv_evaluation.py. The
baseline the factorized output is checked against is the reference
implementation in tests/helpers/legacy_ridge.py, which
tests/test_legacy_reference.py pins against the pre-factorization
scripts. The benchmarks that produced the numbers quoted in the pull
request are kept outside the repository.
"Decoding with generic regression models" was never generic: the only
training script in that section is train_decoder_sklearn_ridge.py, and
after the factorization it is Ridge by construction, right above a
paragraph that says so. The heading now names the estimator, like the
PyFastL2LiR section above it.

The cross-validation section called cv_train_decoder_fastl2lir.py an
example for scikit-learn Ridge regression; it imports and fits
FastL2LiR. That parenthetical and the equivalent one on the sklearn
Ridge trainer are dropped, matching the PyFastL2LiR bullets, which
carry none.

predict_feature.py is left described as it was: it is not Ridge-specific
-- its legacy path predicts any estimator stored in bdpy's pickle
layout -- so only the claim about the pipeline is corrected.
@KenyaOtsuka
KenyaOtsuka force-pushed the refactor/factorized-sklearn-ridge-decoder branch from cfdc71e to 884f330 Compare September 30, 2026 06:00
@KenyaOtsuka
KenyaOtsuka marked this pull request as ready for review September 30, 2026 07:13

@HirokiYasuda03 HirokiYasuda03 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!
I left some comment.

Comment thread predict_feature.py
'decoder.features.paths of the config used for training).'
% model_dir)

makedir_ifnot(results_dir_prediction)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although this is the problem from previous version, in the factorized path, the layer's training features are loaded after the output directory is created and the lock is taken. If the load fails (OOM on a large layer, a missing label, a wrong feature index), the script exits but leaves an empty decoded_features/<layer>/<sbj>/<roi>/ behind. The next run then treats it as "already done" and skips it, so the gap only shows up at evaluation.

Could we either load the training features before makedir_ifnot, or remove the directory (and release the lock) on failure?

Comment thread ridge_factorization.py
% (label, row.shape[0], layer))
rows.append(row)

features = np.vstack(rows)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Peak memory here is ~2x the layer size: the per-label rows list and the stacked array exist at the same time. When the statistics are missing, np.std adds another full-size temporary, so the peak is ~3x. For large layers (~15 GB per layer per the docstring) that is ~45 GB at prediction time, which the legacy predictor never needed.

Suggestions:

  • Preallocate the output array and fill it row by row.
  • Compute mean/std in unit (column) blocks, similar to the blocked approach in bdpy#145.

That would keep the peak close to 1x the layer size.

Comment on lines 207 to -137
feat_labels = np.unique(brain_labels)
feat = get_multi_features(data_features, layer, labels=feat_labels)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Training no longer touches the feature store, so a training label without features (e.g. a blank/catch trial) is only detected at prediction time. By then the decoder is marked done and has to be retrained.

The old code had no explicit check either: it failed by accident with an IndexError from feat_index. The new loader's error is much clearer. Could we run the same label-only existence check here, so the failure happens before training rather than after?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants