Skip to content

AMICA

The main scikit-learn-style interface. Wraps the natural-gradient EM backend (AMICATorchNG).

pyAMICA.AMICA

Adaptive Mixture ICA using the PyTorch natural-gradient EM backend.

This is the main interface for pyAMICA, providing a scikit-learn style API over :class:AMICATorchNG, the natural-gradient EM implementation that matches the Fortran reference (Newton, exact-EM mixture updates, symmetric-ZCA sphere, Jacobian LL).

Parameters:

Name Type Description Default
n_models int

Number of ICA models to learn

1
n_mix int

Number of mixture components per source

3
device str or device

Device to use ('cuda', 'mps', 'cpu', or None for auto). With None (auto), an auto-selected MPS device is redirected to CPU because the backend computes in float64 for Fortran parity and MPS cannot represent it; pass dtype=torch.float32 (with device="mps") to run on MPS instead.

None
verbose bool

Whether to show progress during fitting

True

Attributes:

Name Type Description
model_ AMICATorchNG

The underlying PyTorch model

is_fitted_ bool

Whether a usable model is available. fit sets this True only when the fit converged normally; a degenerate fit (see converged_) leaves it False, and transform/get_mixing_matrix/get_unmixing_matrix/ save refuse such a model (issue #50).

converged_ bool

Whether the last fit ended on a usable stop rather than a degenerate one (stop_reason_ not in nan_ll/singular_ll). A degenerate fit holds non-finite parameters and would produce NaN sources (issue #50).

stop_reason_ str or None

Why the last fit stopped (the backend stop_reason): e.g. "max_iter", "lrate_floor", "nan_ll", "singular_ll".

ll_history_ list

Log-likelihood history during training (the true per-iteration trajectory; may dip below its peak on a late overshoot)

final_ll_ float

Log-likelihood of the fitted parameters (issue #51). Use this, not ll_history_[-1], as the model's log-likelihood: with the best-iterate safeguard the returned parameters can be an earlier, higher-LL iterate.

Examples:

>>> from pyAMICA import AMICA
>>> import numpy as np
>>>
>>> # Generate sample data
>>> X = np.random.randn(32, 10000)  # 32 channels, 10000 samples
>>>
>>> # Fit AMICA model
>>> amica = AMICA(n_models=1, n_mix=3)
>>> amica.fit(X, max_iter=100)
>>>
>>> # Transform data to sources
>>> S = amica.transform(X)
>>>
>>> # Get mixing matrix
>>> A = amica.get_mixing_matrix()
Source code in pyAMICA/amica.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
class AMICA:
    """
    Adaptive Mixture ICA using the PyTorch natural-gradient EM backend.

    This is the main interface for pyAMICA, providing a scikit-learn style
    API over :class:`AMICATorchNG`, the natural-gradient EM implementation
    that matches the Fortran reference (Newton, exact-EM mixture updates,
    symmetric-ZCA sphere, Jacobian LL).

    Parameters
    ----------
    n_models : int, default=1
        Number of ICA models to learn
    n_mix : int, default=3
        Number of mixture components per source
    device : str or torch.device, optional
        Device to use ('cuda', 'mps', 'cpu', or None for auto). With ``None``
        (auto), an auto-selected MPS device is redirected to CPU because the
        backend computes in float64 for Fortran parity and MPS cannot
        represent it; pass ``dtype=torch.float32`` (with ``device="mps"``) to
        run on MPS instead.
    verbose : bool, default=True
        Whether to show progress during fitting

    Attributes
    ----------
    model_ : AMICATorchNG
        The underlying PyTorch model
    is_fitted_ : bool
        Whether a *usable* model is available. ``fit`` sets this True only when
        the fit converged normally; a degenerate fit (see ``converged_``) leaves
        it False, and ``transform``/``get_mixing_matrix``/``get_unmixing_matrix``/
        ``save`` refuse such a model (issue #50).
    converged_ : bool
        Whether the last ``fit`` ended on a usable stop rather than a degenerate
        one (``stop_reason_`` not in ``nan_ll``/``singular_ll``). A degenerate fit
        holds non-finite parameters and would produce NaN sources (issue #50).
    stop_reason_ : str or None
        Why the last ``fit`` stopped (the backend ``stop_reason``): e.g.
        ``"max_iter"``, ``"lrate_floor"``, ``"nan_ll"``, ``"singular_ll"``.
    ll_history_ : list
        Log-likelihood history during training (the true per-iteration
        trajectory; may dip below its peak on a late overshoot)
    final_ll_ : float
        Log-likelihood of the *fitted* parameters (issue #51). Use this, not
        ``ll_history_[-1]``, as the model's log-likelihood: with the best-iterate
        safeguard the returned parameters can be an earlier, higher-LL iterate.

    Examples
    --------
    >>> from pyAMICA import AMICA
    >>> import numpy as np
    >>>
    >>> # Generate sample data
    >>> X = np.random.randn(32, 10000)  # 32 channels, 10000 samples
    >>>
    >>> # Fit AMICA model
    >>> amica = AMICA(n_models=1, n_mix=3)
    >>> amica.fit(X, max_iter=100)
    >>>
    >>> # Transform data to sources
    >>> S = amica.transform(X)
    >>>
    >>> # Get mixing matrix
    >>> A = amica.get_mixing_matrix()
    """

    def __init__(
        self,
        n_models: int = 1,
        n_mix: int = 3,
        device: Optional[Union[str, torch.device]] = None,
        verbose: bool = True,
    ):
        self.n_models = n_models
        self.n_mix = n_mix
        self.device = device
        self.verbose = verbose

        self.model_ = None
        self.is_fitted_ = False
        self.ll_history_ = []
        self.final_ll_ = None
        self.stop_reason_ = None
        self.converged_ = False

    def _select_device(self, ng_dtype) -> Union[str, torch.device]:
        """Resolve the compute device, applying the MPS/float64 fallback.

        ``AMICATorchNG`` defaults to float64 for Fortran parity, which MPS
        cannot represent. When the device was auto-selected (the user did not
        pin one) and resolved to MPS for a float64 run, fall back to CPU so the
        default config runs instead of crashing. CUDA supports float64, so only
        MPS needs this. An explicit ``device="mps"`` is left untouched and
        surfaces ``AMICATorchNG``'s own ValueError; users wanting MPS pass
        ``dtype=torch.float32`` too.
        """
        device = setup_device() if self.device is None else self.device
        dev_type = getattr(device, "type", device)
        if self.device is None and dev_type == "mps" and ng_dtype == torch.float64:
            device = torch.device("cpu")
            msg = (
                "AMICA uses float64 for Fortran parity; MPS lacks float64 "
                "support, so falling back to CPU. Pass dtype=torch.float32 "
                "with device='mps' to run on MPS."
            )
            logger.warning(msg)
            if self.verbose:
                print(msg)
        return device

    def fit(
        self,
        X: np.ndarray,
        max_iter: int = 100,
        lrate: float = 0.05,
        do_mean: bool = True,
        do_sphere: bool = True,
        do_newton: bool = False,
        **kwargs,
    ) -> "AMICA":
        """
        Fit AMICA model to data.

        Parameters
        ----------
        X : np.ndarray
            Input data of shape (n_channels, n_samples)
        max_iter : int, default=100
            Maximum number of iterations
        lrate : float, default=0.05
            Learning rate
        do_mean : bool, default=True
            Whether to remove mean from data
        do_sphere : bool, default=True
            Whether to sphere (whiten) the data
        do_newton : bool, default=False
            Whether to enable the Fortran-parity Newton preconditioner (tune
            via ``newt_start``/``newtrate`` in ``**kwargs``).
        **kwargs
            Additional parameters passed to the :class:`AMICATorchNG`
            constructor (e.g. ``block_size``, ``rho0``, ``seed``, ``dtype``) --
            the backend's tunables are constructor arguments, not fit() kwargs.

        Returns
        -------
        self : AMICA
            Fitted model
        """
        # Validate input
        if X.ndim != 2:
            raise ValueError(f"X must be 2D array, got shape {X.shape}")

        n_channels, n_samples = X.shape

        if self.verbose:
            print(f"Fitting AMICA with {n_channels} channels, {n_samples} samples")
            print(f"Models: {self.n_models}, Mixture components: {self.n_mix}")

        # Setup device (with the MPS/float64 parity fallback, see _select_device).
        device = self._select_device(kwargs.get("dtype", _NG_DEFAULT_DTYPE))

        # Build and train the backend on a LOCAL reference first, and only
        # publish it to self (and derive the fitted-state attributes) once
        # fit() returns. If the backend constructor or fit() raises mid-training
        # (a numerical crash, OOM, singular sphere, interrupt, ...), self is left
        # untouched: a first fit keeps model_ is None (so the output methods
        # raise a clean "not fitted"), and a refit keeps the previous, known-good
        # model rather than a half-trained one falsely marked usable (issue #50
        # silent-failure review).
        backend = AMICATorchNG(
            n_channels=n_channels,
            n_models=self.n_models,
            n_mix=self.n_mix,
            lrate=lrate,
            do_mean=do_mean,
            do_sphere=do_sphere,
            do_newton=do_newton,
            device=device,
            **kwargs,
        )
        backend.fit(X, max_iter=max_iter, verbose=self.verbose)

        self.model_ = backend
        self.ll_history_ = backend.ll_history
        self.final_ll_ = backend.final_ll_
        self.stop_reason_ = backend.stop_reason
        self.converged_ = self.stop_reason_ not in AMICATorchNG._DEGENERATE_STOP_REASONS
        # A degenerate fit (nan_ll/singular_ll) holds non-finite parameters and
        # would return NaN sources, so it is not a usable model: is_fitted_ stays
        # False and the output methods refuse it (issue #50). stop_reason_/
        # converged_ stay set for inspection.
        self.is_fitted_ = self.converged_
        if not self.converged_:
            logger.warning(
                "AMICA.fit ended degenerate (stop_reason=%r) at iteration %d: the "
                "model holds non-finite parameters and cannot transform. Inspect "
                "stop_reason_/ll_history_; lower lrate, disable Newton, or check "
                "data conditioning, then refit.",
                self.stop_reason_,
                backend.iteration,
            )

        return self

    def _check_usable(self, action: str) -> None:
        """Raise if the model cannot produce valid output: either never fitted,
        or the fit ended degenerate (``nan_ll``/``singular_ll``), leaving
        non-finite parameters that would yield NaN sources. This mirrors
        :meth:`AMICATorchNG.state_dict`'s refusal to serialize a degenerate model
        (issue #50): a diverged fit fails loudly here instead of silently
        returning garbage."""
        if self.model_ is None:
            raise ValueError(f"Model must be fitted before {action}.")
        if self.stop_reason_ in AMICATorchNG._DEGENERATE_STOP_REASONS:
            raise RuntimeError(
                f"Refusing to {action}: fit ended degenerate "
                f"(stop_reason={self.stop_reason_!r}), so the model holds "
                f"non-finite parameters and would produce NaN output. Lower "
                f"lrate, disable Newton, or check data conditioning, then refit."
            )

    def transform(self, X: np.ndarray, model_idx: int = 0) -> np.ndarray:
        """
        Transform data to source space.

        Parameters
        ----------
        X : np.ndarray
            Input data of shape (n_channels, n_samples)
        model_idx : int, default=0
            Which model to use for transformation

        Returns
        -------
        S : np.ndarray
            Sources of shape (n_sources, n_samples)
        """
        self._check_usable("transform")
        assert self.model_ is not None

        return self.model_.transform(X, model_idx=model_idx)

    def fit_transform(self, X: np.ndarray, **fit_params) -> np.ndarray:
        """
        Fit model and transform data.

        Parameters
        ----------
        X : np.ndarray
            Input data of shape (n_channels, n_samples)
        **fit_params
            Parameters passed to fit()

        Returns
        -------
        S : np.ndarray
            Sources of shape (n_sources, n_samples)
        """
        self.fit(X, **fit_params)
        return self.transform(X)

    def get_mixing_matrix(self, model_idx: int = 0) -> np.ndarray:
        """
        Get the mixing matrix A.

        Parameters
        ----------
        model_idx : int, default=0
            Which model's mixing matrix to return

        Returns
        -------
        A : np.ndarray
            Mixing matrix of shape (n_channels, n_sources)
        """
        self._check_usable("get the mixing matrix")
        assert self.model_ is not None

        return self.model_.get_mixing_matrix(model_idx=model_idx)

    def get_unmixing_matrix(self, model_idx: int = 0) -> np.ndarray:
        """
        Get the unmixing matrix W.

        Parameters
        ----------
        model_idx : int, default=0
            Which model's unmixing matrix to return

        Returns
        -------
        W : np.ndarray
            Unmixing matrix of shape (n_sources, n_channels)
        """
        self._check_usable("get the unmixing matrix")
        assert self.model_ is not None

        return self.model_.get_unmixing_matrix(model_idx=model_idx)

    def variance_order(
        self, model_idx: int = 0, return_svar: bool = False
    ) -> Union[np.ndarray, tuple]:
        """
        Component order by EEGLAB back-projected variance (IC1 = highest).

        Reports the display order EEGLAB's ``loadmodout15.m`` applies on load,
        without mutating the fitted parameters. Apply it to the columns of
        :meth:`get_mixing_matrix` (or rows of :meth:`get_unmixing_matrix`) to get
        EEGLAB-ordered components in Python.

        Parameters
        ----------
        model_idx : int, default=0
            Which model's components to order.
        return_svar : bool, default=False
            If True, also return the per-source variance sorted to ``order``.

        Returns
        -------
        order : np.ndarray of int
            Source indices, highest back-projected variance first.
        """
        self._check_usable("compute the variance order")
        assert self.model_ is not None

        return self.model_.variance_order(model_idx=model_idx, return_svar=return_svar)

    def write_amica_output(self, outdir: str) -> None:
        """
        Write the fitted model as an EEGLAB-readable AMICA output directory.

        Emits the raw binary files that EEGLAB's ``loadmodout15.m`` reads (``W``,
        ``S``, ``gm``, ``mean``, ``c``, ``alpha``, ``mu``, ``sbeta``, ``rho``,
        ``comp_list``, ``LL``), so a pyAMICA fit drops directly into an EEGLAB
        workflow (``mod = loadmodout15(outdir)``). ``loadmodout15`` applies the
        variance-ordering and normalization on load, so no manual re-ordering or
        sign-flipping is needed. Single-model output is byte-compatible with the
        Fortran reference (issue #92).

        Parameters
        ----------
        outdir : str
            Destination directory (created if absent).
        """
        self._check_usable("write EEGLAB output")
        assert self.model_ is not None

        self.model_.write_amica_output(outdir)

    def save(self, filepath: str) -> None:
        """
        Save the fitted model to ``filepath`` via ``torch.save``.

        Persists the underlying :class:`AMICATorchNG` state (config + fitted
        tensors) plus the wrapper's own configuration, so :meth:`load` can
        fully reconstruct a transform-ready model. Everything written is a
        tensor or plain Python primitive (see
        :meth:`AMICATorchNG.state_dict`), so it reloads with
        ``weights_only=True``.

        Parameters
        ----------
        filepath : str
            Destination path (a ``.pt`` file by convention).
        """
        self._check_usable("save")
        assert self.model_ is not None

        payload = {
            "format_version": 1,
            "wrapper": {
                "n_models": self.n_models,
                "n_mix": self.n_mix,
                "verbose": self.verbose,
            },
            "backend": self.model_.state_dict(),
        }
        torch.save(payload, filepath)

    @classmethod
    def load(
        cls, filepath: str, device: Optional[Union[str, torch.device]] = None
    ) -> "AMICA":
        """
        Load a fitted model saved by :meth:`save`.

        Parameters
        ----------
        filepath : str
            Path to a file written by :meth:`save`.
        device : str or torch.device, optional
            Device to place the restored model on. With ``None`` (auto), the
            same MPS/float64 fallback as :meth:`fit` applies so a float64
            parity model never lands on MPS.

        Returns
        -------
        amica : AMICA
            A fitted model ready for :meth:`transform` / :meth:`get_mixing_matrix`.
        """
        payload = torch.load(filepath, weights_only=True)
        version = payload.get("format_version")
        if version != 1:
            raise ValueError(
                f"unsupported AMICA save format_version: {version!r} (expected 1)"
            )
        for key in ("wrapper", "backend"):
            if key not in payload:
                raise ValueError(
                    f"malformed AMICA save file {filepath!r}: missing {key!r} "
                    f"(format_version={version}); the file may be truncated or "
                    f"corrupted."
                )

        wrapper = payload["wrapper"]
        model = cls(
            n_models=wrapper["n_models"],
            n_mix=wrapper["n_mix"],
            device=device,
            verbose=wrapper["verbose"],
        )

        # Resolve the device using the persisted backend dtype so the same
        # MPS/float64 fallback as fit() applies to an auto-selected device.
        ng_dtype = getattr(torch, payload["backend"]["config"]["dtype"])
        resolved_device = model._select_device(ng_dtype)
        model.model_ = AMICATorchNG.from_state_dict(
            payload["backend"], device=resolved_device
        )
        model.ll_history_ = model.model_.ll_history
        model.final_ll_ = model.model_.final_ll_
        # state_dict() refuses to serialize a degenerate model, so a loaded model
        # is always usable; carry its stop_reason through for inspection anyway.
        model.stop_reason_ = model.model_.stop_reason
        model.converged_ = (
            model.stop_reason_ not in AMICATorchNG._DEGENERATE_STOP_REASONS
        )
        model.is_fitted_ = model.converged_
        return model

    @classmethod
    def from_params_file(cls, params_file: str, **kwargs) -> "AMICA":
        """
        Create AMICA instance from parameter file.

        Parameters
        ----------
        params_file : str
            Path to JSON parameter file
        **kwargs
            Additional parameters to override

        Returns
        -------
        amica : AMICA
            Configured AMICA instance
        """
        import json

        with open(params_file, "r") as f:
            params = json.load(f)

        # Extract relevant parameters
        n_models = params.get("num_models", 1)
        n_mix = params.get("num_mix", 3)

        # Override with kwargs
        n_models = kwargs.pop("n_models", n_models)
        n_mix = kwargs.pop("n_mix", n_mix)

        return cls(n_models=n_models, n_mix=n_mix, **kwargs)

fit(X, max_iter=100, lrate=0.05, do_mean=True, do_sphere=True, do_newton=False, **kwargs)

Fit AMICA model to data.

Parameters:

Name Type Description Default
X ndarray

Input data of shape (n_channels, n_samples)

required
max_iter int

Maximum number of iterations

100
lrate float

Learning rate

0.05
do_mean bool

Whether to remove mean from data

True
do_sphere bool

Whether to sphere (whiten) the data

True
do_newton bool

Whether to enable the Fortran-parity Newton preconditioner (tune via newt_start/newtrate in **kwargs).

False
**kwargs

Additional parameters passed to the :class:AMICATorchNG constructor (e.g. block_size, rho0, seed, dtype) -- the backend's tunables are constructor arguments, not fit() kwargs.

{}

Returns:

Name Type Description
self AMICA

Fitted model

Source code in pyAMICA/amica.py
def fit(
    self,
    X: np.ndarray,
    max_iter: int = 100,
    lrate: float = 0.05,
    do_mean: bool = True,
    do_sphere: bool = True,
    do_newton: bool = False,
    **kwargs,
) -> "AMICA":
    """
    Fit AMICA model to data.

    Parameters
    ----------
    X : np.ndarray
        Input data of shape (n_channels, n_samples)
    max_iter : int, default=100
        Maximum number of iterations
    lrate : float, default=0.05
        Learning rate
    do_mean : bool, default=True
        Whether to remove mean from data
    do_sphere : bool, default=True
        Whether to sphere (whiten) the data
    do_newton : bool, default=False
        Whether to enable the Fortran-parity Newton preconditioner (tune
        via ``newt_start``/``newtrate`` in ``**kwargs``).
    **kwargs
        Additional parameters passed to the :class:`AMICATorchNG`
        constructor (e.g. ``block_size``, ``rho0``, ``seed``, ``dtype``) --
        the backend's tunables are constructor arguments, not fit() kwargs.

    Returns
    -------
    self : AMICA
        Fitted model
    """
    # Validate input
    if X.ndim != 2:
        raise ValueError(f"X must be 2D array, got shape {X.shape}")

    n_channels, n_samples = X.shape

    if self.verbose:
        print(f"Fitting AMICA with {n_channels} channels, {n_samples} samples")
        print(f"Models: {self.n_models}, Mixture components: {self.n_mix}")

    # Setup device (with the MPS/float64 parity fallback, see _select_device).
    device = self._select_device(kwargs.get("dtype", _NG_DEFAULT_DTYPE))

    # Build and train the backend on a LOCAL reference first, and only
    # publish it to self (and derive the fitted-state attributes) once
    # fit() returns. If the backend constructor or fit() raises mid-training
    # (a numerical crash, OOM, singular sphere, interrupt, ...), self is left
    # untouched: a first fit keeps model_ is None (so the output methods
    # raise a clean "not fitted"), and a refit keeps the previous, known-good
    # model rather than a half-trained one falsely marked usable (issue #50
    # silent-failure review).
    backend = AMICATorchNG(
        n_channels=n_channels,
        n_models=self.n_models,
        n_mix=self.n_mix,
        lrate=lrate,
        do_mean=do_mean,
        do_sphere=do_sphere,
        do_newton=do_newton,
        device=device,
        **kwargs,
    )
    backend.fit(X, max_iter=max_iter, verbose=self.verbose)

    self.model_ = backend
    self.ll_history_ = backend.ll_history
    self.final_ll_ = backend.final_ll_
    self.stop_reason_ = backend.stop_reason
    self.converged_ = self.stop_reason_ not in AMICATorchNG._DEGENERATE_STOP_REASONS
    # A degenerate fit (nan_ll/singular_ll) holds non-finite parameters and
    # would return NaN sources, so it is not a usable model: is_fitted_ stays
    # False and the output methods refuse it (issue #50). stop_reason_/
    # converged_ stay set for inspection.
    self.is_fitted_ = self.converged_
    if not self.converged_:
        logger.warning(
            "AMICA.fit ended degenerate (stop_reason=%r) at iteration %d: the "
            "model holds non-finite parameters and cannot transform. Inspect "
            "stop_reason_/ll_history_; lower lrate, disable Newton, or check "
            "data conditioning, then refit.",
            self.stop_reason_,
            backend.iteration,
        )

    return self

transform(X, model_idx=0)

Transform data to source space.

Parameters:

Name Type Description Default
X ndarray

Input data of shape (n_channels, n_samples)

required
model_idx int

Which model to use for transformation

0

Returns:

Name Type Description
S ndarray

Sources of shape (n_sources, n_samples)

Source code in pyAMICA/amica.py
def transform(self, X: np.ndarray, model_idx: int = 0) -> np.ndarray:
    """
    Transform data to source space.

    Parameters
    ----------
    X : np.ndarray
        Input data of shape (n_channels, n_samples)
    model_idx : int, default=0
        Which model to use for transformation

    Returns
    -------
    S : np.ndarray
        Sources of shape (n_sources, n_samples)
    """
    self._check_usable("transform")
    assert self.model_ is not None

    return self.model_.transform(X, model_idx=model_idx)

fit_transform(X, **fit_params)

Fit model and transform data.

Parameters:

Name Type Description Default
X ndarray

Input data of shape (n_channels, n_samples)

required
**fit_params

Parameters passed to fit()

{}

Returns:

Name Type Description
S ndarray

Sources of shape (n_sources, n_samples)

Source code in pyAMICA/amica.py
def fit_transform(self, X: np.ndarray, **fit_params) -> np.ndarray:
    """
    Fit model and transform data.

    Parameters
    ----------
    X : np.ndarray
        Input data of shape (n_channels, n_samples)
    **fit_params
        Parameters passed to fit()

    Returns
    -------
    S : np.ndarray
        Sources of shape (n_sources, n_samples)
    """
    self.fit(X, **fit_params)
    return self.transform(X)

get_mixing_matrix(model_idx=0)

Get the mixing matrix A.

Parameters:

Name Type Description Default
model_idx int

Which model's mixing matrix to return

0

Returns:

Name Type Description
A ndarray

Mixing matrix of shape (n_channels, n_sources)

Source code in pyAMICA/amica.py
def get_mixing_matrix(self, model_idx: int = 0) -> np.ndarray:
    """
    Get the mixing matrix A.

    Parameters
    ----------
    model_idx : int, default=0
        Which model's mixing matrix to return

    Returns
    -------
    A : np.ndarray
        Mixing matrix of shape (n_channels, n_sources)
    """
    self._check_usable("get the mixing matrix")
    assert self.model_ is not None

    return self.model_.get_mixing_matrix(model_idx=model_idx)

get_unmixing_matrix(model_idx=0)

Get the unmixing matrix W.

Parameters:

Name Type Description Default
model_idx int

Which model's unmixing matrix to return

0

Returns:

Name Type Description
W ndarray

Unmixing matrix of shape (n_sources, n_channels)

Source code in pyAMICA/amica.py
def get_unmixing_matrix(self, model_idx: int = 0) -> np.ndarray:
    """
    Get the unmixing matrix W.

    Parameters
    ----------
    model_idx : int, default=0
        Which model's unmixing matrix to return

    Returns
    -------
    W : np.ndarray
        Unmixing matrix of shape (n_sources, n_channels)
    """
    self._check_usable("get the unmixing matrix")
    assert self.model_ is not None

    return self.model_.get_unmixing_matrix(model_idx=model_idx)

variance_order(model_idx=0, return_svar=False)

Component order by EEGLAB back-projected variance (IC1 = highest).

Reports the display order EEGLAB's loadmodout15.m applies on load, without mutating the fitted parameters. Apply it to the columns of :meth:get_mixing_matrix (or rows of :meth:get_unmixing_matrix) to get EEGLAB-ordered components in Python.

Parameters:

Name Type Description Default
model_idx int

Which model's components to order.

0
return_svar bool

If True, also return the per-source variance sorted to order.

False

Returns:

Name Type Description
order np.ndarray of int

Source indices, highest back-projected variance first.

Source code in pyAMICA/amica.py
def variance_order(
    self, model_idx: int = 0, return_svar: bool = False
) -> Union[np.ndarray, tuple]:
    """
    Component order by EEGLAB back-projected variance (IC1 = highest).

    Reports the display order EEGLAB's ``loadmodout15.m`` applies on load,
    without mutating the fitted parameters. Apply it to the columns of
    :meth:`get_mixing_matrix` (or rows of :meth:`get_unmixing_matrix`) to get
    EEGLAB-ordered components in Python.

    Parameters
    ----------
    model_idx : int, default=0
        Which model's components to order.
    return_svar : bool, default=False
        If True, also return the per-source variance sorted to ``order``.

    Returns
    -------
    order : np.ndarray of int
        Source indices, highest back-projected variance first.
    """
    self._check_usable("compute the variance order")
    assert self.model_ is not None

    return self.model_.variance_order(model_idx=model_idx, return_svar=return_svar)

write_amica_output(outdir)

Write the fitted model as an EEGLAB-readable AMICA output directory.

Emits the raw binary files that EEGLAB's loadmodout15.m reads (W, S, gm, mean, c, alpha, mu, sbeta, rho, comp_list, LL), so a pyAMICA fit drops directly into an EEGLAB workflow (mod = loadmodout15(outdir)). loadmodout15 applies the variance-ordering and normalization on load, so no manual re-ordering or sign-flipping is needed. Single-model output is byte-compatible with the Fortran reference (issue #92).

Parameters:

Name Type Description Default
outdir str

Destination directory (created if absent).

required
Source code in pyAMICA/amica.py
def write_amica_output(self, outdir: str) -> None:
    """
    Write the fitted model as an EEGLAB-readable AMICA output directory.

    Emits the raw binary files that EEGLAB's ``loadmodout15.m`` reads (``W``,
    ``S``, ``gm``, ``mean``, ``c``, ``alpha``, ``mu``, ``sbeta``, ``rho``,
    ``comp_list``, ``LL``), so a pyAMICA fit drops directly into an EEGLAB
    workflow (``mod = loadmodout15(outdir)``). ``loadmodout15`` applies the
    variance-ordering and normalization on load, so no manual re-ordering or
    sign-flipping is needed. Single-model output is byte-compatible with the
    Fortran reference (issue #92).

    Parameters
    ----------
    outdir : str
        Destination directory (created if absent).
    """
    self._check_usable("write EEGLAB output")
    assert self.model_ is not None

    self.model_.write_amica_output(outdir)

save(filepath)

Save the fitted model to filepath via torch.save.

Persists the underlying :class:AMICATorchNG state (config + fitted tensors) plus the wrapper's own configuration, so :meth:load can fully reconstruct a transform-ready model. Everything written is a tensor or plain Python primitive (see :meth:AMICATorchNG.state_dict), so it reloads with weights_only=True.

Parameters:

Name Type Description Default
filepath str

Destination path (a .pt file by convention).

required
Source code in pyAMICA/amica.py
def save(self, filepath: str) -> None:
    """
    Save the fitted model to ``filepath`` via ``torch.save``.

    Persists the underlying :class:`AMICATorchNG` state (config + fitted
    tensors) plus the wrapper's own configuration, so :meth:`load` can
    fully reconstruct a transform-ready model. Everything written is a
    tensor or plain Python primitive (see
    :meth:`AMICATorchNG.state_dict`), so it reloads with
    ``weights_only=True``.

    Parameters
    ----------
    filepath : str
        Destination path (a ``.pt`` file by convention).
    """
    self._check_usable("save")
    assert self.model_ is not None

    payload = {
        "format_version": 1,
        "wrapper": {
            "n_models": self.n_models,
            "n_mix": self.n_mix,
            "verbose": self.verbose,
        },
        "backend": self.model_.state_dict(),
    }
    torch.save(payload, filepath)

load(filepath, device=None) classmethod

Load a fitted model saved by :meth:save.

Parameters:

Name Type Description Default
filepath str

Path to a file written by :meth:save.

required
device str or device

Device to place the restored model on. With None (auto), the same MPS/float64 fallback as :meth:fit applies so a float64 parity model never lands on MPS.

None

Returns:

Name Type Description
amica AMICA

A fitted model ready for :meth:transform / :meth:get_mixing_matrix.

Source code in pyAMICA/amica.py
@classmethod
def load(
    cls, filepath: str, device: Optional[Union[str, torch.device]] = None
) -> "AMICA":
    """
    Load a fitted model saved by :meth:`save`.

    Parameters
    ----------
    filepath : str
        Path to a file written by :meth:`save`.
    device : str or torch.device, optional
        Device to place the restored model on. With ``None`` (auto), the
        same MPS/float64 fallback as :meth:`fit` applies so a float64
        parity model never lands on MPS.

    Returns
    -------
    amica : AMICA
        A fitted model ready for :meth:`transform` / :meth:`get_mixing_matrix`.
    """
    payload = torch.load(filepath, weights_only=True)
    version = payload.get("format_version")
    if version != 1:
        raise ValueError(
            f"unsupported AMICA save format_version: {version!r} (expected 1)"
        )
    for key in ("wrapper", "backend"):
        if key not in payload:
            raise ValueError(
                f"malformed AMICA save file {filepath!r}: missing {key!r} "
                f"(format_version={version}); the file may be truncated or "
                f"corrupted."
            )

    wrapper = payload["wrapper"]
    model = cls(
        n_models=wrapper["n_models"],
        n_mix=wrapper["n_mix"],
        device=device,
        verbose=wrapper["verbose"],
    )

    # Resolve the device using the persisted backend dtype so the same
    # MPS/float64 fallback as fit() applies to an auto-selected device.
    ng_dtype = getattr(torch, payload["backend"]["config"]["dtype"])
    resolved_device = model._select_device(ng_dtype)
    model.model_ = AMICATorchNG.from_state_dict(
        payload["backend"], device=resolved_device
    )
    model.ll_history_ = model.model_.ll_history
    model.final_ll_ = model.model_.final_ll_
    # state_dict() refuses to serialize a degenerate model, so a loaded model
    # is always usable; carry its stop_reason through for inspection anyway.
    model.stop_reason_ = model.model_.stop_reason
    model.converged_ = (
        model.stop_reason_ not in AMICATorchNG._DEGENERATE_STOP_REASONS
    )
    model.is_fitted_ = model.converged_
    return model

from_params_file(params_file, **kwargs) classmethod

Create AMICA instance from parameter file.

Parameters:

Name Type Description Default
params_file str

Path to JSON parameter file

required
**kwargs

Additional parameters to override

{}

Returns:

Name Type Description
amica AMICA

Configured AMICA instance

Source code in pyAMICA/amica.py
@classmethod
def from_params_file(cls, params_file: str, **kwargs) -> "AMICA":
    """
    Create AMICA instance from parameter file.

    Parameters
    ----------
    params_file : str
        Path to JSON parameter file
    **kwargs
        Additional parameters to override

    Returns
    -------
    amica : AMICA
        Configured AMICA instance
    """
    import json

    with open(params_file, "r") as f:
        params = json.load(f)

    # Extract relevant parameters
    n_models = params.get("num_models", 1)
    n_mix = params.get("num_mix", 3)

    # Override with kwargs
    n_models = kwargs.pop("n_models", n_models)
    n_mix = kwargs.pop("n_mix", n_mix)

    return cls(n_models=n_models, n_mix=n_mix, **kwargs)