Skip to content

Commit 3be06c8

Browse files
Merge pull request #191 from sccn/feature/issue-139-epic-mne-compat
MNE-Python compatibility layer (epic #139)
2 parents 9fa04f8 + 768601b commit 3be06c8

19 files changed

Lines changed: 2023 additions & 14 deletions

File tree

.github/workflows/ci.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,28 @@ jobs:
113113
files: coverage.xml
114114
fail_ci_if_error: false
115115

116+
test-mne:
117+
name: Test MNE wrapper (optional [mne] extra)
118+
needs: [lint, typecheck]
119+
runs-on: ubuntu-latest
120+
env:
121+
PYTORCH_ENABLE_MPS_FALLBACK: "1"
122+
steps:
123+
- uses: actions/checkout@v4
124+
- uses: astral-sh/setup-uv@v5
125+
with:
126+
python-version: "3.12"
127+
enable-cache: true
128+
# mne IS pip-installable on Linux (unlike the Apple-only mlx extra), so the
129+
# AMICAICA wrapper (issue #139) is exercised for real in CI here. Its tests
130+
# live in pamica/tests/mne_tests and self-skip in the base `test` job
131+
# (importorskip on mne); this job installs the [mne] extra so they run on
132+
# the real sample EEG. pamica/mne_compat is omitted from coverage in
133+
# pyproject (the base env has no mne), so no fail-under gate applies here.
134+
- run: uv sync --extra mne
135+
- name: pytest (mne wrapper, real sample EEG)
136+
run: uv run pytest pamica/tests/mne_tests
137+
116138
build:
117139
name: Build + import (Python ${{ matrix.python-version }})
118140
needs: [lint, typecheck]

docs/api/index.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,13 @@ from pamica.mlx_impl import AMICAMLXNG # requires the `mlx` extra
2727

2828
- **[`AMICAMLXNG`](mlx-backend.md)** — the optional Apple-GPU (MLX) backend; the
2929
fastest option on Apple Silicon (float32).
30+
31+
The optional MNE-Python wrapper is likewise imported explicitly:
32+
33+
```python
34+
from pamica.mne_compat import AMICAICA # requires the `mne` extra
35+
```
36+
37+
- **[`AMICAICA`](mne-compat.md)** — fit AMICA from an MNE `Raw`/`Epochs` and
38+
interoperate with `mne.preprocessing.ICA` (`get_sources`, `apply`,
39+
`plot_components`, `to_mne_ica`).

docs/api/mne-compat.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# MNE-Python compatibility (AMICAICA)
2+
3+
`AMICAICA` fits AMICA directly from an [MNE-Python](https://mne.tools)
4+
`Raw`/`Epochs` and hands the result back through the standard MNE ICA surface.
5+
It is **additive**: the scikit-learn-style [`AMICA`](amica.md) interface and the
6+
byte-identical [EEGLAB output](../guides/eeglab.md) are unchanged; this is a
7+
second entry point for MNE users, not a replacement.
8+
9+
MNE is an optional dependency, so `import pamica` never requires it. Install the
10+
extra and import the wrapper explicitly:
11+
12+
```bash
13+
pip install pamica[mne]
14+
```
15+
16+
```python
17+
import mne
18+
from pamica.mne_compat import AMICAICA
19+
20+
raw = mne.io.read_raw_eeglab("subject.set", preload=True)
21+
22+
ica = AMICAICA(n_mix=3, random_state=42).fit(raw, picks="eeg", max_iter=100)
23+
24+
sources = ica.get_sources(raw) # an mne.io.RawArray of component activations
25+
maps = ica.get_components() # scalp maps, shape (n_channels, n_components)
26+
ica.plot_components() # native mne.viz topographies
27+
28+
# Reconstruct with some components removed:
29+
clean = ica.apply(raw.copy(), exclude=[0, 3])
30+
```
31+
32+
`fit` accepts a `Raw` or `Epochs` (epochs are concatenated along time, as MNE's
33+
own ICA does), any MNE `picks` selector, and forwards remaining keywords
34+
(`max_iter`, `lrate`, `do_newton`, ...) to [`AMICA.fit`](amica.md). It rejects
35+
non-finite input and PCA reduction (`pcakeep`/`pcadb`, which leaves the sphere
36+
rank-deficient so the full-rank export would be invalid), and a degenerate
37+
(diverged) fit is refused by the consumer methods rather than emitting NaNs.
38+
39+
## Interoperating with `mne.preprocessing.ICA`
40+
41+
`to_mne_ica()` returns a fully-populated
42+
[`mne.preprocessing.ICA`](https://mne.tools/stable/generated/mne.preprocessing.ICA.html),
43+
so the entire MNE ICA ecosystem (plotting, `find_bads_eog`/`_ecg`, exclusion
44+
workflows) works on an AMICA decomposition:
45+
46+
```python
47+
mne_ica = ica.to_mne_ica()
48+
eog_idx, scores = mne_ica.find_bads_eog(raw)
49+
mne_ica.plot_scores(scores)
50+
```
51+
52+
The wrapper's `get_sources`, `apply`, `get_components`, `plot_components` and
53+
`plot_sources` delegate to this object, so they reproduce `AMICA.transform`
54+
exactly: MNE
55+
computes sources as `unmixing_matrix_ @ pca_components_ @ (X - pca_mean_)`, and
56+
the export maps pamica's mean, symmetric-ZCA sphere and unmixing into those
57+
matrices (writing the sphere as `V diag(1/√e) Vᵀ` with `V` orthonormal so MNE's
58+
scalp maps come out in channel space). The equivalence
59+
`to_mne_ica().get_sources(raw) == AMICA.transform(X)` is pinned by the test
60+
suite on real sample EEG.
61+
62+
## Multi-model fits
63+
64+
AMICA can learn a mixture of ICA models (`n_models > 1`). MNE's `ICA` represents
65+
only one unmixing matrix, so each model is exported as its own single-model
66+
`mne.preprocessing.ICA`, and the per-sample *model dominance* (which model best
67+
explains each timepoint) is exposed directly, since MNE has no concept for it:
68+
69+
```python
70+
ica = AMICAICA(n_models=2, random_state=42).fit(raw, max_iter=100)
71+
72+
# Per-model: model_idx selects the model on every consumer method.
73+
sources_m1 = ica.get_sources(raw, model_idx=1)
74+
ica.plot_components(model_idx=1)
75+
model1 = ica.to_mne_ica(model_idx=1) # a standard ICA for model 1
76+
77+
# Model dominance over time (P(model | sample), columns sum to 1):
78+
prob = ica.get_model_probability(raw) # (n_models, n_samples)
79+
ica.plot_model_probability(raw) # per-model probability + best-model LL
80+
```
81+
82+
`get_model_probability`/`plot_model_probability` build on the public
83+
`AMICA.model_loglik`/`model_probability` accessors, which score arbitrary data
84+
through the fitted sphere and mean. Each per-model export folds that model's
85+
data-space center into `pca_mean_`, so `to_mne_ica(model_idx=h).get_sources(raw)`
86+
reproduces `AMICA.transform(X, model_idx=h)` (with `X` the picked channel array)
87+
for every model, not just the first.
88+
89+
## Inspecting pamica-specific metadata
90+
91+
An `mne.preprocessing.ICA` has no field for AMICA's adaptive source densities or
92+
component sharing, so rather than drop them, the wrapper exposes them directly:
93+
94+
```python
95+
from pamica.mne_compat import AMICAICA, PDFTYPE_NAMES
96+
97+
ica = AMICAICA(n_models=2, random_state=42).fit(raw, max_iter=100)
98+
99+
families = ica.get_pdftype(model_idx=0) # (n_components,) codes 0-4
100+
names = [PDFTYPE_NAMES[c] for c in families] # e.g. "generalized_gaussian"
101+
rho = ica.get_rho(model_idx=0) # (n_mix, n_components) GG shape
102+
shared = ica.shared_components() # [(model, comp), ...] groups
103+
```
104+
105+
`get_pdftype` returns each component's density family (0 generalized Gaussian,
106+
1 super-Gaussian cosh, 2 Gaussian, 3 logistic, 4 sub-Gaussian cosh; they differ
107+
per component only under the adaptive switcher `pdftype=1`). `get_rho` is the
108+
generalized-Gaussian shape (meaningful for `pdftype=0`). `shared_components`
109+
lists components merged across models by `share_comps` (empty otherwise). The
110+
same three accessors exist on the scikit-learn-style [`AMICA`](amica.md).
111+
112+
## Separation-quality metrics
113+
114+
The [MIR/PMI metrics](metrics.md) are available directly on an MNE object, so
115+
MNE-side users get the same separation-quality numbers as EEGLAB-side users:
116+
117+
```python
118+
mir_nats, variance = ica.mir(raw, model_idx=0) # mutual information reduced
119+
mi_matrix = ica.pmi(raw, model_idx=0) # pairwise MI between sources
120+
```
121+
122+
Both extract the fitted channels from the `Raw`/`Epochs` and delegate to
123+
`AMICA.mir`/`pmi`, reproducing the array API exactly.
124+
125+
::: pamica.mne_compat.AMICAICA

docs/changelog.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,60 @@
33
Release notes are also published on the
44
[GitHub releases page](https://github.com/sccn/pAMICA/releases).
55

6+
## 0.3.0
7+
8+
MNE-Python compatibility layer (epic #139), additive: the scikit-learn-style
9+
`AMICA` API and the byte-identical EEGLAB I/O are unchanged.
10+
11+
- `pamica.mne_compat.AMICAICA`, an MNE-facing wrapper that fits AMICA directly
12+
from an `mne.io.Raw`/`Epochs` (`picks=...`, epochs concatenated along time like
13+
MNE's own ICA) and interoperates with the standard MNE ICA consumer surface:
14+
`get_sources`, `apply`, `get_components`, `plot_components` and `plot_sources`.
15+
`to_mne_ica()`
16+
returns a fully-populated `mne.preprocessing.ICA` (including `reject_`/
17+
`n_samples_`, so `ICA.save` and `plot_properties` work), so the whole MNE ICA
18+
ecosystem (component plotting, `find_bads_eog`/`_ecg`, exclusion workflows)
19+
works on an AMICA decomposition. The export maps pamica's mean, symmetric-ZCA
20+
sphere and unmixing into MNE's `pca_mean_`/`pca_components_`/`unmixing_matrix_`,
21+
writing the sphere as `V diag(1/sqrt(e)) V^T` with `V` orthonormal so MNE's
22+
scalp maps are in channel space; `to_mne_ica().get_sources(raw)` reproduces
23+
`AMICA.transform(X)` to float64 precision, pinned on real sample EEG. `fit`
24+
rejects PCA reduction (`pcakeep`/`pcadb`, which leaves the sphere rank-deficient
25+
and the export invalid) and non-finite input, and a degenerate fit is refused
26+
by the consumer methods rather than emitting NaNs. MNE is an
27+
optional extra (`pip install pamica[mne]`); `import pamica` never requires it,
28+
and a dedicated CI job runs the wrapper tests with the extra installed (phase 1,
29+
single-model, #140).
30+
- Multi-model exposure through the MNE wrapper: `AMICAICA(n_models=...)` fits a
31+
mixture of ICA models, and since MNE's `ICA` represents only one unmixing,
32+
each model is exported as its own single-model `mne.preprocessing.ICA` via
33+
`to_mne_ica(model_idx=...)` (and the `model_idx` argument on `get_sources`/
34+
`apply`/`get_components`/`plot_components`/`plot_sources`). The per-sample model
35+
dominance MNE cannot represent is exposed directly: `get_model_probability(inst)`
36+
returns `P(model | sample)` (`(n_models, n_samples)`, columns sum to 1) and
37+
`plot_model_probability(inst)` draws the per-model probability plus best-model
38+
log-likelihood over time. These build on a new public live accessor,
39+
`AMICA.model_loglik`/`model_probability` (and the `AMICATorchNG` equivalents),
40+
which score arbitrary data through the stored sphere/mean; the training-data
41+
path (without `do_reject`) is pinned bit-for-bit against the E-step's own `Lht`. The per-model export
42+
folds each model's data-space center `c` into `pca_mean_`, so the round trip
43+
holds for the multi-model case too. `pamica.viz.plot_model_probability` now also
44+
accepts a live `lht` array, not only a written `AmicaOutput` (phase 2, #141).
45+
- pamica-specific fitted metadata is inspectable through the MNE wrapper rather
46+
than silently dropped by the `mne.preprocessing.ICA` export: `get_pdftype(model_idx=...)`
47+
returns each component's source-density family code (0-4, named by
48+
`pamica.mne_compat.PDFTYPE_NAMES`), `get_rho(model_idx=...)` the
49+
generalized-Gaussian shape parameters, and `shared_components()` the components
50+
merged across models by `share_comps`. The same accessors are added to
51+
`AMICA`/`AMICATorchNG` (phase 3, #142).
52+
- Separation-quality metrics are available directly on an MNE object:
53+
`AMICAICA.mir(inst, model_idx=...)` (Mutual Information Reduction, in nats) and
54+
`AMICAICA.pmi(inst, model_idx=...)` (pairwise mutual information between the
55+
fitted sources), so MNE-side users get the same metrics as EEGLAB-side users.
56+
Both extract the fitted channels from the `Raw`/`Epochs` and delegate to
57+
`AMICA.mir`/`pmi` (#133); the results match the array API exactly (phase 4,
58+
#143).
59+
660
## 0.2.2
761

862
GitHub repository rename to pAMICA and a `__version__` fix.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ nav:
102102
- MLX backend: api/mlx-backend.md
103103
- NumPy backend: api/numpy-backend.md
104104
- Native backend: api/native-backend.md
105+
- MNE compatibility: api/mne-compat.md
105106
- Separation metrics: api/metrics.md
106107
- Visualization: api/viz.md
107108
- Development:

pamica/amica.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,105 @@ def pmi(
419419

420420
return self.model_.pmi(X, model_idx=model_idx, nbins=nbins)
421421

422+
def model_loglik(self, X: np.ndarray) -> np.ndarray:
423+
"""Per-model, per-sample log-likelihood ``Lht`` on ``X`` (issue #141).
424+
425+
Delegates to :meth:`AMICATorchNG.model_loglik`. For a multi-model fit
426+
this is the joint log-likelihood of each model at each sample, from
427+
which the per-sample model posterior (dominance) is
428+
``softmax(Lht, axis=0)``; see :meth:`model_probability`.
429+
430+
Parameters
431+
----------
432+
X : np.ndarray of shape (n_channels, n_samples)
433+
Raw (unpreprocessed) data.
434+
435+
Returns
436+
-------
437+
Lht : np.ndarray of shape (n_models, n_samples)
438+
439+
Raises
440+
------
441+
ValueError
442+
If the model is unfitted, or if ``X`` is non-finite.
443+
RuntimeError
444+
If the fit ended degenerate (issue #50).
445+
"""
446+
self._check_usable("compute the model log-likelihood")
447+
assert self.model_ is not None
448+
449+
return self.model_.model_loglik(X)
450+
451+
def model_probability(self, X: np.ndarray) -> np.ndarray:
452+
"""Per-sample posterior probability of each model (issue #141).
453+
454+
Delegates to :meth:`AMICATorchNG.model_probability`: the column-wise
455+
``softmax`` over models of :meth:`model_loglik`, i.e. ``P(model h |
456+
x_t)``. Each column sums to 1; all ones for a single model.
457+
458+
Parameters
459+
----------
460+
X : np.ndarray of shape (n_channels, n_samples)
461+
Raw (unpreprocessed) data.
462+
463+
Returns
464+
-------
465+
prob : np.ndarray of shape (n_models, n_samples)
466+
467+
Raises
468+
------
469+
ValueError
470+
If the model is unfitted, if ``X`` is non-finite, or if every model
471+
underflows to ``-inf`` log-likelihood at some sample.
472+
RuntimeError
473+
If the fit ended degenerate (issue #50).
474+
"""
475+
self._check_usable("compute the model probability")
476+
assert self.model_ is not None
477+
478+
return self.model_.model_probability(X)
479+
480+
def get_pdftype(self, model_idx: int = 0) -> np.ndarray:
481+
"""Per-source density-family code for model ``model_idx`` (issue #142).
482+
483+
Delegates to :meth:`AMICATorchNG.get_pdftype`. One integer per source
484+
component (0-4; see :data:`pamica.torch_impl.PDFTYPE_NAMES`).
485+
486+
Returns
487+
-------
488+
np.ndarray of int, shape (n_sources,)
489+
"""
490+
self._check_usable("get the density family")
491+
assert self.model_ is not None
492+
493+
return self.model_.get_pdftype(model_idx=model_idx)
494+
495+
def get_rho(self, model_idx: int = 0) -> np.ndarray:
496+
"""Generalized-Gaussian shape ``rho`` for model ``model_idx`` (issue #142).
497+
498+
Delegates to :meth:`AMICATorchNG.get_rho`.
499+
500+
Returns
501+
-------
502+
np.ndarray of float, shape (n_mix, n_sources)
503+
"""
504+
self._check_usable("get rho")
505+
assert self.model_ is not None
506+
507+
return self.model_.get_rho(model_idx=model_idx)
508+
509+
def shared_components(self) -> list:
510+
"""Components shared across models by ``share_comps`` (issue #142).
511+
512+
Delegates to :meth:`AMICATorchNG.shared_components`: one group of
513+
``(model_idx, source_idx)`` pairs per shared column; empty when nothing
514+
is shared.
515+
"""
516+
self._check_usable("get the shared components")
517+
assert self.model_ is not None
518+
519+
return self.model_.shared_components()
520+
422521
def variance_order(
423522
self, model_idx: int = 0, return_svar: bool = False
424523
) -> Union[np.ndarray, tuple]:

pamica/mne_compat/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""MNE-Python compatibility layer for pamica (issue #139).
2+
3+
This subpackage is an *optional* entry point: it imports ``mne`` at module
4+
import time, so it is intentionally NOT imported by :mod:`pamica.__init__`.
5+
``import pamica`` therefore never requires MNE; reach the wrapper explicitly::
6+
7+
from pamica.mne_compat import AMICAICA
8+
9+
Install the dependency with ``pip install pamica[mne]`` (same lazy-extra
10+
pattern as the optional ``mlx`` backend).
11+
"""
12+
13+
from .core import AMICAICA, PDFTYPE_NAMES
14+
15+
__all__ = ["AMICAICA", "PDFTYPE_NAMES"]

0 commit comments

Comments
 (0)