Skip to content

ENH: Add _soft_import check for sklearn in mne.decoding - #14409

Open
SohamXYZDev wants to merge 1 commit into
mne-tools:mainfrom
SohamXYZDev:fix/decoding-sklearn-soft-import
Open

SohamXYZDev wants to merge 1 commit into
mne-tools:mainfrom
SohamXYZDev:fix/decoding-sklearn-soft-import

Conversation

@SohamXYZDev

Copy link
Copy Markdown

Reference issue (if any)

Fixes #14394.

What does this implement/fix?

Adds _soft_import("sklearn", "decoding") before top-level sklearn imports across the mne.decoding submodules (_fixes.py, base.py, ems.py, receptive_field.py, search_light.py, time_delaying_ridge.py, time_frequency.py, and transformer.py) so that accessing mne.decoding functionality without scikit-learn installed raises MNE's informative RuntimeError instead of a raw ModuleNotFoundError.

Also updates mne/tests/test_docstring_parameters.py to handle the RuntimeError from _soft_import when walking mne.decoding in minimal environments without scikit-learn, and adds test coverage in mne/utils/tests/test_check.py.

Additional information

Because ruff (I001) sorts third-party from sklearn... imports before relative from .base import ... imports, placing _soft_import only in base.py would still allow from sklearn... in submodules like transformer.py or search_light.py (as well as ems.py and _fixes.py, which do not import from base.py) to fail with ModuleNotFoundError before base.py is reached. Placing _soft_import("sklearn", "decoding") right before the sklearn imports in each submodule (with # noqa: E402, matching mne/export/_edf_bdf.py and _eeglab.py) ensures every entry point in mne.decoding.__all__ consistently raises the _soft_import error while keeping import mne and import mne.decoding lazy.

I used Gemini 3.8 Flash to help check import ordering across the decoding submodules and draft the unit test, and reviewed and verified all changes locally.

@larsoner

Copy link
Copy Markdown
Member

@tsbinns can you look? If not, I can

@tsbinns tsbinns self-assigned this Oct 13, 2026
@tsbinns

tsbinns commented Oct 13, 2026

Copy link
Copy Markdown
Member

@larsoner Yeah, can do

@tsbinns

tsbinns commented Oct 13, 2026

Copy link
Copy Markdown
Member

@SohamXYZDev Thanks for submitting this PR.

Because ruff (I001) sorts third-party from sklearn... imports before relative from .base import ... imports, placing _soft_import only in base.py would still allow from sklearn... in submodules like transformer.py or search_light.py (as well as ems.py and _fixes.py, which do not import from base.py) to fail with ModuleNotFoundError before base.py is reached.

Good point, that does complicate things, but having to noqa each sklearn import line is a bit unwieldy.

At the very least, we could nest the sklearn imports that are being moved to the end between # ruff: disable[E402] and # ruff: enable[E402], which would avoid the need to specify this per-line.


However, I wonder if a cleaner approach is to still try and centralise the check in base.py, with something like

from ..utils import _soft_import  # isort: skip
_soft_import("sklearn", name="mne.decoding")

at the top of base.py, and then in the submodules import .base # isort: skip # noqa: E402 at the top of each file (noqa if it's never used).

For the warnings this would cause, we could either nest the affected imports in # ruff: disable[E402] ... # ruff: enable[E402], or add E402 to the ruff per-file-ignores in pyproject.toml, e.g.:

"mne/decoding/*.py" = [
  "E402",  # Module level import not at top of file
]

@larsoner WDYT about that?

Comment thread mne/utils/check.py
Comment on lines +436 to +439
class _SoftImportError(RuntimeError, ImportError):
"""Raised when an optional dependency is missing or incompatible."""

pass

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

IIUC, the ImportError comes from ._fixes.py, but shouldn't that never be encountered since we are adding the _soft_import check at the top of that file?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

In the minimal / 3.12 CI build (where scikit-learn is not installed), pytest collects doctests using --doctest-modules --doctest-ignore-import-errors.

That pytest flag only ignores errors that inherit from ImportError. Because _soft_import raised a raw RuntimeError, pytest failed during test collection when importing _fixes.py. Making _SoftImportError inherit from both RuntimeError and ImportError preserves MNE's RuntimeError behavior while letting pytest's doctest collector skip it cleanly when scikit-learn is absent.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

personally I'd be inclined to just switch to raising ImportError instead of RuntimeError or a custom error. Is there a reason that "preserving MNE's RuntimeError behavior" is something we want? It may be that whoever implemented _soft_import didn't have a strong reason for choosing Runtime vs Import...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@SohamXYZDev Would you still be interested in updating this, or would you like us to finish it off?

@larsoner

Copy link
Copy Markdown
Member

Yeah I think centralizing is better, that way if we ever want to change it's easier

@SohamXYZDev
SohamXYZDev force-pushed the fix/decoding-sklearn-soft-import branch from 59ac762 to 17d0141 Compare October 14, 2026 01:57
@SohamXYZDev

Copy link
Copy Markdown
Author

@tsbinns @larsoner Updated! Rebased on latest main and refactored as suggested:

  1. Centralized _soft_import("sklearn", "decoding") at the top of mne/decoding/base.py.
  2. Submodules now import base first with # isort: skip.
  3. Added "mne/decoding/*.py" = ["E402"] to per-file-ignores in pyproject.toml and stripped the per-line noqa comments.
  4. Resolved circular dependencies by keeping MNETransformerMixin in base.py and localizing Vectorizer inside get_coef.

All 1,311 decoding tests pass locally, and the missing-sklearn import checks verified cleanly.

@SohamXYZDev
SohamXYZDev force-pushed the fix/decoding-sklearn-soft-import branch from 17d0141 to 0b0bdcd Compare October 14, 2026 06:49
@mne-tools mne-tools deleted a comment from Zhaokaixlx Oct 14, 2026
Comment thread mne/decoding/base.py Resolved
@SohamXYZDev
SohamXYZDev force-pushed the fix/decoding-sklearn-soft-import branch from 0b0bdcd to 30b33c4 Compare October 23, 2026 17:54

@tsbinns tsbinns left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @SohamXYZDev!

@larsoner I'm happy with this. Although one thing in particular I'd like your opinion on is the use of a new error type that inherits from RuntimeError and ImportError for soft_import: https://github.com/mne-tools/mne-python/pull/14409/changes#r4263444300

This branch has not been deployed

No deployments
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.

Add a check for sklearn availability to decoding module

4 participants