Skip to content

Commit 177faf3

Browse files
authored
ENH Batch GeneralizingEstimator's estimator and scoring (#13909)
1 parent 41292ed commit 177faf3

8 files changed

Lines changed: 253 additions & 28 deletions

File tree

.circleci/config.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ jobs:
6262
if [[ $(cat merge.txt) != "" ]]; then
6363
echo "Merging $(cat merge.txt)";
6464
git pull --ff-only upstream "refs/pull/$(cat merge.txt)/merge";
65+
elif [[ "$CIRCLE_PROJECT_USERNAME" != "mne-tools" ]]; then
66+
echo "On CIRCLE_PROJECT_USERNAME=\"$CIRCLE_PROJECT_USERNAME\" repo rather than mne-tools, merging upstream/main."
67+
git merge upstream/main --no-edit || exit 1
6568
else
6669
if [[ "$CIRCLE_BRANCH" == "main" ]]; then
6770
KIND=dev

doc/changes/dev/13909.other.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Batch and vectorise classifier estimation and scoring in
2+
:meth:`mne.decoding.GeneralizingEstimator.score` for ``scoring=None``,
3+
``"accuracy"``, ``"balanced_accuracy"`` and ``"roc_auc"``, by
4+
:newcontrib:`Mathias Sablé-Meyer`.

doc/changes/names.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@
213213
.. _Martin Luessi: https://github.com/mluessi
214214
.. _Martin Oberg: https://github.com/obergmartin
215215
.. _Martin Schulz: https://github.com/marsipu
216+
.. _Mathias Sablé-Meyer: https://s-m.ac/
216217
.. _Mathieu Scheltienne: https://github.com/mscheltienne
217218
.. _Mathurin Massias: https://mathurinm.github.io/
218219
.. _Mats van Es: https://github.com/matsvanes

examples/decoding/decoding_time_generalization_conditions.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@
2828
from mne.datasets import sample
2929
from mne.decoding import GeneralizingEstimator
3030

31-
print(__doc__)
32-
3331
# Preprocess data
3432
data_path = sample.data_path()
3533
# Load and filter data, set up epochs

mne/decoding/search_light.py

Lines changed: 159 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66

77
import numpy as np
8+
from scipy.stats import rankdata
89
from sklearn.base import BaseEstimator, MetaEstimatorMixin, clone
910
from sklearn.metrics import check_scoring
1011
from sklearn.preprocessing import LabelEncoder
@@ -680,14 +681,15 @@ def _gl_transform(estimators, X, method, pb):
680681
681682
Returns
682683
-------
683-
Xt : array, shape (n_samples, n_slices)
684-
The transformed values generated by each estimator.
685-
"""
684+
Xt : array, shape (n_samples, n_estimators, n_slices[, n_classes])
685+
Predictions for each (estimator, slice) pair. The trailing axis is
686+
present when ``method`` returns multi-output (e.g. ``predict_proba``).
687+
""" # noqa: E501
686688
n_sample, n_iter = X.shape[0], X.shape[-1]
689+
# stack generalized data for faster prediction
690+
X_stack = X.transpose(np.r_[0, X.ndim - 1, range(1, X.ndim - 1)])
691+
X_stack = X_stack.reshape((n_sample * n_iter,) + X_stack.shape[2:])
687692
for ii, est in enumerate(estimators):
688-
# stack generalized data for faster prediction
689-
X_stack = X.transpose(np.r_[0, X.ndim - 1, range(1, X.ndim - 1)])
690-
X_stack = X_stack.reshape(np.r_[n_sample * n_iter, X_stack.shape[2:]])
691693
transform = getattr(est, method)
692694
_y_pred = transform(X_stack)
693695
# unstack generalizations
@@ -715,6 +717,100 @@ def _gl_init_pred(y_pred, X, n_train):
715717
return y_pred
716718

717719

720+
def _resolve_scoring_for_classifier(scoring, estimators):
721+
"""Promote scoring=None to 'accuracy' for default estimator.
722+
723+
scoring=None goes through sklearn's ``_PassthroughScorer``, which delegates
724+
to ``estimator.score(X, y)``. For a classifier that inherits
725+
``ClassifierMixin.score`` unchanged, that's accuracy — which we can batch.
726+
We compare ``type(est).score.__qualname__`` rather than ``.__name__``
727+
because the bare name is "score" regardless of the defining class. A bare
728+
method has qualname "ClassifierMixin.score"; any override resolves to
729+
"<Subclass>.score", which we leave untouched.
730+
"""
731+
if len(estimators) and getattr(scoring, "_score_func", None) is None:
732+
qname = getattr(type(estimators[0]).score, "__qualname__", "")
733+
if qname == "ClassifierMixin.score":
734+
scoring = check_scoring(estimators[0], "accuracy")
735+
return scoring
736+
737+
738+
def _detect_response_method(scoring):
739+
"""Return (response_method, can_batch) for ``scoring``.
740+
741+
If we can batch the estimator (one of predict, predict_proba,
742+
decision_function, or a tuple of those) then we return that, and
743+
can_batch is True if additionally _score_func is not None.
744+
Otherwise we return None (for the response_method) and False
745+
"""
746+
score_func = getattr(scoring, "_score_func", None)
747+
rm = getattr(scoring, "_response_method", None)
748+
valid = {"predict", "predict_proba", "decision_function"}
749+
if rm == "default":
750+
response_method = "predict"
751+
elif isinstance(rm, str) and rm in valid:
752+
response_method = rm
753+
elif isinstance(rm, tuple) and all(m in valid for m in rm):
754+
response_method = rm
755+
else:
756+
response_method = None
757+
can_batch = score_func is not None and response_method is not None
758+
return response_method, can_batch
759+
760+
761+
def _make_batched_score(score_func, response_method, method, y, sign, kwargs):
762+
"""Return a callable ``y_pred``->score per task, None if not recognised.
763+
764+
The returned callable expects ``y_pred`` of shape
765+
``(n_sample, n_train, n_iter)`` and returns shape ``(n_train, n_iter)``.
766+
Falls back to None for any scorer with non-default ``kwargs`` or
767+
multi-target ``y``, both of which require a slice-by-slice loop.
768+
"""
769+
if kwargs or y.ndim != 1:
770+
return None
771+
name = getattr(score_func, "__name__", "")
772+
773+
if name == "accuracy_score" and response_method == "predict":
774+
775+
def batched_score(y_pred):
776+
return sign * (y_pred == y[:, None, None]).mean(axis=0)
777+
778+
return batched_score
779+
780+
if name == "balanced_accuracy_score" and response_method == "predict":
781+
classes = np.unique(y)
782+
783+
def batched_score(y_pred):
784+
return sign * np.stack(
785+
[(y_pred[y == c] == c).mean(axis=0) for c in classes]
786+
).mean(axis=0)
787+
788+
return batched_score
789+
790+
if name == "roc_auc_score" and method in ("predict_proba", "decision_function"):
791+
classes = np.unique(y)
792+
if len(classes) != 2: # multi-class needs ovr/ovo; defer
793+
return None
794+
pos = y == classes[1]
795+
n_pos, n_neg = int(pos.sum()), int((~pos).sum())
796+
if not (n_pos and n_neg): # degenerate folds raise downstream in sklearn
797+
return None
798+
799+
def batched_score(y_pred):
800+
# Mann-Whitney U identity with average-rank tie correction.
801+
# Equivalent to sklearn's roc_auc within floating point precision,
802+
# but different computation.
803+
ranks = rankdata(y_pred, method="average", axis=0)
804+
return (
805+
sign
806+
* (ranks[pos].sum(axis=0) - n_pos * (n_pos + 1) / 2.0)
807+
/ (n_pos * n_neg)
808+
)
809+
810+
return batched_score
811+
return None
812+
813+
718814
def _gl_score(estimators, scoring, X, y, pb):
719815
"""Score GeneralizingEstimator in parallel.
720816
@@ -743,17 +839,63 @@ def _gl_score(estimators, scoring, X, y, pb):
743839
"""
744840
# FIXME: The level parallelization may be a bit high, and might be memory
745841
# consuming. Perhaps need to lower it down to the loop across X slices.
746-
score_shape = [len(estimators), X.shape[-1]]
747-
for jj in range(X.shape[-1]):
748-
for ii, est in enumerate(estimators):
749-
_score = scoring(est, X[..., jj], y)
750-
# Initialize array of predictions on the first score iteration
751-
if (ii == 0) and (jj == 0):
752-
dtype = type(_score)
753-
score = np.zeros(score_shape, dtype)
754-
score[ii, jj, ...] = _score
755-
756-
pb.update(jj * len(estimators) + ii + 1)
842+
n_iter = X.shape[-1]
843+
n_train = len(estimators)
844+
score_shape = [n_train, n_iter]
845+
846+
scoring = _resolve_scoring_for_classifier(scoring, estimators)
847+
response_method, can_batch = _detect_response_method(scoring)
848+
849+
# If we can't batch, fall back to a simple nested loop for scoring
850+
if not can_batch:
851+
for jj in range(n_iter):
852+
for ii, est in enumerate(estimators):
853+
_score = scoring(est, X[..., jj], y)
854+
if (ii == 0) and (jj == 0):
855+
score = np.zeros(score_shape, type(_score))
856+
score[ii, jj, ...] = _score
857+
pb.update(jj * n_train + ii + 1)
858+
return score
859+
860+
# Resolve a single method name for _gl_transform: pick the first available
861+
# if response_method is a tuple (e.g. roc_auc).
862+
if isinstance(response_method, str):
863+
method = response_method
864+
else:
865+
for m in response_method:
866+
if hasattr(estimators[0], m):
867+
method = m
868+
break
869+
870+
# Batch all predictions through _gl_transform. y_pred shape:
871+
# (n_sample, n_train, n_iter) or (n_sample, n_train, n_iter, n_classes).
872+
y_pred = _gl_transform(estimators, X, method, pb)
873+
874+
# Binary predict_proba: take the positive-class column to match sklearn
875+
# scorer expectations for binary problems.
876+
if method == "predict_proba" and y_pred.ndim == 4 and y_pred.shape[-1] == 2:
877+
y_pred = y_pred[..., 1]
878+
879+
# `scoring._kwargs or {}` also guards score_func(..., **kwargs) against
880+
# scoring._kwargs being None.
881+
score_func = scoring._score_func
882+
sign = scoring._sign
883+
kwargs = scoring._kwargs or {}
884+
batched_score = _make_batched_score(
885+
score_func, response_method, method, y, sign, kwargs
886+
)
887+
888+
# Reduce predictions to scores. Vectorised if we recognised the scorer,
889+
# otherwise nested loops over (estimator, slice).
890+
if batched_score is not None:
891+
score = batched_score(y_pred)
892+
else:
893+
for ii in range(n_train):
894+
for jj in range(n_iter):
895+
_score = sign * score_func(y, y_pred[:, ii, jj], **kwargs)
896+
if (ii == 0) and (jj == 0):
897+
score = np.zeros(score_shape, type(_score))
898+
score[ii, jj, ...] = _score
757899
return score
758900

759901

mne/decoding/tests/test_search_light.py

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
# Copyright the MNE-Python contributors.
44

55
import platform
6+
from functools import wraps
67
from inspect import signature
78

89
import numpy as np
910
import pytest
10-
from numpy.testing import assert_array_equal, assert_equal
11+
from numpy.testing import assert_allclose, assert_array_equal, assert_equal
1112

1213
sklearn = pytest.importorskip("sklearn")
1314

1415
from sklearn.base import BaseEstimator, clone, is_classifier
1516
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
1617
from sklearn.ensemble import BaggingClassifier
1718
from sklearn.linear_model import LinearRegression, LogisticRegression, Ridge
18-
from sklearn.metrics import make_scorer, roc_auc_score
19+
from sklearn.metrics import check_scoring, make_scorer, roc_auc_score
1920
from sklearn.model_selection import cross_val_predict
2021
from sklearn.multiclass import OneVsRestClassifier
2122
from sklearn.pipeline import make_pipeline
@@ -246,7 +247,11 @@ def test_generalization_light(metadata_routing):
246247
gl.fit(X, y)
247248
score = gl.score(X, y)
248249
auc = roc_auc_score(y, gl.estimators_[0].predict_proba(X[..., 0])[..., 1])
249-
assert_equal(score[0, 0], auc)
250+
251+
# The rank identity implemented when batching gives the same AUC as sklearn
252+
# within floating point precision, but implements it with different
253+
# operations. A bit-exact match would need a loop, defeating the batching.
254+
assert_allclose(score[0, 0], auc)
250255

251256
for scoring in ["foo", 999]:
252257
gl = GeneralizingEstimator(logreg, scoring=scoring)
@@ -267,7 +272,8 @@ def test_generalization_light(metadata_routing):
267272
[roc_auc_score(y - 1, _y_pred) for _y_pred in _y_preds]
268273
for _y_preds in gl.decision_function(X).transpose(1, 2, 0)
269274
]
270-
assert_array_equal(score, manual_score)
275+
# allclose instead of equal: see above, batching roc_auc forces this.
276+
assert_allclose(score, manual_score)
271277

272278
# n_jobs
273279
gl = GeneralizingEstimator(logreg, n_jobs=2)
@@ -293,6 +299,78 @@ def test_generalization_light(metadata_routing):
293299
assert_array_equal(y_preds[0], y_preds[1])
294300

295301

302+
@pytest.mark.parametrize(
303+
"scoring, est_name, method",
304+
[
305+
(None, "logreg", "predict"),
306+
("accuracy", "logreg", "predict"),
307+
("balanced_accuracy", "logreg", "predict"),
308+
("roc_auc", "logreg", "decision_function"),
309+
("neg_log_loss", "logreg", "predict_proba"),
310+
(None, "ridge", "predict"),
311+
("roc_auc_multiclass", "logreg", "predict_proba"),
312+
("accuracy_kwargs", "logreg", "predict"),
313+
],
314+
)
315+
def test_gl_score_branches(scoring, est_name, method):
316+
"""Test _gl_score against its own can_batch=False nested-loop fallback."""
317+
n_trials, n_sensors, n_iter = 12, 3, 4
318+
rng = np.random.RandomState(0)
319+
X = rng.randn(n_trials, n_sensors, n_iter)
320+
y = rng.randint(0, 3 if scoring == "roc_auc_multiclass" else 2, n_trials)
321+
per_slice = scoring in ("neg_log_loss", "roc_auc_multiclass", "accuracy_kwargs")
322+
# liblinear is binary-only, switch to lbfgs for the multi-class case.
323+
solver = "lbfgs" if scoring == "roc_auc_multiclass" else "liblinear"
324+
if scoring == "roc_auc_multiclass":
325+
scoring = make_scorer(
326+
roc_auc_score, response_method="predict_proba", multi_class="ovr"
327+
)
328+
elif scoring == "accuracy_kwargs":
329+
# start from the default scorer but add a kwarg to prevent batching
330+
acc_func = check_scoring(LogisticRegression(), "accuracy")._score_func
331+
scoring = make_scorer(acc_func, normalize=False)
332+
est = Ridge() if est_name == "ridge" else LogisticRegression(solver=solver)
333+
gl = GeneralizingEstimator(est, scoring=scoring).fit(X, y)
334+
335+
# Measure batching: count pred/call scores. Wraps `fn` calls so they append
336+
# to a bucket; @wraps preserves __name__ (needed as _gl_score matches it)
337+
def counting(fn, bucket):
338+
@wraps(fn)
339+
def wrapped(*a, **k):
340+
bucket.append(1)
341+
return fn(*a, **k)
342+
343+
return wrapped
344+
345+
# First we count calls to scorer
346+
score_calls = []
347+
scorer = check_scoring(est, scoring)
348+
if getattr(scorer, "_score_func", None) is not None:
349+
scorer._score_func = counting(scorer._score_func, score_calls)
350+
351+
# Now we count calls to the estimator that _gl_score will call (hardcoded)
352+
pred_calls = []
353+
for e in gl.estimators_:
354+
setattr(e, method, counting(getattr(e, method), pred_calls))
355+
356+
# Batched path: assert call counts immediately so the buckets only reflect
357+
# this run (the reference run below would otherwise add to them).
358+
gl.scoring = scorer
359+
actual = gl.score(X, y)
360+
assert len(pred_calls) == (n_iter if est_name != "ridge" else n_iter**2)
361+
assert len(score_calls) == (n_iter**2 if per_slice else 0)
362+
363+
# Reference: force can_batch=False. _score_func set (non-None) bypasses the
364+
# qname coercion; missing _response_method makes can_batch False.
365+
def force_fallback(e, X, y):
366+
return scorer(e, X, y)
367+
368+
force_fallback._score_func = id
369+
gl.scoring = force_fallback
370+
expected = gl.score(X, y)
371+
assert_allclose(actual, expected)
372+
373+
296374
@pytest.mark.parametrize(
297375
"n_jobs, verbose", [(1, False), (2, False), (1, True), (2, "info")]
298376
)

tools/circleci_download.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export MNE_TQDM=off
55
echo "export OPENBLAS_NUM_THREADS=4" >> $BASH_ENV
66
echo "export MNE_DOC_BUILD_N_JOBS=1" >> $BASH_ENV
77

8-
if [ "$CIRCLE_BRANCH" == "main" ] || [[ $(cat gitlog.txt) == *"[circle full]"* ]] || [[ "$CIRCLE_BRANCH" == "maint/"* ]]; then
8+
if { [[ "$CIRCLE_BRANCH" == "main" ]] || [[ $(cat gitlog.txt) == *"[circle full]"* ]] || [[ "$CIRCLE_BRANCH" == "maint/"* ]] ; } && [[ "$CIRCLE_PROJECT_USERNAME" == "mne-tools" ]]; then
99
echo "Doing a full build";
1010
echo html-memory > build.txt;
1111
echo "export OPENBLAS_NUM_THREADS=1" >> $BASH_ENV

tutorials/machine-learning/50_decoding.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@
1414
Design philosophy
1515
=================
1616
Decoding (a.k.a. MVPA) in MNE largely follows the machine learning API of the
17-
scikit-learn package.
18-
Each estimator implements ``fit``, ``transform``, ``fit_transform``, and
19-
(optionally) ``inverse_transform`` methods. For more details on this design,
20-
visit scikit-learn_. For additional theoretical insights into the decoding
17+
scikit-learn package. Each estimator implements ``fit``, ``transform``,
18+
``fit_transform``, and (optionally) ``inverse_transform`` methods. For more details on
19+
this design, visit scikit-learn_. For additional theoretical insights into the decoding
2120
framework in MNE :footcite:`KingEtAl2018`.
2221
2322
For ease of comprehension, we will denote instantiations of the class using

0 commit comments

Comments
 (0)