From e42c2c5971f705d37ebd743ec66f5123ce6ed981 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sat, 18 Jul 2026 09:21:51 -0700 Subject: [PATCH 1/4] Expose MIR/PMI metrics through AMICAICA --- pamica/mne_compat/core.py | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/pamica/mne_compat/core.py b/pamica/mne_compat/core.py index 6132b70..42c5bcc 100644 --- a/pamica/mne_compat/core.py +++ b/pamica/mne_compat/core.py @@ -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 @@ -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): + """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) # From b2d0275d18836af8d1bedef86d8efe3025d2ffb4 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sat, 18 Jul 2026 09:21:53 -0700 Subject: [PATCH 2/4] Test MNE-wrapper MIR/PMI accessors --- pamica/tests/mne_tests/test_mne_wrapper.py | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pamica/tests/mne_tests/test_mne_wrapper.py b/pamica/tests/mne_tests/test_mne_wrapper.py index 902f935..dc028a8 100644 --- a/pamica/tests/mne_tests/test_mne_wrapper.py +++ b/pamica/tests/mne_tests/test_mne_wrapper.py @@ -465,3 +465,45 @@ 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) + for h in range(2): + np.testing.assert_allclose( + fitted_2m.mir(raw, model_idx=h), fitted_2m.amica_.mir(x, model_idx=h) + ) + + +def test_pmi_matches_amica_per_model(raw, fitted_2m): + x = _picked_data(raw) + 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)) + + +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)) + assert fitted_2m.pmi(epochs).shape == ( + fitted_2m.n_components_, + fitted_2m.n_components_, + ) + + +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) From b826707f59c84aa3664b37781d17ce69bb7b5029 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sat, 18 Jul 2026 09:21:54 -0700 Subject: [PATCH 3/4] Document MIR/PMI accessors --- docs/api/mne-compat.md | 13 +++++++++++++ docs/changelog.md | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/docs/api/mne-compat.md b/docs/api/mne-compat.md index 6f240b7..3ff177f 100644 --- a/docs/api/mne-compat.md +++ b/docs/api/mne-compat.md @@ -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 diff --git a/docs/changelog.md b/docs/changelog.md index f7caebe..dacbced 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 From ab27dd41247e9fea8c96e90d4c0afb874782e334 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sat, 18 Jul 2026 09:28:43 -0700 Subject: [PATCH 4/4] Pin pmi-on-epochs, nbins, degenerate guard for metrics --- pamica/mne_compat/core.py | 2 +- pamica/tests/mne_tests/test_mne_wrapper.py | 38 ++++++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/pamica/mne_compat/core.py b/pamica/mne_compat/core.py index 42c5bcc..6040c17 100644 --- a/pamica/mne_compat/core.py +++ b/pamica/mne_compat/core.py @@ -427,7 +427,7 @@ def plot_model_probability(self, inst, *, srate: Optional[float] = None, **kwarg # ------------------------------------------------------------------ # Separation-quality metrics (issue #143, on top of #133) # ------------------------------------------------------------------ - def mir(self, inst, *, model_idx: int = 0, nbins: Optional[int] = None): + 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, diff --git a/pamica/tests/mne_tests/test_mne_wrapper.py b/pamica/tests/mne_tests/test_mne_wrapper.py index dc028a8..cdd2231 100644 --- a/pamica/tests/mne_tests/test_mne_wrapper.py +++ b/pamica/tests/mne_tests/test_mne_wrapper.py @@ -470,27 +470,41 @@ def test_metadata_requires_fit(): # --- 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): - np.testing.assert_allclose( - fitted_2m.mir(raw, model_idx=h), fitted_2m.amica_.mir(x, model_idx=h) - ) + 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)) - assert fitted_2m.pmi(epochs).shape == ( - fitted_2m.n_components_, - fitted_2m.n_components_, + 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) ) @@ -507,3 +521,15 @@ def test_mir_pmi_require_fit(raw): 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)