Skip to content

Commit 9f2d44f

Browse files
Phase 2: multi-model exposure for MNE users (#188)
* Add public model_loglik/model_probability accessors * Let plot_model_probability accept a live lht array * Expose multi-model AMICA through AMICAICA * Document multi-model MNE wrapper * Guard scoring path: finiteness, underflow, docstrings * Harden AMICAICA multi-model: kw-only model_idx, guards, tests * Fix stale Notes mapping doc; add viz/docs coverage
1 parent 9a5933e commit 9f2d44f

9 files changed

Lines changed: 674 additions & 67 deletions

File tree

docs/api/mne-compat.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,31 @@ scalp maps come out in channel space). The equivalence
5959
`to_mne_ica().get_sources(raw) == AMICA.transform(X)` is pinned by the test
6060
suite on real sample EEG.
6161

62-
!!! note "Single-model in this release"
63-
`AMICAICA` covers the single-model case (`n_models == 1`). Multi-model
64-
exposure for MNE users (per-model sources and model dominance) is tracked in
65-
issue #141.
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.
6688

6789
::: pamica.mne_compat.AMICAICA

docs/changelog.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ MNE-Python compatibility layer (epic #139), additive: the scikit-learn-style
2727
optional extra (`pip install pamica[mne]`); `import pamica` never requires it,
2828
and a dedicated CI job runs the wrapper tests with the extra installed (phase 1,
2929
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).
3045

3146
## 0.2.2
3247

pamica/amica.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,64 @@ 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+
422480
def variance_order(
423481
self, model_idx: int = 0, return_svar: bool = False
424482
) -> Union[np.ndarray, tuple]:

0 commit comments

Comments
 (0)