Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/api/mne-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,17 @@ generalized-Gaussian shape (meaningful for `pdftype=0`). `shared_components`
lists components merged across models by `share_comps` (empty otherwise). The
same three accessors exist on the scikit-learn-style [`AMICA`](amica.md).

## Separation-quality metrics

The [MIR/PMI metrics](metrics.md) are available directly on an MNE object, so
MNE-side users get the same separation-quality numbers as EEGLAB-side users:

```python
mir_nats, variance = ica.mir(raw, model_idx=0) # mutual information reduced
mi_matrix = ica.pmi(raw, model_idx=0) # pairwise MI between sources
```

Both extract the fitted channels from the `Raw`/`Epochs` and delegate to
`AMICA.mir`/`pmi`, reproducing the array API exactly.

::: pamica.mne_compat.AMICAICA
7 changes: 7 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ MNE-Python compatibility layer (epic #139), additive: the scikit-learn-style
generalized-Gaussian shape parameters, and `shared_components()` the components
merged across models by `share_comps`. The same accessors are added to
`AMICA`/`AMICATorchNG` (phase 3, #142).
- Separation-quality metrics are available directly on an MNE object:
`AMICAICA.mir(inst, model_idx=...)` (Mutual Information Reduction, in nats) and
`AMICAICA.pmi(inst, model_idx=...)` (pairwise mutual information between the
fitted sources), so MNE-side users get the same metrics as EEGLAB-side users.
Both extract the fitted channels from the `Raw`/`Epochs` and delegate to
`AMICA.mir`/`pmi` (#133); the results match the array API exactly (phase 4,
#143).

## 0.2.2

Expand Down
64 changes: 64 additions & 0 deletions pamica/mne_compat/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ class AMICAICA:
which MNE's ``ICA`` cannot represent -- is exposed directly by
:meth:`get_model_probability` / :meth:`plot_model_probability`.

Separation-quality metrics (issue #133) are available directly on an MNE
object: :meth:`mir` (Mutual Information Reduction) and :meth:`pmi` (pairwise
mutual information between sources). The pamica-specific fitted metadata MNE
cannot hold -- source-density family, GG shape, component sharing -- is
inspectable via :meth:`get_pdftype` / :meth:`get_rho` / :meth:`shared_components`.

Parameters
----------
n_models : int, default=1
Expand Down Expand Up @@ -418,6 +424,64 @@ def plot_model_probability(self, inst, *, srate: Optional[float] = None, **kwarg
srate = float(self.info_["sfreq"])
return _plot_model_probability(lht=lht, srate=srate, **kwargs)

# ------------------------------------------------------------------
# Separation-quality metrics (issue #143, on top of #133)
# ------------------------------------------------------------------
def mir(self, inst, *, model_idx: int = 0, nbins: Optional[int] = None) -> tuple:
"""Mutual Information Reduction of model ``model_idx`` on ``inst``.

How much mutual information the fitted unmixing removes from the data,
in nats (issue #133). Delegates to :meth:`AMICA.mir` on ``inst``'s
fitted-channel data; MIR is shift-invariant, so mean/``c`` centering is
irrelevant.

Parameters
----------
inst : mne.io.BaseRaw | mne.BaseEpochs
Data to score (``Epochs`` concatenated along time).
model_idx : int, default=0
Which model's unmixing to use.
nbins : int, optional
Histogram bin count; see :func:`pamica.metrics.mir`.

Returns
-------
mir_nats : float
variance : float
"""
self._check_fitted("compute MIR")
model_idx = self._check_model_idx(model_idx)
assert self.amica_ is not None
return self.amica_.mir(self._data_for(inst), model_idx=model_idx, nbins=nbins)

def pmi(
self, inst, *, model_idx: int = 0, nbins: Optional[int] = None
) -> np.ndarray:
"""Pairwise Mutual Information between model ``model_idx``'s sources on ``inst``.

The residual pairwise dependence between fitted sources, in nats
(issue #133). Delegates to :meth:`AMICA.pmi` on ``inst``'s fitted-channel
data.

Parameters
----------
inst : mne.io.BaseRaw | mne.BaseEpochs
Data to score (``Epochs`` concatenated along time).
model_idx : int, default=0
Which model's sources to use.
nbins : int, optional
Histogram bin count; see :func:`pamica.metrics.pairwise_mi`.

Returns
-------
mi_matrix : np.ndarray of shape (n_components, n_components)
Symmetric; the diagonal is each source's own entropy.
"""
self._check_fitted("compute PMI")
model_idx = self._check_model_idx(model_idx)
assert self.amica_ is not None
return self.amica_.pmi(self._data_for(inst), model_idx=model_idx, nbins=nbins)

# ------------------------------------------------------------------
# pamica-specific metadata (issue #142)
#
Expand Down
68 changes: 68 additions & 0 deletions pamica/tests/mne_tests/test_mne_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,3 +465,71 @@ def test_metadata_requires_fit():
ica.get_pdftype()
with pytest.raises(ValueError, match="must be fitted"):
ica.shared_components()


# --- separation-quality metrics (issue #143) --------------------------------
def test_mir_matches_amica_per_model(raw, fitted_2m):
x = _picked_data(raw)
mirs = []
for h in range(2):
m = fitted_2m.mir(raw, model_idx=h)
np.testing.assert_allclose(m, fitted_2m.amica_.mir(x, model_idx=h))
mirs.append(m[0])
assert not np.isclose(mirs[0], mirs[1]) # model_idx honored, not hardcoded to 0


def test_pmi_matches_amica_per_model(raw, fitted_2m):
x = _picked_data(raw)
pmis = []
for h in range(2):
pmi_w = fitted_2m.pmi(raw, model_idx=h)
assert pmi_w.shape == (fitted_2m.n_components_, fitted_2m.n_components_)
np.testing.assert_allclose(pmi_w, fitted_2m.amica_.pmi(x, model_idx=h))
pmis.append(pmi_w)
assert not np.allclose(pmis[0], pmis[1]) # per-model, not hardcoded to 0


def test_mir_pmi_on_epochs(raw, fitted_2m):
epochs = mne.make_fixed_length_epochs(raw, duration=2.0, preload=True)
x = np.hstack(epochs.copy().pick("data", exclude="bads").get_data())
np.testing.assert_allclose(fitted_2m.mir(epochs), fitted_2m.amica_.mir(x))
np.testing.assert_allclose(fitted_2m.pmi(epochs), fitted_2m.amica_.pmi(x))


def test_mir_pmi_nbins_passthrough(raw, fitted_2m):
"""nbins must reach the metric: a non-default nbins matches AMICA with the
same nbins (a dropped forward would silently fall back to the default)."""
x = _picked_data(raw)
np.testing.assert_allclose(
fitted_2m.mir(raw, nbins=50), fitted_2m.amica_.mir(x, nbins=50)
)
np.testing.assert_allclose(
fitted_2m.pmi(raw, nbins=50), fitted_2m.amica_.pmi(x, nbins=50)
)


def test_mir_pmi_respect_model_idx_bounds(fitted_2m, raw):
with pytest.raises(ValueError, match="out of range"):
fitted_2m.mir(raw, model_idx=2)
with pytest.raises(ValueError, match="out of range"):
fitted_2m.pmi(raw, model_idx=-1)


def test_mir_pmi_require_fit(raw):
ica = AMICAICA()
with pytest.raises(ValueError, match="must be fitted"):
ica.mir(raw)
with pytest.raises(ValueError, match="must be fitted"):
ica.pmi(raw)


def test_mir_pmi_refuse_degenerate(raw):
ica = AMICAICA(n_models=2, random_state=SEED, device="cpu", verbose=False).fit(
raw, max_iter=MAX_ITER
)
ica.converged_ = False
ica.stop_reason_ = "nan_ll"
with pytest.raises(RuntimeError, match="degenerate"):
ica.mir(raw)
with pytest.raises(RuntimeError, match="degenerate"):
ica.pmi(raw)
Loading