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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pamica/
│ ├── core.py # Natural-gradient EM port (AMICATorchNG); Fortran-parity, primary backend
│ └── utils.py # Preprocessing (sphering, PCA), device selection
├── mlx_impl/ # Optional MLX backend (Apple GPU; AMICAMLXNG, #76/#81)
│ └── core.py # float32 GPU E/M-step + CPU-stream linalg (single- & multi-model GG, NG)
│ └── core.py # float32 GPU E/M-step + CPU-stream linalg; all five pdf families, full torch-equivalent surface (epic #278)
├── numpy_impl/ # Legacy NumPy reference (topic-named modules, issue #34)
│ ├── core.py # AMICA_NumPy (incl. inlined Newton); pdf.py, data.py, load.py, viz.py, utils.py, cli.py
│ └── ...
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ The wrapper auto-selects a device and computes in float64 for Fortran parity.
- float32 (about 7 significant digits, not parity) is required on the Apple GPUs
and modestly faster on CPU; it is not a general speedup, since CUDA is
overhead-bound (float32 is about as fast as float64).
- On Apple Silicon the MLX backend is the fastest option; import it explicitly.
- On Apple Silicon the MLX backend is the fastest option and carries the full
feature surface (all pdf families, Newton, rejection, EEGLAB export, and
Mutual Information Reduction (MIR) diagnostics); import it explicitly.

```python
AMICA(device="cuda").fit(X) # NVIDIA GPU, float64
Expand Down
7 changes: 6 additions & 1 deletion docs/guides/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ interface, plus an optional Apple-GPU backend and a legacy NumPy reference.

The `AMICA` wrapper uses `AMICATorchNG`. The MLX backend is imported separately
(`from pamica.mlx_impl import AMICAMLXNG`) so that `import pamica` never
requires MLX.
requires MLX. As of epic #278 the MLX backend carries the full
`AMICATorchNG`-equivalent surface (all pdf families, Newton, rejection,
persistence, EEGLAB export, MIR/PMI); the wrapper conveniences -- among them
the `from_params_file` reader -- remain PyTorch-only (issue #313 tracks
backend selection there). See the
[backend table](amica-differences.md#backend-differences).

## Device selection

Expand Down
15 changes: 15 additions & 0 deletions docs/guides/eeglab.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ This writes the raw binary files EEGLAB's AMICA loader reads:
For a single model the bytes are identical to the reference Fortran binary's
`amicaout` files, so the directory is interchangeable with a native AMICA run.

The MLX backend writes the same directory: an Apple-Silicon fit exports to
EEGLAB directly, with no torch round trip (epic #278). The export is
validated against a torch twin in the test suite to float32 precision
(max abs diff < 1e-5; `comp_list` is the only field compared exactly --
MLX computes in float32, so its files are not bit-identical to a float64
export the way a torch fit's are to Fortran's):

```python
from pamica.mlx_impl import AMICAMLXNG # requires the mlx extra

model = AMICAMLXNG(n_channels=X.shape[0], n_mix=3)
model.fit(X) # X is (n_channels, n_samples)
model.write_amica_output("amicaout")
```

`LLt` is what `loadmodout15.m` turns into `Lht`/`Lt` and the model-probability
odds `v`; it is written after a fresh `fit()`, and omitted (with a warning) for
a model restored from `load()`, which carries no E-step stash. Under
Expand Down
2 changes: 1 addition & 1 deletion pamica/mlx_impl/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ def _safe_int_cast(name: str, value: np.ndarray, dtype) -> np.ndarray:


class AMICAMLXNG:
"""MLX natural-gradient EM backend (GG, single- and multi-model; #76/#81).
"""MLX natural-gradient EM backend, full torch-equivalent surface (#76/#81, epic #278).

Parameters mirror the subset of :class:`AMICATorchNG` that is supported;
the same ``seed`` produces the same initial parameters as the PyTorch/NumPy
Expand Down
13 changes: 8 additions & 5 deletions pamica/tests/mlx_tests/test_mlx_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

Round-trips through the shared :func:`pamica.numpy_impl.load.loadmodout`
reader (the same reader real EEGLAB output is validated against, issue
#159), plus a torch-twin byte comparison: a float64 ``AMICATorchNG`` built
#159), plus a torch-twin file comparison: a float64 ``AMICATorchNG`` built
from the exact fitted MLX state (the ``_torch_twin`` pattern from
``pamica/tests/test_mlx_sharing_cross_backend.py``) exports the same
parameters, so the two on-disk files can be diffed directly. This embeds an
parameters, so the on-disk files agree to float32 precision (only the
integer ``comp_list`` is compared exactly). This embeds an
inline torch comparison in an ``mlx_tests`` file, the same precedent
``test_mlx_backend.py``/``test_mlx_sharing.py`` already set for non-drift-
guard torch comparisons; the anti-drift AGREEMENT pin for ``do_reject``
Expand Down Expand Up @@ -85,7 +86,7 @@ def test_write_amica_output_round_trips_through_loadmodout(
# #159), so only order-independent quantities are checked through this
# reader -- exact byte-level reproduction (raw files, no reordering) is
# pinned separately below, against a torch twin
# (test_export_is_byte_compatible_with_a_torch_twin), mirroring
# (test_export_matches_a_torch_twin_to_float32_precision), mirroring
# torch_tests/test_amica_ng_wrapper.py's split between
# test_write_amica_output_bytes (raw bytes) and
# test_write_amica_output_loadmodout_readable (order-independent, via
Expand Down Expand Up @@ -277,7 +278,7 @@ def test_write_amica_output_makes_no_extra_forward_pass(
assert (tmp_path / "amicaout" / "LLt").exists()


# --- torch-twin byte comparison (W layout, single-model Fortran parity) ----
# --- torch-twin file comparison (W layout, single-model Fortran parity) ----
def _torch_twin(model, sphere_np: np.ndarray):
"""A float64 AMICATorchNG holding ``model``'s exact fitted state, for a
direct file-level export comparison. Mirrors
Expand Down Expand Up @@ -334,7 +335,9 @@ def _torch_twin(model, sphere_np: np.ndarray):


@pytest.mark.parametrize("n_models", [1, 2])
def test_export_is_byte_compatible_with_a_torch_twin(real_data, tmp_path, n_models):
def test_export_matches_a_torch_twin_to_float32_precision(
real_data, tmp_path, n_models
):
m = _model(n_models=n_models, seed=42, keep_best=False)
m.fit(real_data, max_iter=6, verbose=False)
assert m._sphere_np is not None
Expand Down
Loading