diff --git a/.context/decisions/0001-torch-backend-natural-gradient-em.md b/.context/decisions/0001-torch-backend-natural-gradient-em.md index b65385e..dbcc6aa 100644 --- a/.context/decisions/0001-torch-backend-natural-gradient-em.md +++ b/.context/decisions/0001-torch-backend-natural-gradient-em.md @@ -11,7 +11,7 @@ The default PyTorch backend (`torch_impl/amica_torch.py`, and the experimental reparameterized tensors" (`softmax`/`exp`/`sigmoid` on `alpha`, `beta`, `rho`), driven by autograd. AMICA is fundamentally an EM / natural-gradient fixed-point iteration with closed-form sufficient-statistic updates; the Fortran reference (`amica17.f90`) and the legacy -NumPy `pyAMICA.py` implement it that way. The reframing follows a different optimization +NumPy `pamica.py` implement it that way. The reframing follows a different optimization trajectory and converges to a different fixed point, which is the primary cause of the poor component correlation with Fortran (~0.46-0.9 vs target >0.95). It also blocks scaling: the autograd path materializes all-sample intermediates and retains the graph, so peak memory grows @@ -23,7 +23,7 @@ binary is the definition of done for this project. Rewrite the PyTorch backend as a direct, vectorized port of the NumPy/Fortran natural-gradient (and Newton) update rules. Parameterize `W` directly (no `pinv(A.T).T` per forward), broadcast the E-step over `(model, mix, source, block)` instead of Python loops, accumulate sufficient -statistics block-wise, and drop Adam/autograd for the parameter updates. The NumPy `pyAMICA.py` +statistics block-wise, and drop Adam/autograd for the parameter updates. The NumPy `pamica.py` `_get_block_updates` / `_update_parameters` are the reference spec; validation runs in float64. ## Consequences @@ -60,7 +60,7 @@ partition matching is tracked in #27, adaptive-PDF in #26. - Fortran LL normalization: `amica17.f90:1866` (`LL = LLtmp2 / (num_samples*nw)`). - Fortran per-source mixture reduction: `amica17.f90:1313-1360`. -- NumPy reference updates: `pyAMICA.py:505-730` (block updates, natural-gradient + Newton). +- NumPy reference updates: `pamica.py:505-730` (block updates, natural-gradient + Newton). - Bug catalog and perf/memory analysis: `.context/research.md` (2026-07-02 design review). ## Addendum (2026-07-04, issue #32) diff --git a/.context/decisions/0002-adaptive-pdf-families.md b/.context/decisions/0002-adaptive-pdf-families.md index 15addbd..8faa5d2 100644 --- a/.context/decisions/0002-adaptive-pdf-families.md +++ b/.context/decisions/0002-adaptive-pdf-families.md @@ -24,7 +24,7 @@ sub-Gaussian cosh+, and `pdftype=1` = the extended-Infomax adaptive switcher (Fo sub-Gaussian (code 4) cosh densities by kurtosis sign on the `kurt_start`/`num_kurt`/ `kurt_int` schedule. `rho` is frozen for every non-GG family (`amica15.f90:3682`), and the single-component families 1/4 require `n_mix=1`. The ground-truth `amica15.f90`/ -`amica15_header.f90` are copied into `pyAMICA/`. +`amica15_header.f90` are copied into `pamica/`. ## Consequences @@ -51,7 +51,7 @@ single-component families 1/4 require `n_mix=1`. The ground-truth `amica15.f90`/ ## Receipts -- `pyAMICA/amica15.f90` select-cases at :1277 (likelihood) / :1449 (score); `dorho=.false.` +- `pamica/amica15.f90` select-cases at :1277 (likelihood) / :1449 (score); `dorho=.false.` at :3682; `do_choose_pdfs` at :594; the `m2sum`/`m4sum` moment buffers are allocated/zeroed (:590-591) but never accumulated, confirming the dynamic switch is dead code in the binary. - The extended-Infomax intent comes from the upstream AMICA MATLAB wrapper `runamica15.m` @@ -63,6 +63,6 @@ single-component families 1/4 require `n_mix=1`. The ground-truth `amica15.f90`/ ``` and defaults `pdftype=0; kurt_start=3; num_kurt=5; kurt_int=1;`. The super/sub-Gaussian scores `y +/- tanh(y)` (amica15 codes 1/4) are the classic extended-Infomax nonlinearities. -- `pyAMICA/torch_impl/amica_torch_ng.py`: `_log_pdf_and_deriv`, `_score`, `_choose_pdfs`. -- `pyAMICA/tests/torch_tests/test_ng_pdf_families.py` (formula parity + real-data + opt-in +- `pamica/torch_impl/amica_torch_ng.py`: `_log_pdf_and_deriv`, `_score`, `_choose_pdfs`. +- `pamica/tests/torch_tests/test_ng_pdf_families.py` (formula parity + real-data + opt-in binary integration behind `AMICA_RUN_FORTRAN=1`). diff --git a/.context/decisions/0003-best-iterate-safeguard.md b/.context/decisions/0003-best-iterate-safeguard.md index 4a5f40f..5c6e53c 100644 --- a/.context/decisions/0003-best-iterate-safeguard.md +++ b/.context/decisions/0003-best-iterate-safeguard.md @@ -70,8 +70,8 @@ changes across iterations and per-iteration LLs are not comparable. ## Receipts -- `pyAMICA/torch_impl/amica_torch_ng.py` (`keep_best`, `_snapshot_params`/ +- `pamica/torch_impl/amica_torch_ng.py` (`keep_best`, `_snapshot_params`/ `_restore_params`, `final_ll_`, `_KEEP_BEST_TOL`). -- `pyAMICA/tests/torch_tests/test_ng_backend.py::test_keep_best_*`. +- `pamica/tests/torch_tests/test_ng_backend.py::test_keep_best_*`. - `.context/issue-51/ensemble_ll.py` (Fortran-vs-NG LL ensemble, real data). -- Fortran schedule: `pyAMICA/amica15.f90:1038-1058` (anneal-on-decrease). +- Fortran schedule: `pamica/amica15.f90:1038-1058` (anneal-on-decrease). diff --git a/.context/ideas.md b/.context/ideas.md index 9556820..76e5739 100644 --- a/.context/ideas.md +++ b/.context/ideas.md @@ -1,4 +1,4 @@ -# pyAMICA Design Ideas +# pamica Design Ideas High-level PyTorch design decisions and library options for the AMICA port. diff --git a/.context/issue-136/matlab_viz_verification.md b/.context/issue-136/matlab_viz_verification.md index 83d26bb..3a39269 100644 --- a/.context/issue-136/matlab_viz_verification.md +++ b/.context/issue-136/matlab_viz_verification.md @@ -37,7 +37,7 @@ MATLAB cannot run in CI, so this is recorded rather than automated. `pop_topohistplot.m` and `pop_modPMI.m` carry explicit **GPL-2.0-or-later** headers (Copyright Ozgur Baklan, SCCN, INC, UCSD). `modprobplot.m`, `minfojp.m`, `LLt2v.m`, `smooth_amica_prob.m` carry no header but sit inside that GPL plugin, so they are -conservatively GPL too. pyAMICA is BSD-3-Clause, which is why Phase 2's PMI was a +conservatively GPL too. pamica is BSD-3-Clause, which is why Phase 2's PMI was a clean-room reimplementation. **Posture: run-and-observe only. No `.m` implementation source was read at any point.** @@ -77,10 +77,10 @@ Notes on the two correlation-rather-than-equality rows: Side-by-side renders on the same data are committed here: -- `cmp_modprob.png` — MATLAB `modprobplot` vs `pyAMICA.viz.plot_model_probability` +- `cmp_modprob.png` — MATLAB `modprobplot` vs `pamica.viz.plot_model_probability` (1 s smoothing). Same two stacked panels, same switching times (~1.4, 3.4, 6.5, 9.3, 12.5, 15.7 s), same log-likelihood trace and range (-105 to -125), seconds axis. -- `cmp_pmi.png` — MATLAB `pop_modPMI` vs `pyAMICA.viz.plot_pmi_heatmap`, rendered on +- `cmp_pmi.png` — MATLAB `pop_modPMI` vs `pamica.viz.plot_pmi_heatmap`, rendered on **identical signals** (MATLAB's own `EEG.icaact`) so the comparison isolates the estimator and the ordering. Both show the same dependent-subspace cluster near the centre with the same radiating cross pattern, at the same MI scale. @@ -193,7 +193,7 @@ algorithm (ours greedy nearest-neighbour chain vs MATLAB's iterative cost minimi must agree, since exact-equality dispatch sends them down different branches). 4. **postAmicaUtility's own `loadmodout15.m` is broken on R2025b** (`Unmatched ']'`, - line 120). pyAMICA's bundled copy works. `addpath` `pyAMICA/sample_data` LAST so ours + line 120). pamica's bundled copy works. `addpath` `pamica/sample_data` LAST so ours shadows theirs, or the model never loads and every plot fails with a misleading "No AMICA solution found". 5. **`pop_topohistplot` is broken upstream** (`Unrecognized function or variable @@ -216,7 +216,7 @@ git clone --depth 1 https://github.com/sccn/postAmicaUtility.git # GPL: run, git clone --depth 1 https://github.com/bigdelys/pre_ICA_cleaning.git # Apache-2.0: getMIR.m ``` -Then: `addpath(genpath(eeglab)); addpath(postAmicaUtility); addpath(pyAMICA/sample_data)` +Then: `addpath(genpath(eeglab)); addpath(postAmicaUtility); addpath(pamica/sample_data)` (last, per trap 4); `eeglab nogui`; `pop_loadset` + `pop_loadmodout(EEG, )`; `modprobplot(EEG, 1:num_models, smooth_sec, [])` returns `[v2plot, llt2plot]` — the exact series it draws — and `pop_modPMI(EEG, 'models2plot', 1, 'order', true)` writes diff --git a/.context/issue-144-parity-data-adequacy/bundled_sample_newton0.py b/.context/issue-144-parity-data-adequacy/bundled_sample_newton0.py index 72b4819..c9f9044 100644 --- a/.context/issue-144-parity-data-adequacy/bundled_sample_newton0.py +++ b/.context/issue-144-parity-data-adequacy/bundled_sample_newton0.py @@ -29,8 +29,8 @@ HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] sys.path.insert(0, str(REPO)) -from pyAMICA import AMICA # noqa: E402 -from pyAMICA.torch_impl.utils import load_eeglab_data # noqa: E402 +from pamica import AMICA # noqa: E402 +from pamica.torch_impl.utils import load_eeglab_data # noqa: E402 def xcorr(Wa, Wb): @@ -62,9 +62,9 @@ def amari_distance(Wa, Wb): def run_fortran(data_dim, max_iter, seed, work_dir): work_dir.mkdir(parents=True, exist_ok=True) shutil.copy( - REPO / "pyAMICA/sample_data/eeglab_data.fdt", work_dir / "eeglab_data.fdt" + REPO / "pamica/sample_data/eeglab_data.fdt", work_dir / "eeglab_data.fdt" ) - template = (REPO / "pyAMICA/sample_data/input.param").read_text().splitlines() + template = (REPO / "pamica/sample_data/input.param").read_text().splitlines() lines = [] for line in template: if line.startswith("files"): @@ -81,7 +81,7 @@ def run_fortran(data_dim, max_iter, seed, work_dir): (work_dir / "fortran_output").mkdir(exist_ok=True) result = subprocess.run( - [str(REPO / "pyAMICA/sample_data/amica15mac"), "input.param"], + [str(REPO / "pamica/sample_data/amica15mac"), "input.param"], cwd=work_dir, capture_output=True, text=True, @@ -116,13 +116,13 @@ def main(): ) seeds = list(range(301, 301 + n_seeds)) - with open(REPO / "pyAMICA/sample_data/sample_params.json") as f: + with open(REPO / "pamica/sample_data/sample_params.json") as f: params = json.load(f) data_dim = params["data_dim"] field_dim = params["field_dim"][0] data = load_eeglab_data( - str(REPO / "pyAMICA/sample_data/eeglab_data.fdt"), + str(REPO / "pamica/sample_data/eeglab_data.fdt"), data_dim=data_dim, field_dim=field_dim, dtype=np.float32, diff --git a/.context/issue-144-parity-data-adequacy/run_5seed_newton0.sh b/.context/issue-144-parity-data-adequacy/run_5seed_newton0.sh index 54aff08..1cf674d 100644 --- a/.context/issue-144-parity-data-adequacy/run_5seed_newton0.sh +++ b/.context/issue-144-parity-data-adequacy/run_5seed_newton0.sh @@ -10,7 +10,7 @@ # the NEXT seed's Fortran starts immediately -- CPU and GPU work overlap # since they don't compete for the same resource. set -e -cd ~/pyAMICA-issue144 +cd ~/pamica-issue144 NPY=benchmarks/data/ds002718_sub-002_eeg70_full.npy SCRIPTS=.context/issue-144-parity-data-adequacy diff --git a/.context/issue-144-parity-data-adequacy/run_fortran_only.py b/.context/issue-144-parity-data-adequacy/run_fortran_only.py index 3924da8..ba0b7e6 100644 --- a/.context/issue-144-parity-data-adequacy/run_fortran_only.py +++ b/.context/issue-144-parity-data-adequacy/run_fortran_only.py @@ -15,8 +15,8 @@ HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] -BIN = REPO / "pyAMICA/sample_data/amica15_linux" -INPUT_PARAM = REPO / "pyAMICA/sample_data/input.param" +BIN = REPO / "pamica/sample_data/amica15_linux" +INPUT_PARAM = REPO / "pamica/sample_data/input.param" def write_fdt(data: np.ndarray, path: Path) -> None: diff --git a/.context/issue-144-parity-data-adequacy/run_ng_only.py b/.context/issue-144-parity-data-adequacy/run_ng_only.py index 448af6c..48d37e3 100644 --- a/.context/issue-144-parity-data-adequacy/run_ng_only.py +++ b/.context/issue-144-parity-data-adequacy/run_ng_only.py @@ -14,7 +14,7 @@ HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] sys.path.insert(0, str(REPO)) -from pyAMICA.torch_impl import AMICATorchNG # noqa: E402 +from pamica.torch_impl import AMICATorchNG # noqa: E402 def xcorr(Wa, Wb): diff --git a/.context/issue-144-parity-data-adequacy/test_ds002718_32ch.py b/.context/issue-144-parity-data-adequacy/test_ds002718_32ch.py index 534ea9c..d0de8d5 100644 --- a/.context/issue-144-parity-data-adequacy/test_ds002718_32ch.py +++ b/.context/issue-144-parity-data-adequacy/test_ds002718_32ch.py @@ -25,10 +25,10 @@ HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] sys.path.insert(0, str(REPO)) -from pyAMICA.torch_impl import AMICATorchNG # noqa: E402 +from pamica.torch_impl import AMICATorchNG # noqa: E402 -BIN = REPO / "pyAMICA/sample_data/amica15mac" -INPUT_PARAM = REPO / "pyAMICA/sample_data/input.param" +BIN = REPO / "pamica/sample_data/amica15mac" +INPUT_PARAM = REPO / "pamica/sample_data/input.param" def xcorr(Wa, Wb): diff --git a/.context/issue-144-parity-data-adequacy/test_ds002718_hallu.py b/.context/issue-144-parity-data-adequacy/test_ds002718_hallu.py index e2333ce..f1f7659 100644 --- a/.context/issue-144-parity-data-adequacy/test_ds002718_hallu.py +++ b/.context/issue-144-parity-data-adequacy/test_ds002718_hallu.py @@ -19,10 +19,10 @@ HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] sys.path.insert(0, str(REPO)) -from pyAMICA.torch_impl import AMICATorchNG # noqa: E402 +from pamica.torch_impl import AMICATorchNG # noqa: E402 -BIN = REPO / "pyAMICA/sample_data/amica15_linux" -INPUT_PARAM = REPO / "pyAMICA/sample_data/input.param" +BIN = REPO / "pamica/sample_data/amica15_linux" +INPUT_PARAM = REPO / "pamica/sample_data/input.param" def xcorr(Wa, Wb): diff --git a/.context/issue-155/matlab_interop_verification.md b/.context/issue-155/matlab_interop_verification.md index ace83d4..d2e91d7 100644 --- a/.context/issue-155/matlab_interop_verification.md +++ b/.context/issue-155/matlab_interop_verification.md @@ -1,16 +1,16 @@ -# LLt interop verification: pyAMICA <-> EEGLAB `loadmodout15.m` (issue #155, PR #156) +# LLt interop verification: pamica <-> EEGLAB `loadmodout15.m` (issue #155, PR #156) ## The contract The acceptance bar for the `LLt` work is bidirectional interop, not internal self-consistency: -1. **pyAMICA's reader must understand Fortran's output.** -2. **EEGLAB's reader (`loadmodout15.m`) must understand pyAMICA's output.** +1. **pamica's reader must understand Fortran's output.** +2. **EEGLAB's reader (`loadmodout15.m`) must understand pamica's output.** PR #156's automated tests only pin direction 1 (via the real bundled `sample_data/amicaout/LLt` fixture). They do **not** pin direction 2: the -round-trip tests prove pyAMICA's writer agrees with pyAMICA's *own* reader, +round-trip tests prove pamica's writer agrees with pamica's *own* reader, which a self-consistently-wrong writer/reader pair would also satisfy. Only the genuine MATLAB reader can close that. @@ -24,16 +24,16 @@ same way #92's was. ## Method -MATLAB R2025b, real `loadmodout15.m` from `pyAMICA/sample_data/`, run against +MATLAB R2025b, real `loadmodout15.m` from `pamica/sample_data/`, run against three directories: -- `pyamica_m1` -- pyAMICA `AMICATorchNG` single-model fit, real bundled sample +- `pyamica_m1` -- pamica `AMICATorchNG` single-model fit, real bundled sample EEG (`eeglab_data.fdt`, 32ch x 4096 samples), seed 1, 20 iters. - `pyamica_m2` -- same data, 2 models, seed 4, 8 iters (non-trivial `mod_prob`, so the reader's gm-based model reordering is actually exercised). - `amicaout` -- the genuine Fortran-produced fixture bundled in the repo. -Each result was compared against `pyAMICA.numpy_impl.load.loadmodout`'s view of +Each result was compared against `pamica.numpy_impl.load.loadmodout`'s view of the same directory with `np.array_equal` (not `allclose`). ## Result: bit-exact in both directions @@ -43,20 +43,20 @@ pyamica_m1 Lht (1, 4096) equal=True | Lt (4096,) equal=True | max|dH|=0.0 max pyamica_m2 Lht (2, 4096) equal=True | Lt (4096,) equal=True | max|dH|=0.0 max|dT|=0.0 fortran Lht (1, 30504) equal=True | Lt (30504,) equal=True | max|dH|=0.0 max|dT|=0.0 -EEGLAB loadmodout15 == pyAMICA loadmodout, bit-exact, all cases: True +EEGLAB loadmodout15 == pamica loadmodout, bit-exact, all cases: True pyamica_m1 single-model Lht[0]==Lt via MATLAB: True fortran single-model Lht[0]==Lt via MATLAB: True ``` Corroborating details: -- 2-model `mod_prob` agrees: MATLAB `[0.554689 0.445311]` vs pyAMICA +- 2-model `mod_prob` agrees: MATLAB `[0.554689 0.445311]` vs pamica `[0.5547 0.4453]`. Both readers apply the same gm-descending model reordering to `Lht`, so the model axis stays aligned with `W`/`mod_prob` across the two implementations. - Genuine Fortran fixture reads to `Lt mean = -108.859935652062` under BOTH readers, matching `nw * final_ll = 32 * -3.40187` independently. -- MATLAB reads `Lt` as `(1, N)` and pyAMICA as `(N,)`; that is MATLAB's +- MATLAB reads `Lt` as `(1, N)` and pamica as `(N,)`; that is MATLAB's everything-is-2D convention, not a layout difference (values compare equal after `ravel`). @@ -64,7 +64,7 @@ Corroborating details: `loadmodout15.m:119-124` does `fread(...,'double')` then `reshape(LLt, num_models+1, N)`. MATLAB's `reshape` is column-major, so this is -`order="F"`. pyAMICA's writer (`numpy_impl/load.py:write_amicaout`) does +`order="F"`. pamica's writer (`numpy_impl/load.py:write_amicaout`) does `vstack([Lht, Lt]).ravel(order="F")`, and its reader does `reshape(num_models+1, -1, order="F")`. All three agree with Fortran's `write_output` (`amica15.f90:2308-2333`), which writes per timepoint each diff --git a/.context/issue-159/gate_check.py b/.context/issue-159/gate_check.py index cffdbe1..9b6d7eb 100644 --- a/.context/issue-159/gate_check.py +++ b/.context/issue-159/gate_check.py @@ -1,7 +1,7 @@ """MATLAB gate check for issue #159 (run last). Compares Python ``loadmodout`` against MATLAB ``loadmodout15.m`` element-wise for -W/A/sbeta/rho, on the genuine single-model fixture and the pyAMICA 2-model output. +W/A/sbeta/rho, on the genuine single-model fixture and the pamica 2-model output. Both are the same algorithm (Python is a port), so a correct byte order makes them agree to floating-point noise; the pre-#159 C-order read made W disagree by the internal transpose (and A/svar with it), and sbeta/rho disagree for num_mix > 1. diff --git a/.context/issue-159/gate_compare.m b/.context/issue-159/gate_compare.m index a31bc5b..958e96c 100644 --- a/.context/issue-159/gate_compare.m +++ b/.context/issue-159/gate_compare.m @@ -1,13 +1,13 @@ % MATLAB gate for issue #159 (run after gate_prep.py, before gate_check.py). -% Loads the genuine single-model Fortran fixture and the pyAMICA-written 2-model +% Loads the genuine single-model Fortran fixture and the pamica-written 2-model % output with EEGLAB's real loadmodout15.m, and saves W/A/sbeta/rho so the Python % companion can check they match what Python loadmodout read. This is the only -% test that pins direction (2) of the interop contract: EEGLAB reads pyAMICA's -% (and Fortran's) bytes correctly. A pyAMICA write->read round trip cannot. +% test that pins direction (2) of the interop contract: EEGLAB reads pamica's +% (and Fortran's) bytes correctly. A pamica write->read round trip cannot. here = fileparts(mfilename('fullpath')); root = fullfile(here, '..', '..'); -sample = fullfile(root, 'pyAMICA', 'sample_data'); +sample = fullfile(root, 'pamica', 'sample_data'); % addpath sample_data LAST so its working loadmodout15.m shadows any broken % copy elsewhere on the path (postAmicaUtility's has a syntax error on R2025b). diff --git a/.context/issue-159/gate_prep.py b/.context/issue-159/gate_prep.py index 1e90a37..e4bd8be 100644 --- a/.context/issue-159/gate_prep.py +++ b/.context/issue-159/gate_prep.py @@ -1,6 +1,6 @@ """MATLAB gate prep for issue #159 (run first). -Writes a real 2-model pyAMICA output, then dumps what Python ``loadmodout`` +Writes a real 2-model pamica output, then dumps what Python ``loadmodout`` reads for W/A/sbeta/rho on (a) the genuine single-model Fortran fixture and (b) that 2-model output. ``gate_compare.m`` then loads the same two directories with EEGLAB's ``loadmodout15.m`` and this script's companion assertion (run last) @@ -12,13 +12,13 @@ import os import numpy as np -from pyAMICA import AMICA -from pyAMICA.numpy_impl.load import loadmodout -from pyAMICA.torch_impl.utils import load_eeglab_data +from pamica import AMICA +from pamica.numpy_impl.load import loadmodout +from pamica.torch_impl.utils import load_eeglab_data HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) -SAMPLE = os.path.join(ROOT, "pyAMICA", "sample_data") +SAMPLE = os.path.join(ROOT, "pamica", "sample_data") FIXTURE = os.path.join(SAMPLE, "amicaout") TWO_MODEL = os.path.join(HERE, "gate_2model") diff --git a/.context/issue-159/matlab_interop_verification.md b/.context/issue-159/matlab_interop_verification.md index 7676e2d..f57a3ce 100644 --- a/.context/issue-159/matlab_interop_verification.md +++ b/.context/issue-159/matlab_interop_verification.md @@ -1,14 +1,14 @@ # Issue #159 -- MATLAB interop verification (W / A / sbeta / rho) Direction (2) of the interop contract: EEGLAB's real `loadmodout15.m` reads -pyAMICA's (and Fortran's) bytes correctly. A pyAMICA write->read round trip +pamica's (and Fortran's) bytes correctly. A pamica write->read round trip cannot pin this -- it cancels the byte-order error -- so this is a manual MATLAB run, recorded here (MATLAB is not on PATH and not in CI). ## Setup - MATLAB `R2025b` at `/Volumes/S1/Applications/MATLAB_R2025b.app/bin/matlab`. -- Reader: pyAMICA's own working `pyAMICA/sample_data/loadmodout15.m`, added to the +- Reader: pamica's own working `pamica/sample_data/loadmodout15.m`, added to the path LAST so it shadows any broken copy (postAmicaUtility's has a syntax error on R2025b). `loadmodout15` concatenates paths directly, so the outdir argument is passed with a trailing `filesep`. @@ -17,7 +17,7 @@ run, recorded here (MATLAB is not on PATH and not in CI). `gate_compare.m` (MATLAB `loadmodout15` on the same dirs), `gate_check.py` (element-wise compare). - Inputs: (a) the genuine single-model Fortran fixture `sample_data/amicaout`; - (b) the freshly written pyAMICA 2-model output `.context/issue-159/gate_2model`. + (b) the freshly written pamica 2-model output `.context/issue-159/gate_2model`. ## Result -- GATE PASS diff --git a/.context/issue-21/corrected_mstep_prototype.py b/.context/issue-21/corrected_mstep_prototype.py index c8c70b8..e4ff888 100644 --- a/.context/issue-21/corrected_mstep_prototype.py +++ b/.context/issue-21/corrected_mstep_prototype.py @@ -1,5 +1,5 @@ """VALIDATED reference prototype for the issue #21 fix (do not ship as-is; port -these methods into amica_torch_ng.py and pyAMICA.py -- see handoff.md). +these methods into amica_torch_ng.py and pamica.py -- see handoff.md). `CorrectedNG` subclasses the shipped `AMICATorchNG` and overrides the M-step with the Fortran-faithful version that this session validated: @@ -22,12 +22,12 @@ import numpy as np import torch -from pyAMICA.torch_impl import AMICATorchNG -from pyAMICA.torch_impl.amica_torch_ng import _score -from pyAMICA.torch_impl.utils import load_eeglab_data +from pamica.torch_impl import AMICATorchNG +from pamica.torch_impl.amica_torch_ng import _score +from pamica.torch_impl.utils import load_eeglab_data NW, FIELD, SEED = 32, 30504, 42 -SAMPLE = Path(__file__).resolve().parents[2] / "pyAMICA" / "sample_data" +SAMPLE = Path(__file__).resolve().parents[2] / "pamica" / "sample_data" AO = SAMPLE / "amicaout" diff --git a/.context/issue-21/handoff.md b/.context/issue-21/handoff.md index 2f6e909..9a83037 100644 --- a/.context/issue-21/handoff.md +++ b/.context/issue-21/handoff.md @@ -33,7 +33,7 @@ Fortran; see the correction banner above and `.context/issue-24/findings.md`. - **Issues:** #21 (this work), #24 (Newton bit-parity / init-basin verification via Fortran load_*). - **Env:** UV. Run parity work on CPU/float64: `PYTORCH_ENABLE_MPS_FALLBACK=1 uv run pytest ...`. NG needs `device="cpu"` for float64 (MPS lacks it). -- **Fortran reference solution:** `pyAMICA/sample_data/amicaout/` (200-iter run, LL -3.402). +- **Fortran reference solution:** `pamica/sample_data/amicaout/` (200-iter run, LL -3.402). Raw files (`W`, `S`, `mu`, `sbeta`, `rho`, `alpha`, `gm`, `c`, `mean`) are float64, column-major (`np.fromfile(...).reshape(shape, order="F")`), in Fortran's internal order (NOT re-sorted like loadmodout15.m). @@ -90,7 +90,7 @@ class. Each change with its Fortran line ref: NG == NumPy on the OLD (buggy) accumulator keys (`dmu`, `dbeta`, `dA`). They must become NG-vs-Fortran parity tests (the fixed-point test above is the right shape), because the NumPy reference is equally buggy. Do not "fix" the tests to keep passing against NumPy. -3. **Mirror the fix in `pyAMICA.py`** (same score/exact-EM/source-space/sphere/rho changes; +3. **Mirror the fix in `pamica.py`** (same score/exact-EM/source-space/sphere/rho changes; `_get_block_updates` ~611-755, `_update_parameters` ~757+). Keep NG and NumPy in sync. 4. **Fix `validate_implementations.py::compare_results`** to compare the basis-invariant total spatial filter `W@sphere` (with the transpose), not raw sphered-space `W` rows. @@ -127,5 +127,5 @@ class. Each change with its Fortran line ref: ``` PYTORCH_ENABLE_MPS_FALLBACK=1 uv run python .context/issue-21/reproduce_root_cause.py # score bug PYTORCH_ENABLE_MPS_FALLBACK=1 uv run python .context/issue-21/corrected_mstep_prototype.py # fixed-point + fit -PYTORCH_ENABLE_MPS_FALLBACK=1 uv run pytest pyAMICA/tests/torch_tests/test_ng_backend.py +PYTORCH_ENABLE_MPS_FALLBACK=1 uv run pytest pamica/tests/torch_tests/test_ng_backend.py ``` diff --git a/.context/issue-21/reproduce_root_cause.py b/.context/issue-21/reproduce_root_cause.py index 18ec206..d25a68c 100644 --- a/.context/issue-21/reproduce_root_cause.py +++ b/.context/issue-21/reproduce_root_cause.py @@ -21,11 +21,11 @@ import numpy as np import torch -import pyAMICA.torch_impl.amica_torch_ng as ng_mod -from pyAMICA.torch_impl import AMICATorchNG -from pyAMICA.torch_impl.utils import load_eeglab_data +import pamica.torch_impl.amica_torch_ng as ng_mod +from pamica.torch_impl import AMICATorchNG +from pamica.torch_impl.utils import load_eeglab_data -SAMPLE = Path(__file__).resolve().parents[2] / "pyAMICA" / "sample_data" +SAMPLE = Path(__file__).resolve().parents[2] / "pamica" / "sample_data" NW, FIELD, SEED = 32, 30504, 42 _orig = ng_mod._log_pdf_and_deriv diff --git a/.context/issue-21/root_cause.md b/.context/issue-21/root_cause.md index 2305d47..aa8c67f 100644 --- a/.context/issue-21/root_cause.md +++ b/.context/issue-21/root_cause.md @@ -16,7 +16,7 @@ data scaling, and init are fine; only the M-step *update direction* is broken. This overturns: - the earlier note "Newton is posdef 50/50 / the cause is outside the update equations" (memory `amica-ng-fixed-point-gap`, now corrected); -- AGENTS.md's premise that the NumPy `pyAMICA.py` "is the faithful spec" -- it shares the +- AGENTS.md's premise that the NumPy `pamica.py` "is the faithful spec" -- it shares the bug, which is why `test_ng_backend.py::test_sufficient_stats_match_numpy_reference` passes (it only asserts NG == NumPy, never NG == Fortran). @@ -33,7 +33,7 @@ uses the **score** `fp = rho*sign(y)*|y|^(rho-1)`: Since `p'(y) = -fp(y)*p(y)` for the generalized Gaussian, substituting `dpdf` for `fp` **flips the update sign** -> descent. (Phase 4 already fixed exactly this for the *Newton* -curvature terms, `pyAMICA.py:726` "use the score fp ... NOT dpdf", but never fixed the +curvature terms, `pamica.py:726` "use the score fp ... NOT dpdf", but never fixed the first-order terms.) **Proof:** monkeypatching `_log_pdf_and_deriv` to return `(_log_pdf, _score)` turns @@ -123,6 +123,6 @@ M-step to localize the drift). Fix recipe, ordered: but must be rechecked once the first-order params are on-track). 4. Fortran-faithful lrate control (hold 0.05 in the NG phase, ramp to `newtrate` under Newton, ratchet only after `max_decs`). -5. Apply the same fixes to `pyAMICA.py`; re-baseline `test_ng_backend.py` tests against +5. Apply the same fixes to `pamica.py`; re-baseline `test_ng_backend.py` tests against Fortran, not the (buggy) NumPy reference. 6. Fix the validation-harness metric to compare `W@sphere`. diff --git a/.context/issue-24/drift_localization.md b/.context/issue-24/drift_localization.md index 8e1ec66..004458a 100644 --- a/.context/issue-24/drift_localization.md +++ b/.context/issue-24/drift_localization.md @@ -22,7 +22,7 @@ proven to machine precision and fixed. See `root_cause_Aupdate.py` (self-contain so `corrected_mstep_prototype.py::fixed_point_test` passed while the free-running fit descended. **PORTED TO PRODUCTION & VALIDATED (2026-07-03).** All fixes are in the shipped backends -`torch_impl/amica_torch_ng.py` (`AMICATorchNG`) and `pyAMICA.py` (`AMICA_NumPy`): the A-update +`torch_impl/amica_torch_ng.py` (`AMICATorchNG`) and `pamica.py` (`AMICA_NumPy`): the A-update transpose, exact-EM mu/beta (`fp` not `dpdf`), the digamma rho update (Bugs 1+2), the symmetric-ZCA sphere (cov/N), the Newton-path orientation (`A -= lrate*H^T @ A`), the W/A output transpose (get_*_matrix / transform), and the NumPy Jacobian LL (`sldet` + `logsumexp`, previously positive). @@ -144,13 +144,13 @@ but a non-MKL recompile from this source would compute the wrong score. Flag bef It must be `A_cols - lrate*((I - dWtmp.T/dgm) @ A_cols)` (transpose `dWtmp`, LEFT-multiply), because `A` is stored as Fortran's `A^T`. Proven machine-exact; flips the fit from descending (-3.4974) to ascending (-3.4265, corr 0.648 vs Fortran 0.645). Mirror in shipped - `amica_torch_ng.py::_update_parameters` and `pyAMICA.py`. **Also audit the Newton path**: it + `amica_torch_ng.py::_update_parameters` and `pamica.py`. **Also audit the Newton path**: it builds the Newton direction from the same untransposed `dA_h` and right-multiplies (`corrected_mstep_prototype.py:171-197`), so it needs the matching orientation fix + the `dA(i,k)`/`dA(k,i)` term order (Fortran `:1825`) before Newton is wired in. - [ ] **Bug 1 (rho factor)** -- `corrected_mstep_prototype.py:102-110`: `drho_n` term must be `rho_h * |y|^rho * ln|y|` (add the leading `rho`). Mirror in the shipped - `amica_torch_ng.py` rho path and `pyAMICA.py`. (Confirmed bit-exact with the fix.) + `amica_torch_ng.py` rho path and `pamica.py`. (Confirmed bit-exact with the fix.) - [ ] **Bug 2 (rho mask)** -- `corrected_mstep_prototype.py:104-108`: drop the per-component `(rho!=1)&(rho!=2)` mask; keep only a per-sample underflow guard (Fortran `:1570`). - [ ] **Sphere (cosmetic parity)** -- `corrected_mstep_prototype.py::_preprocess` (and shipped diff --git a/.context/issue-24/localize_drift.py b/.context/issue-24/localize_drift.py index cd6ed94..d19337f 100644 --- a/.context/issue-24/localize_drift.py +++ b/.context/issue-24/localize_drift.py @@ -26,7 +26,7 @@ import torch REPO = Path(__file__).resolve().parents[2] -SAMPLE = REPO / "pyAMICA" / "sample_data" +SAMPLE = REPO / "pamica" / "sample_data" BIN = SAMPLE / "amica15mac" PROTO = REPO / ".context" / "issue-21" / "corrected_mstep_prototype.py" NW, NMIX, FIELD, SEED = 32, 3, 30504, 42 diff --git a/.context/issue-24/root_cause_Aupdate.py b/.context/issue-24/root_cause_Aupdate.py index dde87cb..382bf60 100644 --- a/.context/issue-24/root_cause_Aupdate.py +++ b/.context/issue-24/root_cause_Aupdate.py @@ -34,7 +34,7 @@ import torch REPO = Path(__file__).resolve().parents[2] -SAMPLE = REPO / "pyAMICA" / "sample_data" +SAMPLE = REPO / "pamica" / "sample_data" AO = SAMPLE / "amicaout" BIN = SAMPLE / "amica15mac" PROTO = REPO / ".context" / "issue-21" / "corrected_mstep_prototype.py" diff --git a/.context/issue-24/verify_init_basin.py b/.context/issue-24/verify_init_basin.py index ea78421..d74fc5b 100644 --- a/.context/issue-24/verify_init_basin.py +++ b/.context/issue-24/verify_init_basin.py @@ -43,7 +43,7 @@ from scipy.optimize import linear_sum_assignment REPO = Path(__file__).resolve().parents[2] -SAMPLE = REPO / "pyAMICA" / "sample_data" +SAMPLE = REPO / "pamica" / "sample_data" AO = SAMPLE / "amicaout" BIN = SAMPLE / "amica15mac" PROTO = REPO / ".context" / "issue-21" / "corrected_mstep_prototype.py" @@ -63,7 +63,7 @@ def _import_corrected_ng(): CorrectedNG, load_eeglab_data = _import_corrected_ng() -# Fortran-matched hyperparameters (from pyAMICA/sample_data/input.param). +# Fortran-matched hyperparameters (from pamica/sample_data/input.param). HYPER = dict( n_channels=NW, n_models=1, diff --git a/.context/issue-27/amari_distance.py b/.context/issue-27/amari_distance.py index d1cd29c..3665469 100644 --- a/.context/issue-27/amari_distance.py +++ b/.context/issue-27/amari_distance.py @@ -127,7 +127,7 @@ def per_run_detail(Fs, Gs, metric): between = np.mean([metric(Gs[i], Fs[j]) for j in range(n)]) rows.append( { - "implementation": "pyAMICA", + "implementation": "pamica", "run": i, "within": within, "between": between, @@ -151,14 +151,14 @@ def main(): p = perm_test_not_worse(Fs, Gs, metric, higher_is_worse) summary[name] = { "within_Fortran": {"mean": within_F.mean(), "sd": within_F.std()}, - "within_pyAMICA": {"mean": within_G.mean(), "sd": within_G.std()}, + "within_pamica": {"mean": within_G.mean(), "sd": within_G.std()}, "between": {"mean": between.mean(), "sd": between.std()}, "perm_p_not_worse": p, } print(f"\n==== {name} (N={n} each) ====") for grp, a in [ ("within-Fortran", within_F), - ("within-pyAMICA", within_G), + ("within-pamica", within_G), ("between", between), ]: print(f"{grp:16s} mean={a.mean():.4f} sd={a.std():.4f}") diff --git a/.context/issue-27/amari_summary.json b/.context/issue-27/amari_summary.json index 9302835..f7ece79 100644 --- a/.context/issue-27/amari_summary.json +++ b/.context/issue-27/amari_summary.json @@ -4,7 +4,7 @@ "mean": 0.6381677620538229, "sd": 0.0399667980806766 }, - "within_pyAMICA": { + "within_pamica": { "mean": 0.6607896979339162, "sd": 0.045137442548171064 }, @@ -19,7 +19,7 @@ "mean": 0.17445654208481567, "sd": 0.023308505475839996 }, - "within_pyAMICA": { + "within_pamica": { "mean": 0.15362352165359372, "sd": 0.0192204636370985 }, diff --git a/.context/issue-27/multimodel_c_update.md b/.context/issue-27/multimodel_c_update.md index 4e866be..be1a22a 100644 --- a/.context/issue-27/multimodel_c_update.md +++ b/.context/issue-27/multimodel_c_update.md @@ -3,7 +3,7 @@ ## What was done Ported the update gated by Fortran's `update_c` flag (numerator accumulation at amica17.f90:1423-1429, division at :1899-1901) into both `AMICATorchNG` and the -legacy NumPy `pyAMICA.py`: +legacy NumPy `pamica.py`: - `c[i,h] = dc_numer[i,h] / dc_denom[i,h]`, with `dc_numer[i,h] = sum_t v_h(t) * x(i,t)` (sphered-data space) and diff --git a/.context/issue-27/multimodel_ensemble.py b/.context/issue-27/multimodel_ensemble.py index dd5f8c1..a8a7986 100644 --- a/.context/issue-27/multimodel_ensemble.py +++ b/.context/issue-27/multimodel_ensemble.py @@ -29,19 +29,19 @@ from scipy import stats # noqa: E402 from scipy.optimize import linear_sum_assignment # noqa: E402 -from pyAMICA.torch_impl import AMICATorchNG # noqa: E402 +from pamica.torch_impl import AMICATorchNG # noqa: E402 HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] -BIN = REPO / "pyAMICA/sample_data/amica15mac" -FDT = REPO / "pyAMICA/sample_data/eeglab_data.fdt" -FIXTURE = REPO / "pyAMICA/tests/torch_tests/_ng_e2e_tmp/fortran_run/input.param" +BIN = REPO / "pamica/sample_data/amica15mac" +FDT = REPO / "pamica/sample_data/eeglab_data.fdt" +FIXTURE = REPO / "pamica/tests/torch_tests/_ng_e2e_tmp/fortran_run/input.param" NW, FIELD, MAX_ITER, DELTA = 32, 30504, 100, 0.05 C_FORT, C_NG, C_BET = "#0072B2", "#E69F00", "#009E73" # Okabe-Ito def load_data(): - from pyAMICA.torch_impl.utils import load_eeglab_data + from pamica.torch_impl.utils import load_eeglab_data return load_eeglab_data(str(FDT), data_dim=NW, field_dim=FIELD).astype(np.float64) @@ -175,8 +175,8 @@ def figure(within_F, within_G, between, F_ll, G_ll, diff, p_perm, ks, out): bins = np.linspace(0.5, 1.0, 26) for arr, col, lab in [ (within_F, C_FORT, "within-Fortran"), - (within_G, C_NG, "within-pyAMICA"), - (between, C_BET, "between (pyAMICA-Fortran)"), + (within_G, C_NG, "within-pamica"), + (between, C_BET, "between (pamica-Fortran)"), ]: axA.hist(arr, bins=bins, density=True, color=col, alpha=0.35) axA.hist( @@ -194,7 +194,7 @@ def figure(within_F, within_G, between, F_ll, G_ll, diff, p_perm, ks, out): bins_ll = np.linspace( min(F_ll.min(), G_ll.min()) - 0.005, max(F_ll.max(), G_ll.max()) + 0.005, 24 ) - for arr, col, lab in [(F_ll, C_FORT, "Fortran"), (G_ll, C_NG, "pyAMICA")]: + for arr, col, lab in [(F_ll, C_FORT, "Fortran"), (G_ll, C_NG, "pamica")]: axB.hist(arr, bins=bins_ll, density=True, color=col, alpha=0.35) axB.hist( arr, bins=bins_ll, density=True, histtype="step", color=col, lw=2, label=lab @@ -231,7 +231,7 @@ def figure(within_F, within_G, between, F_ll, G_ll, diff, p_perm, ks, out): 0.20, f"mean corr. (run pairs)\n" f"within-Fortran: {within_F.mean():.3f} (n={len(within_F)})\n" - f"within-pyAMICA: {within_G.mean():.3f} (n={len(within_G)})\n" + f"within-pamica: {within_G.mean():.3f} (n={len(within_G)})\n" f"between: {between.mean():.3f} (n={len(between)})\n" f"diff: {diff:+.3f} (margin +/-0.05)\n" f"perm. p={p_perm:.2f}", @@ -251,7 +251,7 @@ def figure(within_F, within_G, between, F_ll, G_ll, diff, p_perm, ks, out): 0.23, f"mean final LL\n" f"Fortran: {F_ll.mean():.4f} (sd {F_ll.std():.3f})\n" - f"pyAMICA: {G_ll.mean():.4f} (sd {G_ll.std():.3f})\n" + f"pamica: {G_ll.mean():.4f} (sd {G_ll.std():.3f})\n" f"gap: {abs(F_ll.mean() - G_ll.mean()):.3f} (100-iter budget)\n" f"KS p={ks:.0e}", ha="center", @@ -269,7 +269,7 @@ def figure(within_F, within_G, between, F_ll, G_ll, diff, p_perm, ks, out): style="italic", ) fig.suptitle( - "Multi-model AMICA (n_models=2): pyAMICA vs Fortran ensembles, real sample EEG", + "Multi-model AMICA (n_models=2): pamica vs Fortran ensembles, real sample EEG", fontweight="bold", fontsize=8.5, y=0.97, diff --git a/.context/issue-27/per_run_detail.csv b/.context/issue-27/per_run_detail.csv index 9979a6d..6bcae4f 100644 --- a/.context/issue-27/per_run_detail.csv +++ b/.context/issue-27/per_run_detail.csv @@ -1,41 +1,41 @@ -implementation,run,corr_within,corr_between,amari_within,amari_between -Fortran,0,0.6164,0.6274,0.1880,0.1745 -Fortran,1,0.6292,0.6568,0.1784,0.1592 -Fortran,2,0.6465,0.6396,0.1694,0.1654 -Fortran,3,0.6593,0.6740,0.1627,0.1508 -Fortran,4,0.6323,0.6522,0.1797,0.1629 -Fortran,5,0.6692,0.6773,0.1590,0.1488 -Fortran,6,0.6660,0.6808,0.1628,0.1502 -Fortran,7,0.6341,0.6400,0.1786,0.1678 -Fortran,8,0.6285,0.6491,0.1779,0.1663 -Fortran,9,0.6212,0.6341,0.1835,0.1702 -Fortran,10,0.6308,0.6414,0.1794,0.1663 -Fortran,11,0.6297,0.6444,0.1736,0.1614 -Fortran,12,0.6334,0.6416,0.1782,0.1690 -Fortran,13,0.6513,0.6630,0.1693,0.1574 -Fortran,14,0.6666,0.6689,0.1584,0.1540 -Fortran,15,0.6231,0.6254,0.1827,0.1750 -Fortran,16,0.6147,0.6258,0.1903,0.1755 -Fortran,17,0.6280,0.6483,0.1749,0.1632 -Fortran,18,0.6441,0.6371,0.1691,0.1665 -Fortran,19,0.6389,0.6505,0.1733,0.1637 -pyAMICA,0,0.6317,0.6149,0.1690,0.1806 -pyAMICA,1,0.6935,0.6777,0.1440,0.1529 -pyAMICA,2,0.6624,0.6583,0.1545,0.1606 -pyAMICA,3,0.6406,0.6459,0.1527,0.1561 -pyAMICA,4,0.6493,0.6384,0.1506,0.1569 -pyAMICA,5,0.6755,0.6591,0.1548,0.1622 -pyAMICA,6,0.6339,0.6236,0.1677,0.1807 -pyAMICA,7,0.6809,0.6788,0.1417,0.1479 -pyAMICA,8,0.6780,0.6552,0.1508,0.1631 -pyAMICA,9,0.6270,0.6206,0.1703,0.1823 -pyAMICA,10,0.6855,0.6665,0.1474,0.1612 -pyAMICA,11,0.6739,0.6556,0.1506,0.1612 -pyAMICA,12,0.6321,0.6190,0.1617,0.1753 -pyAMICA,13,0.6639,0.6610,0.1519,0.1597 -pyAMICA,14,0.6866,0.6849,0.1460,0.1520 -pyAMICA,15,0.6944,0.6672,0.1417,0.1559 -pyAMICA,16,0.6576,0.6533,0.1551,0.1633 -pyAMICA,17,0.6435,0.6249,0.1615,0.1756 -pyAMICA,18,0.6753,0.6547,0.1442,0.1549 -pyAMICA,19,0.6302,0.6180,0.1562,0.1655 +implementation,run,corr_within,corr_between,amari_within,amari_between +Fortran,0,0.6164,0.6274,0.1880,0.1745 +Fortran,1,0.6292,0.6568,0.1784,0.1592 +Fortran,2,0.6465,0.6396,0.1694,0.1654 +Fortran,3,0.6593,0.6740,0.1627,0.1508 +Fortran,4,0.6323,0.6522,0.1797,0.1629 +Fortran,5,0.6692,0.6773,0.1590,0.1488 +Fortran,6,0.6660,0.6808,0.1628,0.1502 +Fortran,7,0.6341,0.6400,0.1786,0.1678 +Fortran,8,0.6285,0.6491,0.1779,0.1663 +Fortran,9,0.6212,0.6341,0.1835,0.1702 +Fortran,10,0.6308,0.6414,0.1794,0.1663 +Fortran,11,0.6297,0.6444,0.1736,0.1614 +Fortran,12,0.6334,0.6416,0.1782,0.1690 +Fortran,13,0.6513,0.6630,0.1693,0.1574 +Fortran,14,0.6666,0.6689,0.1584,0.1540 +Fortran,15,0.6231,0.6254,0.1827,0.1750 +Fortran,16,0.6147,0.6258,0.1903,0.1755 +Fortran,17,0.6280,0.6483,0.1749,0.1632 +Fortran,18,0.6441,0.6371,0.1691,0.1665 +Fortran,19,0.6389,0.6505,0.1733,0.1637 +pamica,0,0.6317,0.6149,0.1690,0.1806 +pamica,1,0.6935,0.6777,0.1440,0.1529 +pamica,2,0.6624,0.6583,0.1545,0.1606 +pamica,3,0.6406,0.6459,0.1527,0.1561 +pamica,4,0.6493,0.6384,0.1506,0.1569 +pamica,5,0.6755,0.6591,0.1548,0.1622 +pamica,6,0.6339,0.6236,0.1677,0.1807 +pamica,7,0.6809,0.6788,0.1417,0.1479 +pamica,8,0.6780,0.6552,0.1508,0.1631 +pamica,9,0.6270,0.6206,0.1703,0.1823 +pamica,10,0.6855,0.6665,0.1474,0.1612 +pamica,11,0.6739,0.6556,0.1506,0.1612 +pamica,12,0.6321,0.6190,0.1617,0.1753 +pamica,13,0.6639,0.6610,0.1519,0.1597 +pamica,14,0.6866,0.6849,0.1460,0.1520 +pamica,15,0.6944,0.6672,0.1417,0.1559 +pamica,16,0.6576,0.6533,0.1551,0.1633 +pamica,17,0.6435,0.6249,0.1615,0.1756 +pamica,18,0.6753,0.6547,0.1442,0.1549 +pamica,19,0.6302,0.6180,0.1562,0.1655 diff --git a/.context/issue-51/ensemble_ll.py b/.context/issue-51/ensemble_ll.py index 0c6e561..a25b7af 100644 --- a/.context/issue-51/ensemble_ll.py +++ b/.context/issue-51/ensemble_ll.py @@ -23,14 +23,14 @@ import numpy as np from scipy import stats -from pyAMICA.torch_impl import AMICATorchNG -from pyAMICA.torch_impl.utils import load_eeglab_data +from pamica.torch_impl import AMICATorchNG +from pamica.torch_impl.utils import load_eeglab_data HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] -BIN = REPO / "pyAMICA/sample_data/amica15mac" -FDT = REPO / "pyAMICA/sample_data/eeglab_data.fdt" -FIXTURE = REPO / "pyAMICA/tests/torch_tests/_ng_e2e_tmp/fortran_run/input.param" +BIN = REPO / "pamica/sample_data/amica15mac" +FDT = REPO / "pamica/sample_data/eeglab_data.fdt" +FIXTURE = REPO / "pamica/tests/torch_tests/_ng_e2e_tmp/fortran_run/input.param" NW, FIELD = 32, 30504 DELTA_LL = 0.01 # TOST equivalence margin on the mean LL (per sample-channel) diff --git a/.context/issue-84/phase1_build_notes.md b/.context/issue-84/phase1_build_notes.md index de07f48..0fbe5ff 100644 --- a/.context/issue-84/phase1_build_notes.md +++ b/.context/issue-84/phase1_build_notes.md @@ -6,7 +6,7 @@ Adds a `native-fortran-f64` backend to `benchmarks/benchmark_dimsweep.py` that t ## gfortran build portability (source targets ifort + MKL) `amica15.f90` assumes Intel's toolchain. A plain `gfortran` + LAPACK build needs three -fixes, applied by `build_amica.sh` to a **build copy** (the tracked `pyAMICA/amica15.f90` +fixes, applied by `build_amica.sh` to a **build copy** (the tracked `pamica/amica15.f90` reference is never modified): 1. `-cpp` -- resolve the `#ifdef MKL` guards so `include 'mkl_vml.f90'` is skipped. diff --git a/.context/issue-84/phase3_decomp_equivalence.md b/.context/issue-84/phase3_decomp_equivalence.md index 9174381..fa7f073 100644 --- a/.context/issue-84/phase3_decomp_equivalence.md +++ b/.context/issue-84/phase3_decomp_equivalence.md @@ -2,7 +2,7 @@ Runs every backend to a full decomposition (2000 iters) on real EEG, then compares the recovered independent components across backends. Answers the question the whole epic is -built on: **is pyAMICA (and the native-Fortran build) a drop-in replacement for EEGLAB AMICA +built on: **is pamica (and the native-Fortran build) a drop-in replacement for EEGLAB AMICA -- do all backends recover the same sources?** `benchmarks/benchmark_decompose.py` fits each backend, saves total wall-clock + final LL + @@ -16,7 +16,7 @@ Full ds002718 sub-002 recording is 747,750 frames (~50 min @ 250 Hz). Phase 3 us frames = **k = 30 at 70ch** (frames / ch^2 = the EEGLAB data-adequacy rule, minimum ~20-30). numpy is excluded (too slow, not a recommended backend). -## Result 1: pyAMICA is device- and precision-invariant (the headline) +## Result 1: pamica is device- and precision-invariant (the headline) Cross-backend mean Hungarian-matched |corr| @ 70ch, 2000 iters (see `benchmarks/figures/phase3_equivalence_matrix_70ch.png`): @@ -24,7 +24,7 @@ Cross-backend mean Hungarian-matched |corr| @ 70ch, 2000 iters (see **Every torch/MLX backend is identical to each other at 1.000** -- torch-cpu (both machines), torch-cuda, torch-mps, and MLX, across **both f32 and f64** (8 backend/precision/device combinations). Same decomposition on any device at any precision. This is the definitive -"f32 == f64" and "GPU == CPU" result: pyAMICA gives the same ICs regardless of where or how it +"f32 == f64" and "GPU == CPU" result: pamica gives the same ICs regardless of where or how it runs. The two native-Fortran runs (Mac arm64 + Linux x86_64) agree with each other at 0.972 and with diff --git a/.context/mps_pathways.md b/.context/mps_pathways.md index 366f863..5562a59 100644 --- a/.context/mps_pathways.md +++ b/.context/mps_pathways.md @@ -79,7 +79,7 @@ and is Apple-native with a real compiler / lazy graph ([WWDC25](https://develope unfused, and sometimes falls back to CPU ([State of PyTorch HW 2025](https://tunguz.github.io/PyTorch_Hardware_2025/)). An MLX backend could both cut dispatch overhead and fuse the per-block work. -**Status (#76):** `AMICAMLXNG` (`pyAMICA/mlx_impl/core.py`) is a v1 MVP -- single-model, +**Status (#76):** `AMICAMLXNG` (`pamica/mlx_impl/core.py`) is a v1 MVP -- single-model, generalized-Gaussian, natural gradient. It is a **hybrid**: the E/M-step hot path runs on the GPU in float32 (with the Phase A `ufp/y` guard), while all `mlx.core.linalg` is CPU-only in MLX 0.32, so `inv(A)`/`slogdet(W)` run on the CPU stream (hoisted to once per iteration -- diff --git a/.context/paper-applications-draft.md b/.context/paper-applications-draft.md index b6d6eb3..3a7bdb6 100644 --- a/.context/paper-applications-draft.md +++ b/.context/paper-applications-draft.md @@ -21,7 +21,7 @@ advanced brain data modeling features available in AMICA: An efficient measure of source separation performance is Mutual Information Reduction (MIR) introduced by Palmer in (Delorme et al., 2012). - Scott's strong recommendation: add MIR as a built-in pyAMICA option, applicable at decomposition + Scott's strong recommendation: add MIR as a built-in pamica option, applicable at decomposition end and/or optionally at specified decomposition waypoints. See the MIR/PMI port epic (tracked as a GitHub issue) for the implementation side of this. @@ -44,7 +44,7 @@ advanced brain data modeling features available in AMICA: than a day, allowing assays of source and source network instability as well as stability. All these possibilities, once beyond the reach of routine desktop computing, are now practical to -apply using current desktop hardware. Using pyAMICA in still more powerful compute environments can +apply using current desktop hardware. Using pamica in still more powerful compute environments can only increase the depth of detail and statistical power of studies of brain dynamics in complex or even real-life protocols -- either exploratory or confirmatory. For applications in supercomputer environments, however, Palmer's FORTRAN version (AMICA 5.??) customized for use at the San Diego diff --git a/.context/plan.md b/.context/plan.md index 0a88f64..1371814 100644 --- a/.context/plan.md +++ b/.context/plan.md @@ -1,4 +1,4 @@ -# pyAMICA Development Plan +# pamica Development Plan ## Project Overview **Goal:** Python (PyTorch) implementation of AMICA that reproduces the Fortran binary's results within tolerance. @@ -22,7 +22,7 @@ - [x] Port outlier rejection (`do_reject` / `reject_data`) to the torch backend (done in `AMICATorchNG`) - [x] Wire up / stabilize Newton for the torch backend (done in `AMICATorchNG`, posdef, issue #24) - [x] Adaptive PDF selection for `AMICATorchNG` (issue #26). Corrected the "no oracle" finding: - the reference binary is `amica15mac` = `amica15.f90` (now copied into `pyAMICA/`), which + the reference binary is `amica15mac` = `amica15.f90` (now copied into `pamica/`), which implements five `pdtype` density families; the repo's `amica17.f90` is a later GG-only trim. Ported all five (0 GG default, 2 Gaussian, 3 logistic, 4 sub-G cosh+, 1 super-G cosh-) plus the extended-Infomax kurtosis auto-switcher (`pdftype=1`). Fixed families are bit-exact vs the @@ -101,7 +101,7 @@ will be hosted at `eeglab.org/pyAMICA`. built in #97; README de-WIP'd and modernized in #102. `site_url: https://eeglab.org/pyAMICA/` (numpy docstrings, git-revision-date fallback). - [ ] **Standup (needs the transfer):** deploy Pages at `eeglab.org/pyAMICA` under the - sccn org custom domain (`sccn/pyAMICA` project Pages at the `/pyAMICA` subpath). + sccn org custom domain (`sccn/pyAMICA` project Pages at the `/pamica` subpath). ### Phase R3: Transfer to github.com/sccn — REMAINS (user) - [ ] GitHub repo transfer (preserves issues/PRs/stars/history; auto-redirects old @@ -117,8 +117,8 @@ will be hosted at `eeglab.org/pyAMICA`. ### Remaining before actual JOSS submission (user) - [ ] R3 transfer + R2 docs standup (above). - [ ] Archived release (Zenodo/Software Heritage) with a matching version. -- [ ] PyPI distribution name (the name `pyamica`/`pyAMICA` is taken on PyPI; the - `import pyAMICA` name is unaffected; deferred to release). +- [ ] PyPI distribution name (the name `pyamica`/`pamica` is taken on PyPI; the + `import pamica` name is unaffected; deferred to release). - [ ] Fill the `paper.md` corresponding-author ORCID / confirm co-author details at submission (ORCIDs set: Shirazi 0000-0001-5557-259X, Delorme 0000-0002-0799-3557, Makeig 0000-0002-9048-8438). diff --git a/.context/progress_summary.md b/.context/progress_summary.md index d7b7be0..66f98c3 100644 --- a/.context/progress_summary.md +++ b/.context/progress_summary.md @@ -7,7 +7,7 @@ what remains as of the v0.1.0 preparation. ## Delivered ### Natural-gradient EM backend at Fortran parity (epic #9 / issue #24) -- `AMICATorchNG` (`pyAMICA/torch_impl/core.py`) is the sole PyTorch backend; the `AMICA` +- `AMICATorchNG` (`pamica/torch_impl/core.py`) is the sole PyTorch backend; the `AMICA` scikit-learn-style wrapper wraps it directly. - Root cause of the historical parity gap: the natural-gradient A-update was transposed and multiplied on the wrong side. Fix plus exact-EM mixture updates, the digamma rho update, the @@ -67,7 +67,7 @@ what remains as of the v0.1.0 preparation. ### Structure and infrastructure - Module rename into `numpy_impl/` and `torch_impl/` with topic-based names (issue #34); the public - import surface (`from pyAMICA import AMICA, AMICA_NumPy, AMICATorchNG`) is stable. + import surface (`from pamica import AMICA, AMICA_NumPy, AMICATorchNG`) is stable. - UV is the canonical environment (`pyproject.toml` + `uv.lock`); the legacy conda env is retired. - CI is live and green on `main`: ruff lint/format, pytest (excluding slow/Fortran-binary parity), and a build + clean-env import matrix on Python 3.12 and 3.13. Typos check green. diff --git a/.context/research.md b/.context/research.md index 2142b5c..23a3eb9 100644 --- a/.context/research.md +++ b/.context/research.md @@ -1,4 +1,4 @@ -# pyAMICA Research & Parity Analysis +# pamica Research & Parity Analysis Fortran-vs-Python parity analysis. Fortran reference: `amica17.f90` (~3900 lines), `amica17_header.f90` (declarations), `funmod2.f90` (function modules). @@ -22,7 +22,7 @@ Fortran-vs-Python parity analysis. Fortran reference: `amica17.f90` (~3900 lines | alpha, mu, sbeta, rho | `self.alpha`, `self.mu`, `self.beta`, `self.rho` | done | | comp_list, LL, nd | `self.comp_list`, `self.ll`, `self.nd` | done | | lambda, kappa, sigma2 (Newton) | `compute_newton_direction()` | partial | -| baralpha (Newton) | `self.baralpha` in legacy `pyAMICA.py` | done in NumPy; missing in torch backend | +| baralpha (Newton) | `self.baralpha` in legacy `pamica.py` | done in NumPy; missing in torch backend | ## Core Subroutines (Fortran -> Python) | Fortran | Python | Status | @@ -33,12 +33,12 @@ Fortran-vs-Python parity analysis. Fortran reference: `amica17.f90` (~3900 lines | update_params | `_update_parameters()` | done | | get_unmixing_matrices | `get_unmixing_matrices()` | done | | identify_shared_comps | `identify_shared_components()` | done | -| reject_data | `_reject_outliers()` in legacy `pyAMICA.py` | done in NumPy; missing in torch backend | +| reject_data | `_reject_outliers()` in legacy `pamica.py` | done in NumPy; missing in torch backend | | determine_block_size | `determine_block_size()` | done | | write_output / write_history | `save_results()` | done / partial | ## Missing / incomplete in the PyTorch backend -The legacy NumPy `pyAMICA.py` implements outlier rejection, `baralpha`, and Newton; the PyTorch +The legacy NumPy `pamica.py` implements outlier rejection, `baralpha`, and Newton; the PyTorch backend (the parity-focused path) does not yet. Items below refer to the torch backend. 1. Outlier rejection (`do_reject`, `reject_data`) - present in NumPy, absent in torch. 2. Adaptive PDF selection (`do_choose_pdfs`) - partial, in `amica_torch_v2.py`, not wired into the default interface. @@ -65,7 +65,7 @@ log_norm = torch.lgamma(1.0 + 1.0/rho) + torch.log(torch.tensor(2.0)) # CORRECT: log_norm = torch.log(torch.tensor(2.0)) + torch.lgamma(1.0/rho) - torch.log(rho) ``` -Location: `pyAMICA/torch_impl/adaptive_pdf.py`. +Location: `pamica/torch_impl/adaptive_pdf.py`. ### Newton ramping Fortran ramps conservatively at Newton start (lrate 0.05 -> 0.10 at iter 51, doubling, capped ~0.5). @@ -89,7 +89,7 @@ reframes AMICA as "minimize NLL with Adam over reparameterized tensors," but AMI fixed-point EM / natural-gradient method with closed-form sufficient-statistic updates. Adam on the reparameterized surface follows a different trajectory and converges to a different fixed point, so component correlation with Fortran is essentially coincidental. Decision to rewrite: -see `.context/decisions/0001-torch-backend-natural-gradient-em.md`. The NumPy `pyAMICA.py` is a +see `.context/decisions/0001-torch-backend-natural-gradient-em.md`. The NumPy `pamica.py` is a faithful port and is the spec. ### Concrete bugs (fixable independently of the rewrite) @@ -120,7 +120,7 @@ faithful port and is the spec. REPORTED LL value. 4. **Newton double-steps** - `newton_optimizer.py:221-262` mutates `A` in place, then the main loop also calls Adam `optimizer.step()` on the same iteration -> NaN at `newt_start`. Port the - NumPy Newton (`pyAMICA.py:666-694`) instead. + NumPy Newton (`pamica.py:666-694`) instead. 5. **`pinv` in the hot path** - `amica_torch.py:149` recomputes `W = pinv(A.T).T` every forward. Track `W` directly. 6. **float32 default** - Fortran is double throughout; validate in float64. diff --git a/.context/scratch_history.md b/.context/scratch_history.md index 3e06079..11b2327 100644 --- a/.context/scratch_history.md +++ b/.context/scratch_history.md @@ -1,4 +1,4 @@ -# pyAMICA Scratch History +# pamica Scratch History Debugging notes, failed attempts, and lessons. Prevents repeating dead ends. @@ -39,8 +39,8 @@ Debugging notes, failed attempts, and lessons. Prevents repeating dead ends. ## Debugging commands ```bash -python -m pytest pyAMICA/tests/test_sample_data.py::test_sample_data_light -v -s -python -m pyAMICA.amica_cli pyAMICA/sample_data/sample_params.json --verbose --outdir debug_out +python -m pytest pamica/tests/test_sample_data.py::test_sample_data_light -v -s +python -m pamica.amica_cli pamica/sample_data/sample_params.json --verbose --outdir debug_out # Fortran build (if needed): gfortran -O3 -fopenmp amica17.f90 funmod2.f90 -o amica -llapack -lblas ``` @@ -59,7 +59,7 @@ evidence in the issue and `.context/issue-136/matlab_viz_verification.md`. ## Issue #136 (MIR/PMI visualizations): MATLAB gate for plots Same run-and-observe posture as #155, extended to figures. postAmicaUtility is GPL -(pop_topohistplot.m / pop_modPMI.m carry explicit GPL-2.0-or-later headers), pyAMICA +(pop_topohistplot.m / pop_modPMI.m carry explicit GPL-2.0-or-later headers), pamica is BSD-3-Clause, so its .m source was never read: the gate used only `help` text, rendered figures, and black-box I/O. Every plotted quantity is pinned to MATLAB (mir vs Apache-2.0 getMIR.m at 1.7e-15; P(model|data) vs LLt2v at 1.4e-14; v @@ -86,7 +86,7 @@ because tests only ever covered rho=1.0/2.0, the special-cased correct branches. ## Issue #155 (LLt output): MATLAB interop re-verified for the new file `LLt` (per-timepoint, per-model log-likelihood) was added to the writer in PR #156, so it went through the same #92 MATLAB gate below. Result: real -`loadmodout15.m` under MATLAB R2025b reads pyAMICA's `LLt` bit-exactly (single- +`loadmodout15.m` under MATLAB R2025b reads pamica's `LLt` bit-exactly (single- and multi-model), and both readers agree bit-exactly on the genuine Fortran fixture. Full record + how to re-run: `.context/issue-155/matlab_interop_verification.md`. The automated tests only @@ -96,7 +96,7 @@ it by hand if the LLt layout, `write_amicaout`, or `loadmodout` ever change. ## Issue #92 (EEGLAB drop-in output): the column-major layout fix (KEY) Fortran/EEGLAB store arrays **column-major**. The MATLAB round-trip exposed that -pyAMICA wrote the non-square mixture params (`alpha`/`mu`/`sbeta`/`rho`, shape +pamica wrote the non-square mixture params (`alpha`/`mu`/`sbeta`/`rho`, shape `(num_mix, num_comps)`) and `c`/`comp_list` in **C-order**, so real MATLAB `loadmodout15.m` (column-major reads) got scrambled mixture params -- e.g. the per-component mixture proportions did NOT sum to 1. FIXED (issue #92): the writer diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 191f1ae..e00ed75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: bash native/build.sh echo "PAMICA_NATIVE_BINARY=$PWD/native/amica15_shim" >> "$GITHUB_ENV" # Exclude the "slow" parity tests: they invoke the macOS-only Fortran - # reference binary (pyAMICA/sample_data/amica15mac), which cannot run on + # reference binary (pamica/sample_data/amica15mac), which cannot run on # the Linux runner. MPS-specific tests self-skip when MPS is absent. # -n auto parallelizes the (independent) torch fits across runner cores; # pytest-cov combines per-worker branch coverage automatically. Coverage @@ -135,4 +135,4 @@ jobs: run: | uv venv --python ${{ matrix.python-version }} .import-venv uv pip install --python .import-venv dist/*.whl - .import-venv/bin/python -c "import pyAMICA; print('pyAMICA', pyAMICA.__version__)" + .import-venv/bin/python -c "import pamica; print('pamica', pamica.__version__)" diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 9d6516b..c879c0e 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -114,12 +114,12 @@ jobs: ext=""; [ "${{ runner.os }}" = "Windows" ] && ext=".exe" bin="$PWD/dist/amica15-${{ matrix.target }}$ext" run="$(mktemp -d)"; cd "$run" - cp "$GITHUB_WORKSPACE/pyAMICA/sample_data/eeglab_data.fdt" . + cp "$GITHUB_WORKSPACE/pamica/sample_data/eeglab_data.fdt" . mkdir -p out sed -e 's#^files .*#files ./eeglab_data.fdt#' \ -e 's#^outdir .*#outdir ./out/#' \ -e 's#^max_iter .*#max_iter 2#' -e 's#^writestep .*#writestep 2#' \ - "$GITHUB_WORKSPACE/pyAMICA/sample_data/input.param" > p.param + "$GITHUB_WORKSPACE/pamica/sample_data/input.param" > p.param OMP_NUM_THREADS=2 "$bin" p.param | tee run.log # Extract the last iteration's LL. A finite negative number (starts with # "-") = a working decomposition; a blank, "NaN" or "Infinity" diff --git a/.gitignore b/.gitignore index ac7662b..9af5fd2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ auto_examples/ gen_api/ # Ignore everything in the egg-info directory -pyAMICA.egg-info/* +pamica.egg-info/* # Generated run/validation output output/ @@ -25,8 +25,8 @@ coverage.xml htmlcov/ # Temp output from NG e2e test (Fortran run artifacts) -pyAMICA/tests/torch_tests/_ng_e2e_tmp/ -pyAMICA/tests/torch_tests/_ng_e2e_json_tmp/ +pamica/tests/torch_tests/_ng_e2e_tmp/ +pamica/tests/torch_tests/_ng_e2e_json_tmp/ # Benchmark run artifacts benchmarks/results/ diff --git a/.rules/documentation.md b/.rules/documentation.md index cd4383e..9add7ed 100644 --- a/.rules/documentation.md +++ b/.rules/documentation.md @@ -16,7 +16,7 @@ bun add -D typedoc ## Minimal mkdocs.yml ```yaml -site_name: pyAMICA +site_name: pamica site_url: https://example.com repo_url: https://github.com/user/repo diff --git a/.zenodo.json b/.zenodo.json index 6e603c8..72d11a2 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -1,6 +1,6 @@ { - "title": "pyAMICA: a Python implementation of Adaptive Mixture ICA", - "description": "pyAMICA is a Python (PyTorch) implementation of Adaptive Mixture Independent Component Analysis (AMICA) that reproduces the reference Fortran implementation within numerical tolerance, with CPU, NVIDIA GPU (CUDA), and Apple GPU (MLX) support. It targets EEG/EMG blind source separation and provides a scikit-learn-style interface and byte-identical EEGLAB (loadmodout15) output.", + "title": "pamica: a Python implementation of Adaptive Mixture ICA", + "description": "pamica is a Python (PyTorch) implementation of Adaptive Mixture Independent Component Analysis (AMICA) that reproduces the reference Fortran implementation within numerical tolerance, with CPU, NVIDIA GPU (CUDA), and Apple GPU (MLX) support. It targets EEG/EMG blind source separation and provides a scikit-learn-style interface and byte-identical EEGLAB (loadmodout15) output.", "upload_type": "software", "access_right": "open", "license": "bsd-3-clause", diff --git a/AGENTS.md b/AGENTS.md index 45e661e..06a5edf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# pyAMICA Instructions +# pamica Instructions ## Project Context **Purpose:** Python implementation of AMICA (Adaptive Mixture Independent Component Analysis) that reproduces the results of the reference Fortran binary. Targets EEG/EMG source separation with GPU/MPS/CPU support. @@ -7,7 +7,7 @@ ## Architecture Map ``` -pyAMICA/ +pamica/ ├── amica.py # Main scikit-learn-style AMICA interface (wraps AMICATorchNG) ├── __init__.py # Exposes AMICA (PyTorch), AMICA_NumPy (legacy), numpy_impl, torch_impl ├── torch_impl/ # PyTorch backend @@ -25,10 +25,10 @@ pyAMICA/ validate_implementations.py # Runs both implementations, Hungarian component matching, reports ``` Module names are topic-based (`core`/`newton`/`pdf`/`data`/... under `numpy_impl/`, -`core`/`utils` under `torch_impl/`); the old `pyAMICA.py`/`amica_*.py`/`amica_torch_ng.py` +`core`/`utils` under `torch_impl/`); the old `pamica.py`/`amica_*.py`/`amica_torch_ng.py` prefixes were dropped in issue #34. The public import surface is stable: -`from pyAMICA import AMICA, AMICA_NumPy, AMICATorchNG`. The optional MLX backend is -imported separately (`from pyAMICA.mlx_impl import AMICAMLXNG`) so `import pyAMICA` never +`from pamica import AMICA, AMICA_NumPy, AMICATorchNG`. The optional MLX backend is +imported separately (`from pamica.mlx_impl import AMICAMLXNG`) so `import pamica` never requires MLX; install it with `uv pip install mlx` or the `mlx` extra (Apple Silicon only). ## Environment Setup @@ -66,13 +66,13 @@ Multi-model MLX (#81) also wins (~5x over torch-CPU; MPS still loses); the remai is component sharing. ## Key Files -- **Main interface:** `pyAMICA/amica.py` (thin wrapper over `AMICATorchNG`) -- **PyTorch backend:** `pyAMICA/torch_impl/core.py` (`AMICATorchNG`, natural-gradient EM, +- **Main interface:** `pamica/amica.py` (thin wrapper over `AMICATorchNG`) +- **PyTorch backend:** `pamica/torch_impl/core.py` (`AMICATorchNG`, natural-gradient EM, Fortran-parity; ADR `.context/decisions/0001-torch-backend-natural-gradient-em.md`). This is the only PyTorch backend; the basic `AMICATorch`/`AMICATorchV2` paths were removed in #32. - **Validation harness:** `validate_implementations.py` -- **Fortran reference binary:** `pyAMICA/sample_data/amica15mac` -- **Sample data:** `pyAMICA/sample_data/` +- **Fortran reference binary:** `pamica/sample_data/amica15mac` +- **Sample data:** `pamica/sample_data/` ## Current Status - PyTorch backend with GPU/MPS/CPU support; the `AMICATorchNG` natural-gradient EM backend now @@ -100,7 +100,7 @@ source-density families via `pdftype`: 0 generalized Gaussian (default, unchange 3 logistic, 4 sub-Gaussian cosh+, and the extended-Infomax adaptive switcher (`pdftype=1`, which flips each source between super-Gaussian code 1 and sub-Gaussian code 4 by kurtosis sign on the `kurt_start`/`num_kurt`/`kurt_int` schedule). Key correction to the earlier "no oracle" finding: the -validation binary is `amica15mac` = `amica15.f90` (now copied into `pyAMICA/`), which *does* +validation binary is `amica15mac` = `amica15.f90` (now copied into `pamica/`), which *does* implement the families; the repo's `amica17.f90` is a later GG-only trim, and the reference binary was never amica17. The fixed families are bit-exact vs the literal Fortran `z0`/`fp` (~1e-15) and converge to the binary within ~0.005 LL (Newton-matched). The dynamic `do_choose_pdfs` switch is diff --git a/CITATION.cff b/CITATION.cff index a1921f2..5cd22a8 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,8 +1,8 @@ cff-version: 1.3.0 message: "If you use this software, please cite it using the metadata below." -title: "pyAMICA: a Python implementation of Adaptive Mixture ICA" +title: "pamica: a Python implementation of Adaptive Mixture ICA" abstract: >- - pyAMICA is a Python (PyTorch) implementation of Adaptive Mixture Independent + pamica is a Python (PyTorch) implementation of Adaptive Mixture Independent Component Analysis (AMICA) that reproduces the reference Fortran implementation, with GPU (CUDA), Apple-GPU (MLX), and CPU support. It targets EEG/EMG blind source separation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3915423..2d39e3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to pyAMICA +# Contributing to pamica -Thanks for your interest in contributing. pyAMICA is a Python implementation of +Thanks for your interest in contributing. pamica is a Python implementation of Adaptive Mixture Independent Component Analysis (AMICA) that reproduces the reference Fortran implementation. Because **numerical parity with the Fortran reference is the definition of correctness**, contributions are held to that @@ -10,12 +10,12 @@ standard rather than to "it converges." - **Questions, bugs, and feature requests:** please open an issue on the [GitHub issue tracker](https://github.com/sccn/pyAMICA/issues). -- When reporting a bug, include the pyAMICA version, platform, device +- When reporting a bug, include the pamica version, platform, device (CPU/CUDA/MPS/MLX), precision (float32/float64), and a minimal example. ## Development setup -pyAMICA uses [UV](https://docs.astral.sh/uv/) for environment and dependency +pamica uses [UV](https://docs.astral.sh/uv/) for environment and dependency management. ```bash @@ -31,7 +31,7 @@ support. ## Testing - **Real data only.** Correctness tests use the real sample EEG and the Fortran - binary shipped in `pyAMICA/sample_data/`. Do not use mocks, stubs, or synthetic + binary shipped in `pamica/sample_data/`. Do not use mocks, stubs, or synthetic data as the basis for a correctness test: no test is better than a fake passing test. - Run with coverage: `uv run pytest --cov`. diff --git a/README.md b/README.md index f5d6a55..80a5d9e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# pyAMICA: Adaptive Mixture ICA +# pamica: Adaptive Mixture ICA [![CI](https://github.com/sccn/pyAMICA/actions/workflows/ci.yml/badge.svg)](https://github.com/sccn/pyAMICA/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/sccn/pyAMICA/branch/main/graph/badge.svg)](https://codecov.io/gh/sccn/pyAMICA) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21312148.svg)](https://doi.org/10.5281/zenodo.21312148) -[![Docs](https://img.shields.io/badge/docs-eeglab.org%2FpyAMICA-blue)](https://eeglab.org/pyAMICA/) +[![Docs](https://img.shields.io/badge/docs-eeglab.org%2Fpamica-blue)](https://eeglab.org/pyAMICA/) Python (PyTorch) implementation of Adaptive Mixture Independent Component Analysis (AMICA) that reproduces the reference Fortran implementation within numerical @@ -44,12 +44,12 @@ extra: `uv pip install mlx`. ## Usage -pyAMICA exposes a scikit-learn-style estimator backed by the PyTorch +pamica exposes a scikit-learn-style estimator backed by the PyTorch natural-gradient EM implementation: ```python import numpy as np -from pyAMICA import AMICA +from pamica import AMICA # X is (n_channels, n_samples) of real EEG/EMG; float64 gives Fortran parity model = AMICA(n_models=1, n_mix=3).fit(X) @@ -71,12 +71,12 @@ The wrapper auto-selects a device and computes in float64 for Fortran parity. ```python AMICA(device="cuda").fit(X) # NVIDIA GPU, float64 -from pyAMICA.mlx_impl import AMICAMLXNG # Apple GPU (install the mlx extra) +from pamica.mlx_impl import AMICAMLXNG # Apple GPU (install the mlx extra) ``` ### EEGLAB interoperability -pyAMICA writes results in EEGLAB's AMICA output format, so a run drops into an +pamica writes results in EEGLAB's AMICA output format, so a run drops into an EEGLAB workflow with no manual re-ordering or sign-flipping: ```python @@ -92,7 +92,7 @@ mod = loadmodout15('amicaout'); % components in EEGLAB variance order The NumPy reference backend keeps a JSON-driven command-line interface: ```bash -python -m pyAMICA.numpy_impl.cli params.json --outdir results +python -m pamica.numpy_impl.cli params.json --outdir results ``` See the [documentation](https://eeglab.org/pyAMICA/) for the full API, the @@ -100,7 +100,7 @@ parameter reference, and the backend-selection and validation guides. ## Citation -If you use pyAMICA, please cite it (see [CITATION.cff](CITATION.cff)) and the +If you use pamica, please cite it (see [CITATION.cff](CITATION.cff)) and the original AMICA method: > Palmer, J. A., Kreutz-Delgado, K., & Makeig, S. (2012). AMICA: An adaptive diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 64e306b..33df8ea 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,13 +1,13 @@ # Third-Party Notices -pyAMICA is BSD-3-Clause (see `LICENSE`). This file lists source code ported +pamica is BSD-3-Clause (see `LICENSE`). This file lists source code ported into the package from third-party projects under other licenses, and reproduces the license text required by each, per that license's redistribution terms. ## MIR (Mutual Information Reduction) metric -`pyAMICA/metrics/mir.py` is ported from `getMIR.m` (and its nested +`pamica/metrics/mir.py` is ported from `getMIR.m` (and its nested `getent4`) in Arnaud Delorme's [`bigdelys/pre_ICA_cleaning`](https://github.com/bigdelys/pre_ICA_cleaning) repository, at diff --git a/_typos.toml b/_typos.toml index 45a5e78..0cde0e4 100644 --- a/_typos.toml +++ b/_typos.toml @@ -5,7 +5,7 @@ [files] extend-exclude = [ "*.f90", # read-only Fortran reference (amica17*.f90, funmod2.f90) - "pyAMICA/sample_data/", # sample EEG + Fortran outputs + saved .npy results + "pamica/sample_data/", # sample EEG + Fortran outputs + saved .npy results "pytorch_debug_test/", # generated debug output "uv.lock", "paper.bib", # citation metadata: accented author names + LaTeX escapes diff --git a/benchmarks/benchmark_decompose.py b/benchmarks/benchmark_decompose.py index 3134298..2824d78 100644 --- a/benchmarks/benchmark_decompose.py +++ b/benchmarks/benchmark_decompose.py @@ -67,7 +67,7 @@ def _load_dimsweep(): def _fit_torch(data, device, dtype_str, iters, threads=None): import torch - from pyAMICA.torch_impl import AMICATorchNG + from pamica.torch_impl import AMICATorchNG dtype = torch.float64 if dtype_str == "f64" else torch.float32 prev = torch.get_num_threads() @@ -105,7 +105,7 @@ def _fit_torch(data, device, dtype_str, iters, threads=None): def _fit_mlx(data, iters): - from pyAMICA.mlx_impl import AMICAMLXNG + from pamica.mlx_impl import AMICAMLXNG m = AMICAMLXNG( n_channels=data.shape[0], diff --git a/benchmarks/benchmark_dimsweep.py b/benchmarks/benchmark_dimsweep.py index 8d0499a..463c003 100644 --- a/benchmarks/benchmark_dimsweep.py +++ b/benchmarks/benchmark_dimsweep.py @@ -68,7 +68,7 @@ # Native-Fortran backend (#85): paths + run limits. _REPO_ROOT = Path(__file__).resolve().parent.parent -SAMPLE_DIR = _REPO_ROOT / "pyAMICA" / "sample_data" +SAMPLE_DIR = _REPO_ROOT / "pamica" / "sample_data" _PARAM_TEMPLATE = SAMPLE_DIR / "input.param" _DEFAULT_FORTRAN_BIN = _REPO_ROOT / "benchmarks" / "fortran" / "amica15" _FORTRAN_TIMEOUT = 3600 # seconds; a crashed/hung run must not hang the sweep @@ -106,7 +106,7 @@ def _run_torch( ): import torch - from pyAMICA.torch_impl import AMICATorchNG + from pamica.torch_impl import AMICATorchNG dtype = torch.float64 if dtype_str == "f64" else torch.float32 @@ -149,7 +149,7 @@ def one(n_iter): def _run_mlx(data, iters, repeats, n_models=1): - from pyAMICA.mlx_impl import AMICAMLXNG + from pamica.mlx_impl import AMICAMLXNG def one(n_iter): m = AMICAMLXNG( @@ -178,7 +178,7 @@ def _run_numpy(data, iters, repeats, n_models=1, share=False, threads=None): from threadpoolctl import threadpool_limits - from pyAMICA.numpy_impl.core import AMICA as AMICA_NumPy + from pamica.numpy_impl.core import AMICA as AMICA_NumPy # NumPy uses share_int (torch uses share_iter). share_kw: dict[str, Any] = ( diff --git a/benchmarks/benchmark_gpu.py b/benchmarks/benchmark_gpu.py index 575e83b..22c2a85 100644 --- a/benchmarks/benchmark_gpu.py +++ b/benchmarks/benchmark_gpu.py @@ -26,10 +26,10 @@ import numpy as np import torch -from pyAMICA.torch_impl.core import AMICATorchNG -from pyAMICA.torch_impl.utils import load_eeglab_data +from pamica.torch_impl.core import AMICATorchNG +from pamica.torch_impl.utils import load_eeglab_data -SAMPLE_DIR = Path(__file__).resolve().parent.parent / "pyAMICA" / "sample_data" +SAMPLE_DIR = Path(__file__).resolve().parent.parent / "pamica" / "sample_data" def _combos() -> list[tuple[str, torch.dtype]]: diff --git a/benchmarks/benchmark_runtime.py b/benchmarks/benchmark_runtime.py index 27213ad..8051c13 100644 --- a/benchmarks/benchmark_runtime.py +++ b/benchmarks/benchmark_runtime.py @@ -54,7 +54,7 @@ import torch REPO_ROOT = Path(__file__).resolve().parent.parent -SAMPLE_DIR = REPO_ROOT / "pyAMICA" / "sample_data" +SAMPLE_DIR = REPO_ROOT / "pamica" / "sample_data" DATA_FILE = SAMPLE_DIR / "eeglab_data.fdt" PARAM_TEMPLATE = SAMPLE_DIR / "input.param" FORTRAN_BINARY = SAMPLE_DIR / "amica15mac" @@ -63,9 +63,9 @@ # params.json keys AMICATorchNG accepts as constructor kwargs; anything else is # handled explicitly (below) or does not apply to the backend. -from pyAMICA import AMICA # noqa: E402 -from pyAMICA.torch_impl import AMICATorchNG # noqa: E402 -from pyAMICA.torch_impl.utils import load_eeglab_data # noqa: E402 +from pamica import AMICA # noqa: E402 +from pamica.torch_impl import AMICATorchNG # noqa: E402 +from pamica.torch_impl.utils import load_eeglab_data # noqa: E402 import inspect # noqa: E402 diff --git a/benchmarks/fortran/README.md b/benchmarks/fortran/README.md index fd34e7b..e4181c4 100644 --- a/benchmarks/fortran/README.md +++ b/benchmarks/fortran/README.md @@ -6,9 +6,9 @@ against the torch / mlx / numpy backends. ## Why build from source (not `amica15mac`) -The bundled `pyAMICA/sample_data/amica15mac` is an **x86_64** binary. On Apple Silicon +The bundled `pamica/sample_data/amica15mac` is an **x86_64** binary. On Apple Silicon it only runs under Rosetta 2, so its timing is not representative of native hardware. -The benchmark therefore builds `amica15` from `pyAMICA/{funmod2,amica15}.f90` natively on +The benchmark therefore builds `amica15` from `pamica/{funmod2,amica15}.f90` natively on each host -- the x86 Linux/CUDA host *and* Apple Silicon -- and times *that*. Both are honest native-CPU rows. (`amica15mac` is still fine for structural smoke-testing of the adapter on a Mac; it is just not a timing reference.) @@ -107,5 +107,5 @@ tracked reference source** (it patches a build copy under `build/src/`): vendor SIMD math library could make the exp/log-heavy E-step somewhat faster, so treat this as a portable-baseline timing, not a max-tuned one. -These are generic gfortran-portability fixes (not pyAMICA-specific) and are candidates to +These are generic gfortran-portability fixes (not pamica-specific) and are candidates to upstream to [sccn/amica](https://github.com/sccn/amica). diff --git a/benchmarks/fortran/build_amica.sh b/benchmarks/fortran/build_amica.sh index b2a0319..95dfd8a 100755 --- a/benchmarks/fortran/build_amica.sh +++ b/benchmarks/fortran/build_amica.sh @@ -17,7 +17,7 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "$here/../.." && pwd)" -src_dir="${AMICA_SRC:-$repo_root/pyAMICA}" +src_dir="${AMICA_SRC:-$repo_root/pamica}" fc="${FC:-mpif90}" build_dir="$here/build" out="$here/amica15" diff --git a/codecov.yml b/codecov.yml index 706a373..a1c5c4d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -16,7 +16,7 @@ coverage: # macOS x86_64 reference), so their code paths are reported via local runs, not # Codecov. Keep the report focused on the package. ignore: - - "pyAMICA/tests/**" + - "pamica/tests/**" - "validate_implementations.py" - "benchmarks/**" diff --git a/docs/api/amica.md b/docs/api/amica.md index d1783ff..5c2e0b7 100644 --- a/docs/api/amica.md +++ b/docs/api/amica.md @@ -3,4 +3,4 @@ The main scikit-learn-style interface. Wraps the natural-gradient EM backend ([`AMICATorchNG`](torch-backend.md)). -::: pyAMICA.AMICA +::: pamica.AMICA diff --git a/docs/api/index.md b/docs/api/index.md index 6760785..e9b7e45 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -3,17 +3,17 @@ The public import surface is stable: ```python -from pyAMICA import AMICA, AMICA_NumPy, AMICATorchNG +from pamica import AMICA, AMICA_NumPy, AMICATorchNG ``` - **[`AMICA`](amica.md)** — the main scikit-learn-style interface. Wraps the PyTorch natural-gradient EM backend. Start here. - **[`AMICATorchNG`](torch-backend.md)** — the PyTorch natural-gradient EM backend (Fortran parity). The `AMICA` interface delegates to this class. -- **[`pyAMICA.metrics`](metrics.md)** — separation-quality metrics (`mir`, +- **[`pamica.metrics`](metrics.md)** — separation-quality metrics (`mir`, `pairwise_mi`, `block_diagonal_order`) as free functions over plain arrays. Also reachable as `AMICA.mir`/`AMICA.pmi` on a fitted model. -- **[`pyAMICA.viz`](viz.md)** — backend-agnostic plots over a written +- **[`pamica.viz`](viz.md)** — backend-agnostic plots over a written `amicaout` directory. - **[`AMICA_NumPy`](numpy-backend.md)** — the legacy NumPy reference implementation, retained as an oracle and for its command-line interface. @@ -22,7 +22,7 @@ The optional Apple-Silicon GPU backend is imported separately and is not part of the default import surface: ```python -from pyAMICA.mlx_impl import AMICAMLXNG # requires the `mlx` extra +from pamica.mlx_impl import AMICAMLXNG # requires the `mlx` extra ``` - **[`AMICAMLXNG`](mlx-backend.md)** — the optional Apple-GPU (MLX) backend; the diff --git a/docs/api/metrics.md b/docs/api/metrics.md index 2e337cf..f2e8753 100644 --- a/docs/api/metrics.md +++ b/docs/api/metrics.md @@ -1,4 +1,4 @@ -# Separation metrics (pyAMICA.metrics) +# Separation metrics (pamica.metrics) Quality metrics for a decomposition, as free functions over plain arrays. They are backend-agnostic and independent of any fitted model object, so they work on @@ -23,7 +23,7 @@ unmixing from somewhere else. To plot a `pairwise_mi` matrix, see masks the entropy diagonal. ```python -from pyAMICA.metrics import mir, pairwise_mi, block_diagonal_order +from pamica.metrics import mir, pairwise_mi, block_diagonal_order ``` ## Provenance @@ -35,13 +35,13 @@ with the original to 1.7e-15 relative on the bundled sample data. `pairwise_mi` and `block_diagonal_order` are a clean-room reimplementation. The comparable MATLAB code (`minfojp.m` in postAmicaUtility) is GPL-2.0-or-later and -pyAMICA is BSD-3-Clause, so that source was never read; the implementation works +pamica is BSD-3-Clause, so that source was never read; the implementation works from the published description in Delorme et al. (2012), "Independent EEG sources are dipolar", PLoS ONE. It agrees with that reference at r=0.9887 on identical signals. -::: pyAMICA.metrics.mir +::: pamica.metrics.mir -::: pyAMICA.metrics.pairwise_mi +::: pamica.metrics.pairwise_mi -::: pyAMICA.metrics.block_diagonal_order +::: pamica.metrics.block_diagonal_order diff --git a/docs/api/mlx-backend.md b/docs/api/mlx-backend.md index c2f2191..a132ce8 100644 --- a/docs/api/mlx-backend.md +++ b/docs/api/mlx-backend.md @@ -8,14 +8,14 @@ fastest option on Apple hardware; see [Backends & Devices](../guides/backends.md for the performance comparison. MLX is an optional dependency (Apple Silicon only), so it is imported separately -and is not part of the default `import pyAMICA` surface: +and is not part of the default `import pamica` surface: ```python -from pyAMICA.mlx_impl import AMICAMLXNG # requires the `mlx` extra +from pamica.mlx_impl import AMICAMLXNG # requires the `mlx` extra ``` -Install it with `uv pip install mlx` or the `mlx` extra (`pip install pyAMICA[mlx]`). +Install it with `uv pip install mlx` or the `mlx` extra (`pip install pamica[mlx]`). Because it computes in float32, use the [PyTorch backend](torch-backend.md) on CUDA/CPU for float64 Fortran-parity runs. -::: pyAMICA.mlx_impl.AMICAMLXNG +::: pamica.mlx_impl.AMICAMLXNG diff --git a/docs/api/numpy-backend.md b/docs/api/numpy-backend.md index 7bbb326..c6284be 100644 --- a/docs/api/numpy-backend.md +++ b/docs/api/numpy-backend.md @@ -5,4 +5,4 @@ command-line interface. It carries the same parity fixes as the PyTorch backend, plus baralpha and outlier rejection (`do_reject`), which mirrors the PyTorch backend's `good_idx` sample-dropping mechanism (issue #123). -::: pyAMICA.AMICA_NumPy +::: pamica.AMICA_NumPy diff --git a/docs/api/torch-backend.md b/docs/api/torch-backend.md index b6a0706..d39ea87 100644 --- a/docs/api/torch-backend.md +++ b/docs/api/torch-backend.md @@ -5,4 +5,4 @@ mixture updates, symmetric-ZCA sphere, Jacobian log-likelihood). The [`AMICA`](amica.md) interface delegates to this class; use it directly for lower-level control. -::: pyAMICA.AMICATorchNG +::: pamica.AMICATorchNG diff --git a/docs/api/viz.md b/docs/api/viz.md index 4c3b0e8..c220f5f 100644 --- a/docs/api/viz.md +++ b/docs/api/viz.md @@ -1,14 +1,14 @@ -# Visualization (pyAMICA.viz) +# Visualization (pamica.viz) Top-level, backend-agnostic plots for a fitted model's output -(`pyAMICA.numpy_impl.load.AmicaOutput`, as returned by -`pyAMICA.numpy_impl.load.loadmodout`). Unlike the legacy -`pyAMICA.numpy_impl.viz` module, these functions **return a `Figure`** and +(`pamica.numpy_impl.load.AmicaOutput`, as returned by +`pamica.numpy_impl.load.loadmodout`). Unlike the legacy +`pamica.numpy_impl.viz` module, these functions **return a `Figure`** and accept an optional `ax`/`axes` to draw on, instead of returning `None` and mutating pyplot's global state. - **`plot_pmi_heatmap`** — a components-by-components pairwise mutual-information - heatmap (see `pyAMICA.metrics.pairwise_mi`), reordered to cluster related + heatmap (see `pamica.metrics.pairwise_mi`), reordered to cluster related components near the diagonal. - **`plot_model_probability`** — for a multi-model fit, two stacked panels: each model's posterior probability over time, and the log-likelihood of the most @@ -18,9 +18,9 @@ activations from a loaded `AmicaOutput` depends on an unsettled `W` convention question, tracked in [#159](https://github.com/sccn/pyAMICA/issues/159). ```python -from pyAMICA import plot_pmi_heatmap, plot_model_probability +from pamica import plot_pmi_heatmap, plot_model_probability ``` -::: pyAMICA.viz.plot_pmi_heatmap +::: pamica.viz.plot_pmi_heatmap -::: pyAMICA.viz.plot_model_probability +::: pamica.viz.plot_model_probability diff --git a/docs/changelog.md b/docs/changelog.md index 2cab389..b7d4296 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -14,7 +14,7 @@ Release notes are also published on the released as a self-contained binary for macOS arm64, Linux x64/arm64 and Windows x64 (Windows arm64 runs the x64 binary via emulation until a native toolchain exists, issue #173). The binary is resolved for the host and downloaded from the - release on first use (SHA-256 verified); `python -m pyAMICA.native` installs it + release on first use (SHA-256 verified); `python -m pamica.native` installs it explicitly, or set `PAMICA_NATIVE_BINARY` to a local build (epic #165). - Fixed `loadmodout` reading `W`, `sbeta` and `rho` in the wrong byte order: it used C order where the writer, genuine Fortran output and EEGLAB's @@ -31,22 +31,22 @@ Release notes are also published on the supported `sources(X, model=0)` accessor (the loaded-fit counterpart of the live model's `transform`) so downstream source derivations no longer hand-roll the sphere/unmixing composition (#159). Migration note: a *multi-model* - `amicaout` directory written by an earlier pyAMICA (whose `W` used the old + `amicaout` directory written by an earlier pamica (whose `W` used the old model-interleaved layout) must be regenerated with `write_amica_output`, not just re-loaded; there is no version marker to detect the old layout (genuine Fortran output carries none either), and the pre-fix multi-model `W` was never in the correct convention regardless. Single-model directories are unaffected (byte-identical before and after). -- Separation-quality metrics (`pyAMICA.metrics`): `mir` (Mutual Information +- Separation-quality metrics (`pamica.metrics`): `mir` (Mutual Information Reduction, in nats) measures how much mutual information a fitted unmixing removes from the data. A direct port of `getMIR.m` from bigdelys/pre_ICA_cleaning (Apache-2.0; see `THIRD_PARTY_NOTICES.md`), verified against the original at 1.7e-15 relative on the bundled sample EEG (#134). -- `pairwise_mi` and `block_diagonal_order` (`pyAMICA.metrics`): the pairwise +- `pairwise_mi` and `block_diagonal_order` (`pamica.metrics`): the pairwise mutual-information matrix between fitted sources, plus a greedy nearest-neighbour-chain ordering that clusters dependent components near the diagonal. A clean-room reimplementation: the reference (`minfojp.m` in - postAmicaUtility) is GPL-2.0-or-later and pyAMICA is BSD-3-Clause, so its + postAmicaUtility) is GPL-2.0-or-later and pamica is BSD-3-Clause, so its source was never read. Agrees with that reference at r=0.9887 on identical signals (#135). - `LLt` output parity with the Fortran reference: both backends now write the @@ -60,17 +60,17 @@ Release notes are also published on the - `AMICATorchNG`/`AMICA` gain `mir()`/`pmi()` accessors that compose the fitted unmixing the documented way (`get_unmixing_matrix(model_idx) @ sphere` for MIR, `transform(X, model_idx)` for PMI) and delegate to - `pyAMICA.metrics.mir`/`pairwise_mi`, so callers no longer hand-compose the + `pamica.metrics.mir`/`pairwise_mi`, so callers no longer hand-compose the transform themselves. `fit()` also accepts `mir_step` (default `0`, off) to record MIR waypoints during training in `mir_history_` as `(iteration, mir_nats, variance)`; like `ll_history_`, it is a true trajectory that a `keep_best` restore does not rewrite. PCA reduction (`pcakeep`/`pcadb`) is rejected up front with a named error, since it leaves the sphere rank-deficient and MIR's log-Jacobian undefined (#137). -- Visualization module (`pyAMICA.viz`): `plot_pmi_heatmap` and +- Visualization module (`pamica.viz`): `plot_pmi_heatmap` and `plot_model_probability`, backend-agnostic views over `AmicaOutput` that return a `Figure` (and accept an optional `ax`/`axes`) rather than mutating pyplot - global state, plus `read_eeglab_set_metadata` for the sample rate pyAMICA + global state, plus `read_eeglab_set_metadata` for the sample rate pamica itself has no notion of. Both plots are verified against the MATLAB reference: the smoothed model probability matches `smooth_amica_prob` at r=0.9886, and `pairwise_mi` matches `minfojp` at r=0.9887 (#136). diff --git a/docs/concepts/how-amica-works.md b/docs/concepts/how-amica-works.md index 3016252..191bbd6 100644 --- a/docs/concepts/how-amica-works.md +++ b/docs/concepts/how-amica-works.md @@ -73,13 +73,13 @@ $$ - **Newton update.** Once the iterates are close, AMICA switches the unmixing update to a **Newton step** (using the per-source curvature of the - likelihood), which sharpens convergence near the optimum. pyAMICA keeps this + likelihood), which sharpens convergence near the optimum. pamica keeps this step positive-definite for stability. ### Convergence and the returned solution Each EM iteration increases the log-likelihood until it converges. Because the -learning-rate schedule is not strictly monotone, pyAMICA returns the +learning-rate schedule is not strictly monotone, pamica returns the **highest-likelihood iterate** it visited (the *best-iterate* safeguard) rather than the last one, and reports its likelihood as `final_ll_`. @@ -91,7 +91,7 @@ and converges toward the reference solution. ## Relationship to the Fortran reference -Every M-step update in pyAMICA is derived to match the AMICA reference Fortran +Every M-step update in pamica is derived to match the AMICA reference Fortran implementation, and on real sample EEG the natural-gradient backend reaches the same solution (log-likelihood and Hungarian-matched component correlation). See [Validation & Parity](../guides/validation.md) for the acceptance criteria and diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 15b2afb..76eb9c5 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -1,6 +1,6 @@ # Background -This section explains the ideas behind pyAMICA from the ground up, for readers +This section explains the ideas behind pamica from the ground up, for readers new to independent component analysis. - **[What is ICA?](what-is-ica.md)** — the blind source separation problem, the diff --git a/docs/concepts/what-is-amica.md b/docs/concepts/what-is-amica.md index ff8c2e6..821f0f7 100644 --- a/docs/concepts/what-is-amica.md +++ b/docs/concepts/what-is-amica.md @@ -51,7 +51,7 @@ An adaptive source density (solid) as a weighted sum of generalized Gaussian mixture components (dashed). /// -pyAMICA supports all five source-density families of the reference +pamica supports all five source-density families of the reference implementation (generalized Gaussian is the default), plus an extended-Infomax switcher that flips each source between super- and sub-Gaussian by the sign of its kurtosis. diff --git a/docs/concepts/what-is-ica.md b/docs/concepts/what-is-ica.md index 01c14fa..41c897b 100644 --- a/docs/concepts/what-is-ica.md +++ b/docs/concepts/what-is-ica.md @@ -79,7 +79,7 @@ In practice the data are first **centered** (mean removed) and **whitened** (also called *sphering*): linearly transformed so the channels are uncorrelated and have unit variance. Whitening removes all second-order structure, reducing the remaining ICA problem to finding an orthogonal rotation, which is both -faster and better conditioned. pyAMICA uses a symmetric (ZCA) whitening that +faster and better conditioned. pamica uses a symmetric (ZCA) whitening that matches the Fortran reference. ## What ICA cannot pin down @@ -92,7 +92,7 @@ irreducible: scalar can move between a column of $\mathbf{A}$ and the corresponding source. These do not affect the usefulness of the components; they only mean component -*indices* and *scaling* are conventions, not ground truth. pyAMICA follows the +*indices* and *scaling* are conventions, not ground truth. pamica follows the EEGLAB conventions for ordering and sign where it matters (see [Validation & Parity](../guides/validation.md)). diff --git a/docs/development/testing.md b/docs/development/testing.md index 9431fba..c557a85 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -3,7 +3,7 @@ ## Real data only Correctness tests use real sample EEG and the reference Fortran binary shipped in -`pyAMICA/sample_data/`. Mocks, stubs, and synthetic data are not used as the +`pamica/sample_data/`. Mocks, stubs, and synthetic data are not used as the basis for correctness tests: no test is better than a fake passing test. ## Running the suite @@ -11,14 +11,14 @@ basis for correctness tests: no test is better than a fake passing test. ```bash uv run pytest # full suite uv run pytest --cov # with coverage -uv run pytest pyAMICA/tests/torch_tests/ # PyTorch-vs-Fortran parity tests +uv run pytest pamica/tests/torch_tests/ # PyTorch-vs-Fortran parity tests ``` ## Layout -- `pyAMICA/tests/` — end-to-end and interface tests. -- `pyAMICA/tests/torch_tests/` — natural-gradient backend parity, PDF families, +- `pamica/tests/` — end-to-end and interface tests. +- `pamica/tests/torch_tests/` — natural-gradient backend parity, PDF families, component sharing, float32 stability, and edge cases. -- `pyAMICA/tests/mlx_tests/` — MLX backend tests (Apple Silicon). +- `pamica/tests/mlx_tests/` — MLX backend tests (Apple Silicon). - `validate_implementations.py` — cross-implementation validation harness (Hungarian component matching against Fortran). diff --git a/docs/getting-started.md b/docs/getting-started.md index 3481f82..b77ca0b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,7 +2,7 @@ ## Installation -pyAMICA uses [UV](https://docs.astral.sh/uv/) for environment and dependency +pamica uses [UV](https://docs.astral.sh/uv/) for environment and dependency management. ### From source @@ -19,7 +19,7 @@ install from source as above. ### Optional Apple-Silicon GPU backend The MLX backend is Apple-only and is therefore an optional extra; `import -pyAMICA` never requires it. +pamica` never requires it. ```bash uv sync --extra mlx # or, in an existing environment: uv pip install mlx @@ -32,7 +32,7 @@ which wraps the natural-gradient EM backend. ```python import numpy as np -from pyAMICA import AMICA +from pamica import AMICA # X is (n_channels, n_samples); use real EEG/EMG rather than random data # for a meaningful decomposition. diff --git a/docs/guides/backends.md b/docs/guides/backends.md index efc4c24..a112e7f 100644 --- a/docs/guides/backends.md +++ b/docs/guides/backends.md @@ -1,6 +1,6 @@ # Backends & Devices -pyAMICA ships one primary PyTorch backend behind the [`AMICA`](../api/amica.md) +pamica ships one primary PyTorch backend behind the [`AMICA`](../api/amica.md) interface, plus an optional Apple-GPU backend and a legacy NumPy reference. ## Backends @@ -8,11 +8,11 @@ interface, plus an optional Apple-GPU backend and a legacy NumPy reference. | Backend | Class | Role | |---|---|---| | PyTorch natural-gradient EM | [`AMICATorchNG`](../api/torch-backend.md) | **Default.** Fortran-parity backend; CUDA / CPU, and float32 on MPS. | -| MLX (Apple GPU) | [`AMICAMLXNG`](../api/mlx-backend.md) (`pyAMICA.mlx_impl`) | Optional Apple-Silicon GPU backend; float32 only. | +| MLX (Apple GPU) | [`AMICAMLXNG`](../api/mlx-backend.md) (`pamica.mlx_impl`) | Optional Apple-Silicon GPU backend; float32 only. | | NumPy reference | [`AMICA_NumPy`](../api/numpy-backend.md) | Legacy oracle + CLI; carries the same parity fixes. | The `AMICA` wrapper uses `AMICATorchNG`. The MLX backend is imported separately -(`from pyAMICA.mlx_impl import AMICAMLXNG`) so that `import pyAMICA` never +(`from pamica.mlx_impl import AMICAMLXNG`) so that `import pamica` never requires MLX. ## Device selection diff --git a/docs/guides/eeglab.md b/docs/guides/eeglab.md index dc3e047..5b1e1f7 100644 --- a/docs/guides/eeglab.md +++ b/docs/guides/eeglab.md @@ -1,6 +1,6 @@ # EEGLAB interoperability -pyAMICA is a drop-in replacement for EEGLAB's AMICA: a fit written to disk loads +pamica is a drop-in replacement for EEGLAB's AMICA: a fit written to disk loads directly with the same reader EEGLAB uses (`loadmodout15.m`), with the components in the same order and orientation, so no manual re-sorting, sign-flipping, or reformatting is needed. @@ -10,7 +10,7 @@ reformatting is needed. After a fit, call `write_amica_output` with a destination directory: ```python -from pyAMICA import AMICA +from pamica import AMICA model = AMICA(n_models=1, n_mix=3) model.fit(X) # X is (n_channels, n_samples) @@ -55,7 +55,7 @@ mod = loadmodout15('amicaout'); `loadmodout15` applies the EEGLAB conventions on load: it orders components by back-projected variance (IC1 has the highest), derives the sensor-space mixing -`A = pinv(W * S)`, and normalizes each map to unit norm. Because pyAMICA writes +`A = pinv(W * S)`, and normalizes each map to unit norm. Because pamica writes the same format, the components you get in EEGLAB match a native AMICA run. ## Variance ordering in Python @@ -75,6 +75,6 @@ Pass `return_svar=True` to also get the per-component variances. Single-model output is byte-identical to the Fortran reference. For `n_models > 1` the per-model axis layout is self-consistent (it round-trips -through `loadmodout15` and pyAMICA's own reader) but is not byte-identical to a +through `loadmodout15` and pamica's own reader) but is not byte-identical to a native multi-model AMICA run; see the multi-model equivalence discussion in [Validation & Parity](validation.md). diff --git a/docs/guides/index.md b/docs/guides/index.md index bf8a33f..d5246a6 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -1,6 +1,6 @@ # User Guide -The user guide covers how to run pyAMICA in practice and how its results relate +The user guide covers how to run pamica in practice and how its results relate to the reference implementation. - **[Backends & Devices](backends.md)** — the available compute backends diff --git a/docs/guides/validation.md b/docs/guides/validation.md index 90a2ee6..9209cbb 100644 --- a/docs/guides/validation.md +++ b/docs/guides/validation.md @@ -1,6 +1,6 @@ # Validation & Parity -**Correctness in pyAMICA is defined as parity with the reference Fortran binary, +**Correctness in pamica is defined as parity with the reference Fortran binary, not merely as convergence.** A run is correct when it reproduces the Fortran output within numerical tolerance. This page collects the full verification evidence: bit-exact score functions, single-model parity, multi-model distributional similarity, cross-platform device and precision invariance, @@ -49,7 +49,7 @@ distance: - Log-likelihood ~ -3.6993 ($k\approx153$; Fortran ~ -3.6993, gap ~0.0003). - Hungarian-matched component correlation ~0.998 ($k\approx153$; Fortran-vs-Fortran self-consistency over the same 5 seeds: ~0.999), clearing the >0.95 gate. On the bundled sample ($k\approx30$) both - numbers are consistent: ~0.998 pyAMICA-vs-Fortran, ~0.998 Fortran-vs-Fortran. + numbers are consistent: ~0.998 pamica-vs-Fortran, ~0.998 Fortran-vs-Fortran. - Amari distance ~0.006 (bundled sample; Fortran-vs-Fortran: ~0.005). The fixed source-density families are bit-exact against the literal Fortran score/derivative expressions (~1e-15), @@ -78,24 +78,24 @@ The `pdftype=1` extended-Infomax switcher flips each source between the super-Ga sub-Gaussian (code 4) densities on a kurtosis schedule; its dynamic switch has no bit-exact oracle (the reference's `do_choose_pdfs` accumulator is dead code in the binary), so it is validated by real-data log-likelihood instead. Each fixed family converges within ~0.005 LL of the binary at a matched Newton budget. -See `pyAMICA/tests/torch_tests/test_ng_pdf_families.py` and ADR 0002. +See `pamica/tests/torch_tests/test_ng_pdf_families.py` and ADR 0002. ## Multi-model distributional similarity Multi-model AMICA is not partition-identifiable, so exact partition parity with Fortran is the wrong acceptance bar. The right test is whether the two implementations sample a similar distribution over solutions. Running an ensemble of `N = 20` fits per implementation on the bundled sample EEG (`n_models = 2`, 3 mixture components, 100 iterations, matched schedule), -the pyAMICA-vs-Fortran partition cross-correlation distribution overlaps Fortran's own run-to-run distribution: +the pamica-vs-Fortran partition cross-correlation distribution overlaps Fortran's own run-to-run distribution: | Distribution (pairwise Hungarian-matched \|corr\|) | Mean | SD | Range | |---|---:|---:|---| | within-Fortran (Fortran vs Fortran) | 0.638 | 0.040 | [0.572, 0.797] | -| within-pyAMICA (pyAMICA vs pyAMICA) | 0.661 | 0.045 | [0.583, 0.820] | -| between (pyAMICA vs Fortran) | 0.649 | 0.045 | [0.582, 0.886] | +| within-pamica (pamica vs pamica) | 0.661 | 0.045 | [0.583, 0.820] | +| between (pamica vs Fortran) | 0.649 | 0.045 | [0.582, 0.886] | -![Multi-model solution-ensemble cross-correlation distributions for pyAMICA and Fortran.](../assets/figures/multimodel-ensemble.png){ width=640 } +![Multi-model solution-ensemble cross-correlation distributions for pamica and Fortran.](../assets/figures/multimodel-ensemble.png){ width=640 } /// caption -Pairwise Hungarian-matched component correlation for 20 pyAMICA and 20 Fortran multi-model fits of the sample EEG. -The within-Fortran, within-pyAMICA, and between-implementation distributions overlap: the estimators sample the same solution space. +Pairwise Hungarian-matched component correlation for 20 pamica and 20 Fortran multi-model fits of the sample EEG. +The within-Fortran, within-pamica, and between-implementation distributions overlap: the estimators sample the same solution space. /// The three distribution means lie within 0.011 of each other (between 0.649, within-Fortran 0.638), well inside a $\pm 0.05$ margin. To test this at the correct unit of analysis, we use a **run-level permutation test**: @@ -106,9 +106,9 @@ Permuting the 40 runs as intact units instead (20000 permutations, statistic = w The single-run cross-correlation of ~0.65 is therefore intrinsic estimator spread, not a shortfall: Fortran agrees with *itself* at 0.64. The per-block sufficient statistics and one M-step are bit-exact against Fortran (~$10^{-15}$), so the update equations are correct; -a small residual in the log-likelihood *distribution* (pyAMICA $-3.363 \pm 0.006$ vs Fortran $-3.354 \pm 0.003$; +a small residual in the log-likelihood *distribution* (pamica $-3.363 \pm 0.006$ vs Fortran $-3.354 \pm 0.003$; Kolmogorov-Smirnov $p \approx 6\times10^{-5}$) is an optimizer-quality effect, -not a model-correctness defect (pyAMICA reaches Fortran's mean with about twice as many iterations). +not a model-correctness defect (pamica reaches Fortran's mean with about twice as many iterations). ### Amari distance: a second, assignment-free metric @@ -117,14 +117,14 @@ The Amari distance does not: it is permutation- and scale-invariant by construct so it is a genuinely independent check on the same 20-run ensembles (`.context/issue-27/ensemble.npz`; recomputed by `.context/issue-27/amari_distance.py` with no re-fitting, since the raw unmixing matrices from the original 40 fits are already saved). Each stacked 2-model matrix is split into its per-model 32x32 blocks; -since which Fortran model corresponds to which pyAMICA model is not identified, both label pairings are tried and the lower-distance pairing is kept, per run pair. -This pairing correction is not free: on this ensemble it lowers the reported distance by ~0.02-0.03 versus always keeping the naive (unswapped) pairing, a similar order of magnitude to the within-Fortran/within-pyAMICA gap below, so part of that gap plausibly reflects how often each group happens to need the swap, not just genuine agreement differences. +since which Fortran model corresponds to which pamica model is not identified, both label pairings are tried and the lower-distance pairing is kept, per run pair. +This pairing correction is not free: on this ensemble it lowers the reported distance by ~0.02-0.03 versus always keeping the naive (unswapped) pairing, a similar order of magnitude to the within-Fortran/within-pamica gap below, so part of that gap plausibly reflects how often each group happens to need the swap, not just genuine agreement differences. | Distribution (Amari distance, lower is better) | Mean | SD | |---|---:|---:| | within-Fortran (Fortran vs Fortran) | 0.174 | 0.023 | -| within-pyAMICA (pyAMICA vs pyAMICA) | 0.154 | 0.019 | -| between (pyAMICA vs Fortran) | 0.163 | 0.022 | +| within-pamica (pamica vs pamica) | 0.154 | 0.019 | +| between (pamica vs Fortran) | 0.163 | 0.022 | The same run-level permutation test (20000 permutations, intact 40-run units) finds no evidence that between-implementation agreement is worse than Fortran's own run-to-run agreement ($p > 0.999$), agreeing with the correlation-based conclusion above. @@ -156,30 +156,30 @@ The same run-level permutation test (20000 permutations, intact 40-run units) fi | Fortran | 17 | 0.6280 | 0.6483 | 0.1749 | 0.1632 | | Fortran | 18 | 0.6441 | 0.6371 | 0.1691 | 0.1665 | | Fortran | 19 | 0.6389 | 0.6505 | 0.1733 | 0.1637 | - | pyAMICA | 0 | 0.6317 | 0.6149 | 0.1690 | 0.1806 | - | pyAMICA | 1 | 0.6935 | 0.6777 | 0.1440 | 0.1529 | - | pyAMICA | 2 | 0.6624 | 0.6583 | 0.1545 | 0.1606 | - | pyAMICA | 3 | 0.6406 | 0.6459 | 0.1527 | 0.1561 | - | pyAMICA | 4 | 0.6493 | 0.6384 | 0.1506 | 0.1569 | - | pyAMICA | 5 | 0.6755 | 0.6591 | 0.1548 | 0.1622 | - | pyAMICA | 6 | 0.6339 | 0.6236 | 0.1677 | 0.1807 | - | pyAMICA | 7 | 0.6809 | 0.6788 | 0.1417 | 0.1479 | - | pyAMICA | 8 | 0.6780 | 0.6552 | 0.1508 | 0.1631 | - | pyAMICA | 9 | 0.6270 | 0.6206 | 0.1703 | 0.1823 | - | pyAMICA | 10 | 0.6855 | 0.6665 | 0.1474 | 0.1612 | - | pyAMICA | 11 | 0.6739 | 0.6556 | 0.1506 | 0.1612 | - | pyAMICA | 12 | 0.6321 | 0.6190 | 0.1617 | 0.1753 | - | pyAMICA | 13 | 0.6639 | 0.6610 | 0.1519 | 0.1597 | - | pyAMICA | 14 | 0.6866 | 0.6849 | 0.1460 | 0.1520 | - | pyAMICA | 15 | 0.6944 | 0.6672 | 0.1417 | 0.1559 | - | pyAMICA | 16 | 0.6576 | 0.6533 | 0.1551 | 0.1633 | - | pyAMICA | 17 | 0.6435 | 0.6249 | 0.1615 | 0.1756 | - | pyAMICA | 18 | 0.6753 | 0.6547 | 0.1442 | 0.1549 | - | pyAMICA | 19 | 0.6302 | 0.6180 | 0.1562 | 0.1655 | + | pamica | 0 | 0.6317 | 0.6149 | 0.1690 | 0.1806 | + | pamica | 1 | 0.6935 | 0.6777 | 0.1440 | 0.1529 | + | pamica | 2 | 0.6624 | 0.6583 | 0.1545 | 0.1606 | + | pamica | 3 | 0.6406 | 0.6459 | 0.1527 | 0.1561 | + | pamica | 4 | 0.6493 | 0.6384 | 0.1506 | 0.1569 | + | pamica | 5 | 0.6755 | 0.6591 | 0.1548 | 0.1622 | + | pamica | 6 | 0.6339 | 0.6236 | 0.1677 | 0.1807 | + | pamica | 7 | 0.6809 | 0.6788 | 0.1417 | 0.1479 | + | pamica | 8 | 0.6780 | 0.6552 | 0.1508 | 0.1631 | + | pamica | 9 | 0.6270 | 0.6206 | 0.1703 | 0.1823 | + | pamica | 10 | 0.6855 | 0.6665 | 0.1474 | 0.1612 | + | pamica | 11 | 0.6739 | 0.6556 | 0.1506 | 0.1612 | + | pamica | 12 | 0.6321 | 0.6190 | 0.1617 | 0.1753 | + | pamica | 13 | 0.6639 | 0.6610 | 0.1519 | 0.1597 | + | pamica | 14 | 0.6866 | 0.6849 | 0.1460 | 0.1520 | + | pamica | 15 | 0.6944 | 0.6672 | 0.1417 | 0.1559 | + | pamica | 16 | 0.6576 | 0.6533 | 0.1551 | 0.1633 | + | pamica | 17 | 0.6435 | 0.6249 | 0.1615 | 0.1756 | + | pamica | 18 | 0.6753 | 0.6547 | 0.1442 | 0.1549 | + | pamica | 19 | 0.6302 | 0.6180 | 0.1562 | 0.1655 | ## Cross-platform device and precision invariance -The strongest reassurance that pyAMICA is a single, well-defined implementation is that it recovers the *same* +The strongest reassurance that pamica is a single, well-defined implementation is that it recovers the *same* independent components no matter where or how it runs. Fitting the same real EEG (ds002718 sub-002, 147,000 frames, 70 channels, 2000 iterations) on every backend and Hungarian-matching the unmixing components across them: @@ -275,7 +275,7 @@ This is cross-*implementation* agreement, not just cross-device. The residual ga ## EEGLAB drop-in round-trip -pyAMICA writes the same on-disk format EEGLAB's AMICA plugin reads, so a fit is a drop-in replacement: +pamica writes the same on-disk format EEGLAB's AMICA plugin reads, so a fit is a drop-in replacement: no re-sorting, sign-flipping, or reformatting. After a fit, `write_amica_output(dir)` writes the raw binary files (`gm`, `W`, `S`, `mean`, `c`, `alpha`, `mu`, `sbeta`, `rho`, `comp_list`, `LL`) that EEGLAB's `loadmodout15.m` loads. @@ -292,7 +292,7 @@ The round-trip is verified two ways: `variance_order()` reproduces EEGLAB's IC ordering (IC1 = highest back-projected variance) in Python without a disk round-trip. For `n_models > 1` the layout is self-consistent and round-trips through both readers, but is not byte-identical to a native multi-model run (see the multi-model discussion above). -Full usage is in the [EEGLAB interoperability guide](eeglab.md); tests are in `pyAMICA/tests/torch_tests/test_amica_ng_wrapper.py`. +Full usage is in the [EEGLAB interoperability guide](eeglab.md); tests are in `pamica/tests/torch_tests/test_amica_ng_wrapper.py`. ## Performance across backends @@ -384,7 +384,7 @@ guarded to a no-op so the parity results above stay byte-for-byte unchanged. | Outlier rejection (`do_reject`, #123) | off by default | `good_idx` mechanism in both backends; NumPy port validated vs the PyTorch backend | | Degenerate-fit contract (#50) | always | a NaN or singular fit is refused (`converged_` / `stop_reason_`); `transform`/`get_*`/`save`/`write_amica_output` raise instead of returning NaN sources | -Tests live under `pyAMICA/tests/`: `torch_tests/test_ng_backend.py`, `torch_tests/test_ng_sharing.py`, `torch_tests/test_amica_ng_wrapper.py`, and `test_numpy_reject.py`. +Tests live under `pamica/tests/`: `torch_tests/test_ng_backend.py`, `torch_tests/test_ng_sharing.py`, `torch_tests/test_amica_ng_wrapper.py`, and `test_numpy_reject.py`. ## Reproducing these results @@ -417,6 +417,6 @@ the underlying findings are in `.context/issue-84/` and `.context/issue-90/`. `sample_data/sample_params.json` is the JSON parameter file used above (loaded via `AMICA.from_params_file`); its keys mostly reuse Fortran's `.param` names (`lrate`, `do_newton`, `rho0`, `block_size`, `max_iter`, `num_models`, ...), but not all of them match one-to-one -(for example `num_mix` here vs `num_mix_comps` in Fortran's `input.param`), and pyAMICA does not +(for example `num_mix` here vs `num_mix_comps` in Fortran's `input.param`), and pamica does not yet parse the literal Fortran `.param` text format. A native `.param` reader, so the same file drives both implementations, is tracked as a future issue. diff --git a/docs/index.md b/docs/index.md index 7ee1d91..7aa422e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -# pyAMICA +# pamica Python (PyTorch) implementation of **Adaptive Mixture Independent Component Analysis (AMICA)** that reproduces the results of the reference Fortran binary, @@ -6,12 +6,12 @@ with GPU / Apple-GPU / CPU support. AMICA is a blind source separation algorithm widely used for electroencephalography (EEG) and electromyography (EMG) source decomposition. -pyAMICA exposes a scikit-learn-style interface over a natural-gradient +pamica exposes a scikit-learn-style interface over a natural-gradient expectation-maximization (EM) backend that matches the Fortran reference (`amica15`) to within numerical tolerance: single-model log-likelihood and Hungarian-matched component correlation both agree with Fortran on real EEG. -## Why pyAMICA +## Why pamica - **Fortran parity is the specification.** Correctness is defined as matching the reference Fortran output within tolerance, not merely converging. See diff --git a/examples/torch_example.py b/examples/torch_example.py index 78b12ce..6ad83c8 100644 --- a/examples/torch_example.py +++ b/examples/torch_example.py @@ -2,7 +2,7 @@ """ Example of using the PyTorch AMICA implementation. -Uses the public :class:`~pyAMICA.AMICA` interface, which wraps the +Uses the public :class:`~pamica.AMICA` interface, which wraps the natural-gradient EM backend (``AMICATorchNG``) that matches the Fortran reference. @@ -18,8 +18,8 @@ import numpy as np # Public AMICA interface (wraps the natural-gradient EM backend) -from pyAMICA import AMICA -from pyAMICA.torch_impl.utils import load_eeglab_data, compare_with_fortran +from pamica import AMICA +from pamica.torch_impl.utils import load_eeglab_data, compare_with_fortran # Set up logging logging.basicConfig(level=logging.INFO) @@ -29,7 +29,7 @@ def main(): """Run PyTorch AMICA on sample data.""" # Paths - sample_dir = Path(__file__).parent.parent / "pyAMICA" / "sample_data" + sample_dir = Path(__file__).parent.parent / "pamica" / "sample_data" data_file = sample_dir / "eeglab_data.fdt" params_file = sample_dir / "sample_params.json" fortran_output = sample_dir / "amicaout" diff --git a/mkdocs.yml b/mkdocs.yml index c496c61..122717d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,10 +1,10 @@ -# pyAMICA documentation (MkDocs Material). +# pamica documentation (MkDocs Material). # Deployed to GitHub Pages by .github/workflows/docs.yml. The repo lives at # github.com/sccn/pyAMICA, whose project Pages inherit the sccn org Pages custom -# domain (eeglab.org) at the /pyAMICA subpath, so the site is served at +# domain (eeglab.org) at the /pamica subpath, so the site is served at # https://eeglab.org/pyAMICA/. Links are relative so the build is location-independent. -site_name: pyAMICA Documentation +site_name: pamica Documentation site_url: https://eeglab.org/pyAMICA/ site_description: Python (PyTorch) implementation of Adaptive Mixture ICA (AMICA) with Fortran parity and GPU/MPS/CPU support. site_author: Seyed Yahya Shirazi diff --git a/native/README.md b/native/README.md index 9f12f3b..6a5b6e3 100644 --- a/native/README.md +++ b/native/README.md @@ -19,7 +19,7 @@ framework, always present) and static reference LAPACK/OpenBLAS on Linux/Windows ## The single-rank MPI shim -`amica15.f90` is an MPI + OpenMP + LAPACK program, but pyAMICA always runs it as +`amica15.f90` is an MPI + OpenMP + LAPACK program, but pamica always runs it as one process. Every MPI collective it uses (`BCAST`, `REDUCE`, `ALLREDUCE`, `BARRIER`, `GATHER`, `COMM_SPLIT`, ...) is trivial for a single rank: broadcast is a no-op, reduce/gather is a copy, barrier is a no-op. `mpi_single.f90` supplies diff --git a/native/build.sh b/native/build.sh index 67df4e1..23e2b63 100755 --- a/native/build.sh +++ b/native/build.sh @@ -15,7 +15,7 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "$here/.." && pwd)" -src_dir="${AMICA_SRC:-$repo_root/pyAMICA}" +src_dir="${AMICA_SRC:-$repo_root/pamica}" mode="${MPI_MODE:-shim}" build_dir="$here/build_$mode" out="${OUT:-$here/amica15_$mode}" diff --git a/native/mpi_single.f90 b/native/mpi_single.f90 index 0e327a4..a4127a5 100644 --- a/native/mpi_single.f90 +++ b/native/mpi_single.f90 @@ -1,6 +1,6 @@ ! Single-rank MPI shim for AMICA (epic #165, phase 1). ! -! amica15.f90 is an MPI + OpenMP + LAPACK program, but pyAMICA always runs it as +! amica15.f90 is an MPI + OpenMP + LAPACK program, but pamica always runs it as ! ONE process. Every MPI collective it uses is trivial for a single rank ! (broadcast is a no-op, reduce/gather is a copy, barrier is a no-op), so instead ! of linking a real MPI runtime (Open MPI / MS-MPI), we provide a tiny stub. This diff --git a/native/validate_shim.sh b/native/validate_shim.sh index 1cdea5e..9522ef9 100755 --- a/native/validate_shim.sh +++ b/native/validate_shim.sh @@ -16,7 +16,7 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "$here/.." && pwd)" -sample="$repo_root/pyAMICA/sample_data" +sample="$repo_root/pamica/sample_data" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT @@ -30,7 +30,7 @@ fflags=(-O3 -fopenmp -cpp -ffree-line-length-none -std=legacy -fallow-argument-m build() { # build -> $work/amica_ local mode="$1" fc="$2" d="$work/b_$1" mkdir -p "$d" - cp "$repo_root/pyAMICA"/{funmod2,amica15,amica15_header}.f90 "$d/" + cp "$repo_root/pamica"/{funmod2,amica15,amica15_header}.f90 "$d/" # --pin-seed drops the clock term from the RNG seed so the shim and mpif90 # builds seed identically and can be compared bit-for-bit. Scoped to the seed # formula inside patch_sources.py (not a loose whole-file regex). diff --git a/pyAMICA/__init__.py b/pamica/__init__.py similarity index 94% rename from pyAMICA/__init__.py rename to pamica/__init__.py index d04cfd8..61b5ce8 100644 --- a/pyAMICA/__init__.py +++ b/pamica/__init__.py @@ -1,5 +1,5 @@ """ -pyAMICA: Python implementation of Adaptive Mixture ICA algorithm. +pamica: Python implementation of Adaptive Mixture ICA algorithm. This package provides a Python implementation of the Adaptive Mixture ICA (AMICA) algorithm for blind source separation using adaptive mixtures of independent diff --git a/pyAMICA/amica.py b/pamica/amica.py similarity index 97% rename from pyAMICA/amica.py rename to pamica/amica.py index c86311c..869c220 100644 --- a/pyAMICA/amica.py +++ b/pamica/amica.py @@ -26,7 +26,7 @@ class AMICA: """ Adaptive Mixture ICA using the PyTorch natural-gradient EM backend. - This is the main interface for pyAMICA, providing a scikit-learn style + This is the main interface for pamica, 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). @@ -79,7 +79,7 @@ class AMICA: Examples -------- - >>> from pyAMICA import AMICA + >>> from pamica import AMICA >>> import numpy as np >>> >>> # Generate sample data @@ -344,7 +344,7 @@ def mir( Mutual Information Reduction (issue #137) of the fitted unmixing on ``X``. Composes the full raw-data-to-sources transform (unmixing @ sphere) - the documented way and delegates to :func:`pyAMICA.metrics.mir`. + the documented way and delegates to :func:`pamica.metrics.mir`. Parameters ---------- @@ -353,7 +353,7 @@ def mir( model_idx : int, default=0 Which model's unmixing to use nbins : int, optional - Histogram bin count; see :func:`pyAMICA.metrics.mir` + Histogram bin count; see :func:`pamica.metrics.mir` Returns ------- @@ -369,7 +369,7 @@ def mir( ``pcadb``) is active, which leaves the sphere rank-deficient so MIR's log-Jacobian term is undefined; or if ``X`` is non-finite or has a constant channel. See :meth:`AMICATorchNG.mir` and - :func:`pyAMICA.metrics.mir`. + :func:`pamica.metrics.mir`. RuntimeError If the fit ended degenerate (issue #50), since the parameters are non-finite and any metric from them would be meaningless. @@ -385,7 +385,7 @@ def pmi( """ Pairwise Mutual Information (issue #137) between the fitted sources on ``X``. - Delegates to :func:`pyAMICA.metrics.pairwise_mi` on + Delegates to :func:`pamica.metrics.pairwise_mi` on ``transform(X, model_idx)``. Parameters @@ -395,14 +395,14 @@ def pmi( model_idx : int, default=0 Which model's sources to use nbins : int, optional - Histogram bin count; see :func:`pyAMICA.metrics.pairwise_mi` + Histogram bin count; see :func:`pamica.metrics.pairwise_mi` Returns ------- mi_matrix : np.ndarray of shape (n_sources, n_sources) Symmetric pairwise mutual information, in nats. The diagonal is each source's own entropy, not a mutual information; see - :func:`pyAMICA.viz.plot_pmi_heatmap`, which masks it by default. + :func:`pamica.viz.plot_pmi_heatmap`, which masks it by default. Raises ------ @@ -410,7 +410,7 @@ def pmi( If the model is unfitted; or if ``X`` is non-finite or has a constant channel; or if ``nbins`` is too large for the sample count (a sparse joint histogram fabricates mutual information from noise). - See :func:`pyAMICA.metrics.pairwise_mi`. + See :func:`pamica.metrics.pairwise_mi`. RuntimeError If the fit ended degenerate (issue #50). """ @@ -453,7 +453,7 @@ def write_amica_output(self, outdir: str) -> None: 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 + ``comp_list``, ``LL``), so a pamica 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 diff --git a/pyAMICA/amica15.f90 b/pamica/amica15.f90 similarity index 100% rename from pyAMICA/amica15.f90 rename to pamica/amica15.f90 diff --git a/pyAMICA/amica15_header.f90 b/pamica/amica15_header.f90 similarity index 100% rename from pyAMICA/amica15_header.f90 rename to pamica/amica15_header.f90 diff --git a/pyAMICA/amica17.f90 b/pamica/amica17.f90 similarity index 100% rename from pyAMICA/amica17.f90 rename to pamica/amica17.f90 diff --git a/pyAMICA/amica17_header.f90 b/pamica/amica17_header.f90 similarity index 100% rename from pyAMICA/amica17_header.f90 rename to pamica/amica17_header.f90 diff --git a/pyAMICA/funmod2.f90 b/pamica/funmod2.f90 similarity index 100% rename from pyAMICA/funmod2.f90 rename to pamica/funmod2.f90 diff --git a/pyAMICA/metrics/__init__.py b/pamica/metrics/__init__.py similarity index 100% rename from pyAMICA/metrics/__init__.py rename to pamica/metrics/__init__.py diff --git a/pyAMICA/metrics/_common.py b/pamica/metrics/_common.py similarity index 100% rename from pyAMICA/metrics/_common.py rename to pamica/metrics/_common.py diff --git a/pyAMICA/metrics/mir.py b/pamica/metrics/mir.py similarity index 100% rename from pyAMICA/metrics/mir.py rename to pamica/metrics/mir.py diff --git a/pyAMICA/metrics/pmi.py b/pamica/metrics/pmi.py similarity index 98% rename from pyAMICA/metrics/pmi.py rename to pamica/metrics/pmi.py index 9cac080..f3bd8db 100644 --- a/pyAMICA/metrics/pmi.py +++ b/pamica/metrics/pmi.py @@ -53,7 +53,7 @@ def pairwise_mi(sources: np.ndarray, nbins: int | None = None) -> np.ndarray: or raw channels). nbins : int, optional Histogram bin count per axis of the joint 2-D histogram. Defaults to - ``round(3 * log2(1 + N/10))``; see `pyAMICA.metrics._common.resolve_nbins`. + ``round(3 * log2(1 + N/10))``; see `pamica.metrics._common.resolve_nbins`. Returns ------- diff --git a/pyAMICA/mlx_impl/__init__.py b/pamica/mlx_impl/__init__.py similarity index 83% rename from pyAMICA/mlx_impl/__init__.py rename to pamica/mlx_impl/__init__.py index 01fe969..6ae63da 100644 --- a/pyAMICA/mlx_impl/__init__.py +++ b/pamica/mlx_impl/__init__.py @@ -1,8 +1,8 @@ -"""MLX backend for pyAMICA (Apple-Silicon GPU). +"""MLX backend for pamica (Apple-Silicon GPU). Mirrors ``torch_impl`` but targets Apple's MLX array framework. MLX is an *optional* dependency (Apple Silicon only); importing this subpackage requires -``mlx`` to be installed, so the top-level ``pyAMICA`` package never imports it +``mlx`` to be installed, so the top-level ``pamica`` package never imports it eagerly. Install with ``uv pip install mlx`` (or the ``mlx`` extra). The backend (:class:`AMICAMLXNG`) runs the natural-gradient EM E/M-step on the diff --git a/pyAMICA/mlx_impl/core.py b/pamica/mlx_impl/core.py similarity index 99% rename from pyAMICA/mlx_impl/core.py rename to pamica/mlx_impl/core.py index 7caa15d..0d04289 100644 --- a/pyAMICA/mlx_impl/core.py +++ b/pamica/mlx_impl/core.py @@ -1,6 +1,6 @@ """MLX natural-gradient EM backend for AMICA (issue #76/#81, epic #74 Phase C/D). -``AMICAMLXNG`` is a port of :class:`pyAMICA.torch_impl.core.AMICATorchNG` +``AMICAMLXNG`` is a port of :class:`pamica.torch_impl.core.AMICATorchNG` to Apple's MLX array framework, so the per-block E/M-step runs on the Apple GPU. It is structurally parallel to the PyTorch backend (same method names/order and Fortran citations) so the two can be diffed method-by-method. diff --git a/pyAMICA/native/__init__.py b/pamica/native/__init__.py similarity index 100% rename from pyAMICA/native/__init__.py rename to pamica/native/__init__.py diff --git a/pyAMICA/native/__main__.py b/pamica/native/__main__.py similarity index 76% rename from pyAMICA/native/__main__.py rename to pamica/native/__main__.py index 1c8955b..39dd10d 100644 --- a/pyAMICA/native/__main__.py +++ b/pamica/native/__main__.py @@ -1,8 +1,8 @@ """Install the native AMICA binary for this system (epic #165). - python -m pyAMICA.native # download+cache the latest release binary - python -m pyAMICA.native --version v0.2.0 # a specific release - python -m pyAMICA.native --print # just print where it would resolve to + python -m pamica.native # download+cache the latest release binary + python -m pamica.native --version v0.2.0 # a specific release + python -m pamica.native --print # just print where it would resolve to Downloads the release asset matching the host platform, verifies its SHA-256, and caches it; prints the resolved path. Idempotent (a cached binary is reused). @@ -17,7 +17,7 @@ def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="python -m pyAMICA.native") + parser = argparse.ArgumentParser(prog="python -m pamica.native") parser.add_argument( "--version", default="latest", help="release tag (default: latest)" ) diff --git a/pyAMICA/native/engine.py b/pamica/native/engine.py similarity index 97% rename from pyAMICA/native/engine.py rename to pamica/native/engine.py index a8e85fb..57738f5 100644 --- a/pyAMICA/native/engine.py +++ b/pamica/native/engine.py @@ -1,9 +1,9 @@ -"""``AMICANative``: run the native AMICA Fortran binary as a pyAMICA backend. +"""``AMICANative``: run the native AMICA Fortran binary as a pamica backend. The fourth run engine, alongside the NumPy, PyTorch (``AMICATorchNG``) and MLX backends. It writes the data and an ``input.param``, runs the dependency-free -binary resolved by :mod:`pyAMICA.native.resolver`, and reads the result back -through :func:`pyAMICA.numpy_impl.load.loadmodout` -- so its output is an +binary resolved by :mod:`pamica.native.resolver`, and reads the result back +through :func:`pamica.numpy_impl.load.loadmodout` -- so its output is an ``AmicaOutput`` with the same accessors (``.sources``, ``.W``, ``.A``, ...) as a loaded fit. This is the Fortran reference itself, so it is the parity oracle the Python backends are validated against. diff --git a/pyAMICA/native/resolver.py b/pamica/native/resolver.py similarity index 100% rename from pyAMICA/native/resolver.py rename to pamica/native/resolver.py diff --git a/pamica/numpy_impl/__init__.py b/pamica/numpy_impl/__init__.py new file mode 100644 index 0000000..524e275 --- /dev/null +++ b/pamica/numpy_impl/__init__.py @@ -0,0 +1,11 @@ +"""Legacy NumPy reference implementation of AMICA. + +Topic-named modules (``core``, ``newton``, ``pdf``, ``data``, ``load``, ``viz``, +``utils``, ``cli``), renamed from the old ``pamica.py``/``amica_*.py`` sprawl in +issue #34. The scikit-learn-style :class:`~pamica.numpy_impl.core.AMICA` is also +exposed at the package root as ``pamica.AMICA_NumPy``. +""" + +from .core import AMICA + +__all__ = ["AMICA"] diff --git a/pyAMICA/numpy_impl/cli.py b/pamica/numpy_impl/cli.py similarity index 98% rename from pyAMICA/numpy_impl/cli.py rename to pamica/numpy_impl/cli.py index b67302a..a4a7bd5 100644 --- a/pyAMICA/numpy_impl/cli.py +++ b/pamica/numpy_impl/cli.py @@ -10,7 +10,7 @@ 4. Result saving Example usage: - python -m pyAMICA.numpy_impl.cli params.json --outdir results --seed 42 # Use -m flag to run as module + python -m pamica.numpy_impl.cli params.json --outdir results --seed 42 # Use -m flag to run as module The parameter file should be in JSON format and must include: - files: List of binary data files to process diff --git a/pyAMICA/numpy_impl/core.py b/pamica/numpy_impl/core.py similarity index 99% rename from pyAMICA/numpy_impl/core.py rename to pamica/numpy_impl/core.py index 7e53a51..23dcb8b 100644 --- a/pyAMICA/numpy_impl/core.py +++ b/pamica/numpy_impl/core.py @@ -34,7 +34,7 @@ Usage Example ------------ >>> import numpy as np ->>> from pyAMICA import AMICA +>>> from pamica import AMICA >>> >>> # Generate random data >>> X = np.random.randn(64, 1000) # 64 channels, 1000 samples @@ -1012,7 +1012,7 @@ def _compute_full_posterior_ll(self) -> Tuple[np.ndarray, np.ndarray]: during iteration i's E-step, the M-step then updates the parameters, and ``write_output`` writes both -- so Fortran's on-disk LLt is stale by one M-step relative to the parameters written alongside it. This - method recomputes from the POST-update parameters, so pyAMICA's LLt is + method recomputes from the POST-update parameters, so pamica's LLt is self-consistent with the written W/A (better-behaved, not "fixed toward Fortran" -- do not change this to match Fortran's staleness). @@ -1630,7 +1630,7 @@ def _write_results(self): Writes raw little-endian float64 (and int32 ``comp_list``) files with no extension, in the layout that ``load.loadmodout`` reads (and that - ``load_results`` reads back), so pyAMICA output is loadable by the same + ``load_results`` reads back), so pamica output is loadable by the same reader as the Fortran reference (issue #30). This shares the ``write_amicaout`` writer with the torch backend, so its @@ -1638,7 +1638,7 @@ def _write_results(self): to the Fortran ``amicaout`` files, and since #159 multi-model output is written in genuine Fortran layout as well (each array's model axis slowest, column-major within a model), so EEGLAB's ``loadmodout15.m`` - reads both correctly. See :func:`pyAMICA.numpy_impl.load.write_amicaout`. + reads both correctly. See :func:`pamica.numpy_impl.load.write_amicaout`. Also writes ``LLt`` (issue #155) via ``_compute_full_posterior_ll``, which is called on every ``_write_results`` call -- including every @@ -1651,7 +1651,7 @@ def _write_results(self): # A is written (Fortran output omits it; loadmodout derives A from W and # S) only so load_results can restore it directly for the viz helpers. # The Fortran 'nd' file (per-component weight-change history) is a - # different quantity from pyAMICA's scalar self.nd, so it is not emitted + # different quantity from pamica's scalar self.nd, so it is not emitted # (loadmodout treats 'nd' as optional). from .load import write_amicaout @@ -1666,7 +1666,7 @@ def _write_results(self): c=self.c, alpha=self.alpha, mu=self.mu, - sbeta=self.beta, # Fortran's 'sbeta' is pyAMICA's beta (scale) + sbeta=self.beta, # Fortran's 'sbeta' is pamica's beta (scale) rho=self.rho, comp_list=self.comp_list, ll=np.asarray(self.ll), diff --git a/pyAMICA/numpy_impl/data.py b/pamica/numpy_impl/data.py similarity index 100% rename from pyAMICA/numpy_impl/data.py rename to pamica/numpy_impl/data.py diff --git a/pyAMICA/numpy_impl/load.py b/pamica/numpy_impl/load.py similarity index 98% rename from pyAMICA/numpy_impl/load.py rename to pamica/numpy_impl/load.py index d8521fb..910a919 100644 --- a/pyAMICA/numpy_impl/load.py +++ b/pamica/numpy_impl/load.py @@ -111,7 +111,7 @@ def write_amicaout( ``loadmodout15.m`` read: ``gm``, ``W``, ``S``, ``mean``, ``c``, ``alpha``, ``mu``, ``sbeta``, ``rho``, ``comp_list`` (1-based ``int32``), ``LL`` and (when given) ``LLt``. This is the write counterpart of :func:`loadmodout`, - so a pyAMICA fit (either backend) drops into an EEGLAB workflow (issue #92). + so a pamica fit (either backend) drops into an EEGLAB workflow (issue #92). Both backends store these arrays in the same convention. Single-model output is byte-identical to the Fortran reference's ``amicaout`` files. Multi-model @@ -129,14 +129,14 @@ def write_amicaout( Destination directory (created if absent). gm, W, sphere, mean, c, alpha, mu, sbeta, rho : array-like Model weights, unmixing, sphere, data mean, per-model centers and the - mixture-density parameters (``sbeta`` is the scale, pyAMICA's ``beta``). + mixture-density parameters (``sbeta`` is the scale, pamica's ``beta``). comp_list : array-like of int 0-based component ids; written 1-based to match the Fortran format. ll : array-like Per-iteration log-likelihood history. A : array-like, optional Mixing matrix. ``loadmodout15`` derives ``A`` from ``W`` and ``S`` and - ignores this file; it is written (when given) only so pyAMICA's own + ignores this file; it is written (when given) only so pamica's own ``load_results`` can restore ``A`` directly for the viz helpers. Lht : array-like of shape (num_models, n_samples), optional Per-model per-sample log-likelihood (Fortran ``modloglik``). Written @@ -475,9 +475,9 @@ def loadmodout(outdir: Union[str, Path]) -> AmicaOutput: def read_eeglab_set_metadata(path: Union[str, Path]) -> dict: """Read sample rate, channel positions, and labels from an EEGLAB ``.set`` file. - pyAMICA has no notion of sampling rate or channel geometry anywhere in its + pamica has no notion of sampling rate or channel geometry anywhere in its own data structures (`AmicaOutput`, `load_data_file`, `load_results` all - lack it); `pyAMICA.viz.plot_model_probability` needs the sample rate for a + lack it); `pamica.viz.plot_model_probability` needs the sample rate for a seconds x-axis. This is a minimal `scipy.io.loadmat` reader for exactly those fields, not a general EEGLAB-format loader. diff --git a/pyAMICA/numpy_impl/newton.py b/pamica/numpy_impl/newton.py similarity index 100% rename from pyAMICA/numpy_impl/newton.py rename to pamica/numpy_impl/newton.py diff --git a/pyAMICA/numpy_impl/params.json b/pamica/numpy_impl/params.json similarity index 100% rename from pyAMICA/numpy_impl/params.json rename to pamica/numpy_impl/params.json diff --git a/pyAMICA/numpy_impl/pdf.py b/pamica/numpy_impl/pdf.py similarity index 100% rename from pyAMICA/numpy_impl/pdf.py rename to pamica/numpy_impl/pdf.py diff --git a/pyAMICA/numpy_impl/utils.py b/pamica/numpy_impl/utils.py similarity index 100% rename from pyAMICA/numpy_impl/utils.py rename to pamica/numpy_impl/utils.py diff --git a/pyAMICA/numpy_impl/viz.py b/pamica/numpy_impl/viz.py similarity index 100% rename from pyAMICA/numpy_impl/viz.py rename to pamica/numpy_impl/viz.py diff --git a/pyAMICA/sample_data/ATTRIBUTION.md b/pamica/sample_data/ATTRIBUTION.md similarity index 89% rename from pyAMICA/sample_data/ATTRIBUTION.md rename to pamica/sample_data/ATTRIBUTION.md index be0ee1d..2d34875 100644 --- a/pyAMICA/sample_data/ATTRIBUTION.md +++ b/pamica/sample_data/ATTRIBUTION.md @@ -1,8 +1,8 @@ # Sample data and reference artifacts: provenance and licenses The files in this directory are third-party artifacts bundled solely to validate -pyAMICA against the reference AMICA implementation. They are used for testing and -documentation and are not part of the installable `pyAMICA` package. +pamica against the reference AMICA implementation. They are used for testing and +documentation and are not part of the installable `pamica` package. - **`amica15mac`** - the reference AMICA binary (macOS x86_64), compiled from the AMICA source distributed by the Swartz Center for Computational Neuroscience diff --git a/pyAMICA/sample_data/amica15mac b/pamica/sample_data/amica15mac similarity index 100% rename from pyAMICA/sample_data/amica15mac rename to pamica/sample_data/amica15mac diff --git a/pyAMICA/sample_data/amicaout/A b/pamica/sample_data/amicaout/A similarity index 100% rename from pyAMICA/sample_data/amicaout/A rename to pamica/sample_data/amicaout/A diff --git a/pyAMICA/sample_data/amicaout/LL b/pamica/sample_data/amicaout/LL similarity index 100% rename from pyAMICA/sample_data/amicaout/LL rename to pamica/sample_data/amicaout/LL diff --git a/pyAMICA/sample_data/amicaout/LLt b/pamica/sample_data/amicaout/LLt similarity index 100% rename from pyAMICA/sample_data/amicaout/LLt rename to pamica/sample_data/amicaout/LLt diff --git a/pyAMICA/sample_data/amicaout/S b/pamica/sample_data/amicaout/S similarity index 100% rename from pyAMICA/sample_data/amicaout/S rename to pamica/sample_data/amicaout/S diff --git a/pyAMICA/sample_data/amicaout/W b/pamica/sample_data/amicaout/W similarity index 100% rename from pyAMICA/sample_data/amicaout/W rename to pamica/sample_data/amicaout/W diff --git a/pyAMICA/sample_data/amicaout/alpha b/pamica/sample_data/amicaout/alpha similarity index 100% rename from pyAMICA/sample_data/amicaout/alpha rename to pamica/sample_data/amicaout/alpha diff --git a/pyAMICA/sample_data/amicaout/c b/pamica/sample_data/amicaout/c similarity index 100% rename from pyAMICA/sample_data/amicaout/c rename to pamica/sample_data/amicaout/c diff --git a/pyAMICA/sample_data/amicaout/comp_list b/pamica/sample_data/amicaout/comp_list similarity index 100% rename from pyAMICA/sample_data/amicaout/comp_list rename to pamica/sample_data/amicaout/comp_list diff --git a/pyAMICA/sample_data/amicaout/gm b/pamica/sample_data/amicaout/gm similarity index 100% rename from pyAMICA/sample_data/amicaout/gm rename to pamica/sample_data/amicaout/gm diff --git a/pyAMICA/sample_data/amicaout/mean b/pamica/sample_data/amicaout/mean similarity index 100% rename from pyAMICA/sample_data/amicaout/mean rename to pamica/sample_data/amicaout/mean diff --git a/pyAMICA/sample_data/amicaout/mu b/pamica/sample_data/amicaout/mu similarity index 100% rename from pyAMICA/sample_data/amicaout/mu rename to pamica/sample_data/amicaout/mu diff --git a/pyAMICA/sample_data/amicaout/out.txt b/pamica/sample_data/amicaout/out.txt similarity index 100% rename from pyAMICA/sample_data/amicaout/out.txt rename to pamica/sample_data/amicaout/out.txt diff --git a/pyAMICA/sample_data/amicaout/rho b/pamica/sample_data/amicaout/rho similarity index 100% rename from pyAMICA/sample_data/amicaout/rho rename to pamica/sample_data/amicaout/rho diff --git a/pyAMICA/sample_data/amicaout/sbeta b/pamica/sample_data/amicaout/sbeta similarity index 100% rename from pyAMICA/sample_data/amicaout/sbeta rename to pamica/sample_data/amicaout/sbeta diff --git a/pyAMICA/sample_data/eeglab_data.fdt b/pamica/sample_data/eeglab_data.fdt similarity index 100% rename from pyAMICA/sample_data/eeglab_data.fdt rename to pamica/sample_data/eeglab_data.fdt diff --git a/pyAMICA/sample_data/eeglab_data.set b/pamica/sample_data/eeglab_data.set similarity index 100% rename from pyAMICA/sample_data/eeglab_data.set rename to pamica/sample_data/eeglab_data.set diff --git a/pyAMICA/sample_data/input.param b/pamica/sample_data/input.param similarity index 100% rename from pyAMICA/sample_data/input.param rename to pamica/sample_data/input.param diff --git a/pyAMICA/sample_data/loadmodout15.m b/pamica/sample_data/loadmodout15.m similarity index 100% rename from pyAMICA/sample_data/loadmodout15.m rename to pamica/sample_data/loadmodout15.m diff --git a/pyAMICA/sample_data/pyresults/A.npy b/pamica/sample_data/pyresults/A.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/A.npy rename to pamica/sample_data/pyresults/A.npy diff --git a/pyAMICA/sample_data/pyresults/W.npy b/pamica/sample_data/pyresults/W.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/W.npy rename to pamica/sample_data/pyresults/W.npy diff --git a/pyAMICA/sample_data/pyresults/alpha.npy b/pamica/sample_data/pyresults/alpha.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/alpha.npy rename to pamica/sample_data/pyresults/alpha.npy diff --git a/pyAMICA/sample_data/pyresults/beta.npy b/pamica/sample_data/pyresults/beta.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/beta.npy rename to pamica/sample_data/pyresults/beta.npy diff --git a/pyAMICA/sample_data/pyresults/c.npy b/pamica/sample_data/pyresults/c.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/c.npy rename to pamica/sample_data/pyresults/c.npy diff --git a/pyAMICA/sample_data/pyresults/comp_list.npy b/pamica/sample_data/pyresults/comp_list.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/comp_list.npy rename to pamica/sample_data/pyresults/comp_list.npy diff --git a/pyAMICA/sample_data/pyresults/gm.npy b/pamica/sample_data/pyresults/gm.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/gm.npy rename to pamica/sample_data/pyresults/gm.npy diff --git a/pyAMICA/sample_data/pyresults/ll.npy b/pamica/sample_data/pyresults/ll.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/ll.npy rename to pamica/sample_data/pyresults/ll.npy diff --git a/pyAMICA/sample_data/pyresults/mean.npy b/pamica/sample_data/pyresults/mean.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/mean.npy rename to pamica/sample_data/pyresults/mean.npy diff --git a/pyAMICA/sample_data/pyresults/mu.npy b/pamica/sample_data/pyresults/mu.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/mu.npy rename to pamica/sample_data/pyresults/mu.npy diff --git a/pyAMICA/sample_data/pyresults/nd.npy b/pamica/sample_data/pyresults/nd.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/nd.npy rename to pamica/sample_data/pyresults/nd.npy diff --git a/pyAMICA/sample_data/pyresults/out.txt b/pamica/sample_data/pyresults/out.txt similarity index 99% rename from pyAMICA/sample_data/pyresults/out.txt rename to pamica/sample_data/pyresults/out.txt index ef34a44..5ef95c3 100644 --- a/pyAMICA/sample_data/pyresults/out.txt +++ b/pamica/sample_data/pyresults/out.txt @@ -4,7 +4,7 @@ Starting optimization... Final LL: 1.514409e+06, Gradient norm: 4.950805e-10 Converged due to small gradient norm Optimization finished after 73 iterations -Results saved to pyAMICA/sample_data/pyresults +Results saved to pamica/sample_data/pyresults 18035 nd = 901.0117357062, D = -2.80007e+02 -2.80007e+02 ( 2.29 s, 0.4 h) iter 4 lrate = 0.0500000000 LL = 1429185.8366917700 nd = 882.5131338624, D = 7.82524e+02 7.82524e+02 ( 3.00 s, 0.4 h) iter 5 lrate = 0.0500000000 LL = 1431281.4057004189 nd = 874.6022400579, D = 2.09557e+03 2.09557e+03 ( 3.69 s, 0.4 h) diff --git a/pyAMICA/sample_data/pyresults/rho.npy b/pamica/sample_data/pyresults/rho.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/rho.npy rename to pamica/sample_data/pyresults/rho.npy diff --git a/pyAMICA/sample_data/pyresults/sphere.npy b/pamica/sample_data/pyresults/sphere.npy similarity index 100% rename from pyAMICA/sample_data/pyresults/sphere.npy rename to pamica/sample_data/pyresults/sphere.npy diff --git a/pyAMICA/sample_data/sample_params.json b/pamica/sample_data/sample_params.json similarity index 95% rename from pyAMICA/sample_data/sample_params.json rename to pamica/sample_data/sample_params.json index 2f383e3..4000f97 100644 --- a/pyAMICA/sample_data/sample_params.json +++ b/pamica/sample_data/sample_params.json @@ -1,5 +1,5 @@ { - "files": ["pyAMICA/sample_data/eeglab_data.fdt"], + "files": ["pamica/sample_data/eeglab_data.fdt"], "outdir": "./amicaout/", "data_dim": 32, "field_dim": [30504], diff --git a/pyAMICA/tests/__init__.py b/pamica/tests/__init__.py similarity index 100% rename from pyAMICA/tests/__init__.py rename to pamica/tests/__init__.py diff --git a/pyAMICA/tests/fixtures/ds002718_sub-002_electrodes.tsv b/pamica/tests/fixtures/ds002718_sub-002_electrodes.tsv similarity index 100% rename from pyAMICA/tests/fixtures/ds002718_sub-002_electrodes.tsv rename to pamica/tests/fixtures/ds002718_sub-002_electrodes.tsv diff --git a/pyAMICA/tests/mlx_tests/__init__.py b/pamica/tests/mlx_tests/__init__.py similarity index 100% rename from pyAMICA/tests/mlx_tests/__init__.py rename to pamica/tests/mlx_tests/__init__.py diff --git a/pyAMICA/tests/mlx_tests/test_mlx_backend.py b/pamica/tests/mlx_tests/test_mlx_backend.py similarity index 94% rename from pyAMICA/tests/mlx_tests/test_mlx_backend.py rename to pamica/tests/mlx_tests/test_mlx_backend.py index 6d19f3b..56c11ed 100644 --- a/pyAMICA/tests/mlx_tests/test_mlx_backend.py +++ b/pamica/tests/mlx_tests/test_mlx_backend.py @@ -46,7 +46,7 @@ def _load_real_data() -> np.ndarray: - from pyAMICA.torch_impl.utils import load_eeglab_data + from pamica.torch_impl.utils import load_eeglab_data return load_eeglab_data(str(DATA_FILE), data_dim=NW, field_dim=FIELD).astype( np.float64 @@ -56,7 +56,7 @@ def _load_real_data() -> np.ndarray: def test_rejects_unsupported_config(): """The still-deferred configs (Newton, non-GG families) fail loudly, not silently. Multi-model IS supported (issue #81), so it is not rejected.""" - from pyAMICA.mlx_impl import AMICAMLXNG + from pamica.mlx_impl import AMICAMLXNG kwarg_sets: list[dict[str, Any]] = [ {"do_newton": True}, @@ -72,8 +72,8 @@ def test_sufficient_stats_match_numpy_reference(): statistics match the validated NumPy float64 reference on an identical block with identical (shared-seed) parameters. rtol=1e-3 accommodates float32 vs float64 (measured max relerr ~1.6e-4 on dWtmp/dmu_d).""" - from pyAMICA.mlx_impl import AMICAMLXNG - from pyAMICA.numpy_impl.core import AMICA as AMICA_NumPy + from pamica.mlx_impl import AMICAMLXNG + from pamica.numpy_impl.core import AMICA as AMICA_NumPy data = _load_real_data() m = AMICAMLXNG(n_channels=NW, n_mix=NMIX, seed=SEED, block_size=256) @@ -114,7 +114,7 @@ def test_backend_stable_on_full_data(): """The MLX backend fits the full 30504-sample EEG on the Apple GPU (float32) without diverging (the Phase A ``ufp/y`` guard is carried into MLX), and the log-likelihood improves over the run.""" - from pyAMICA.mlx_impl import AMICAMLXNG + from pamica.mlx_impl import AMICAMLXNG m = AMICAMLXNG(n_channels=NW, n_mix=NMIX, seed=SEED) m.fit(_load_real_data(), max_iter=100, verbose=False) @@ -139,8 +139,8 @@ def test_converged_ll_matches_torch_float32(): reduction order), so the tolerance is relational, never a hardcoded LL.""" import torch - from pyAMICA.mlx_impl import AMICAMLXNG - from pyAMICA.torch_impl import AMICATorchNG + from pamica.mlx_impl import AMICAMLXNG + from pamica.torch_impl import AMICATorchNG data = _load_real_data() mlx_m = AMICAMLXNG(n_channels=NW, n_mix=NMIX, seed=SEED) @@ -169,8 +169,8 @@ def test_multimodel_matches_torch_float32(): (covered by the tests above).""" import torch - from pyAMICA.mlx_impl import AMICAMLXNG - from pyAMICA.torch_impl import AMICATorchNG + from pamica.mlx_impl import AMICAMLXNG + from pamica.torch_impl import AMICATorchNG data = _load_real_data() m = 2 @@ -251,7 +251,7 @@ def test_degenerate_fit_stops_and_reports_nan(): exercising the MLX degenerate-stop machinery (the same path the new ``nan_params`` guard uses) rather than only the happy path. Sphering/mean are off so the NaN reaches the E-step (numpy eigh would otherwise raise on it).""" - from pyAMICA.mlx_impl import AMICAMLXNG + from pamica.mlx_impl import AMICAMLXNG data = _load_real_data()[:, :4096].copy() data[0, 0] = np.nan @@ -268,8 +268,8 @@ def test_init_matches_torch_seed(): single- and multi-model -- rather than inferring it from downstream LL.""" import torch - from pyAMICA.mlx_impl import AMICAMLXNG - from pyAMICA.torch_impl import AMICATorchNG + from pamica.mlx_impl import AMICAMLXNG + from pamica.torch_impl import AMICATorchNG data = _load_real_data() for nm in (1, 2): @@ -295,7 +295,7 @@ def test_init_matches_torch_seed(): def test_multimodel_dead_model_keeps_prior_c(): """A zero-responsibility model (dgm[h]==0) keeps its prior bias c rather than writing 0/0 (the dead-model guard), mirroring AMICATorchNG.""" - from pyAMICA.mlx_impl import AMICAMLXNG + from pamica.mlx_impl import AMICAMLXNG data = _load_real_data() m = AMICAMLXNG(n_channels=NW, n_models=2, n_mix=NMIX, seed=SEED) diff --git a/pyAMICA/tests/test_amari_distance.py b/pamica/tests/test_amari_distance.py similarity index 100% rename from pyAMICA/tests/test_amari_distance.py rename to pamica/tests/test_amari_distance.py diff --git a/pyAMICA/tests/test_amari_ensemble_script.py b/pamica/tests/test_amari_ensemble_script.py similarity index 100% rename from pyAMICA/tests/test_amari_ensemble_script.py rename to pamica/tests/test_amari_ensemble_script.py diff --git a/pyAMICA/tests/test_amica.py b/pamica/tests/test_amica.py similarity index 95% rename from pyAMICA/tests/test_amica.py rename to pamica/tests/test_amica.py index 7c3a9ad..e87f8e4 100644 --- a/pyAMICA/tests/test_amica.py +++ b/pamica/tests/test_amica.py @@ -6,9 +6,9 @@ from pathlib import Path import unittest -from pyAMICA.numpy_impl.data import load_data_file, preprocess_data -from pyAMICA.numpy_impl.pdf import compute_pdf -from pyAMICA.numpy_impl.newton import compute_newton_direction +from pamica.numpy_impl.data import load_data_file, preprocess_data +from pamica.numpy_impl.pdf import compute_pdf +from pamica.numpy_impl.newton import compute_newton_direction class TestAMICA(unittest.TestCase): diff --git a/pyAMICA/tests/test_channel_selection.py b/pamica/tests/test_channel_selection.py similarity index 100% rename from pyAMICA/tests/test_channel_selection.py rename to pamica/tests/test_channel_selection.py diff --git a/pyAMICA/tests/test_decompose_equiv.py b/pamica/tests/test_decompose_equiv.py similarity index 100% rename from pyAMICA/tests/test_decompose_equiv.py rename to pamica/tests/test_decompose_equiv.py diff --git a/pyAMICA/tests/test_dimsweep_threads.py b/pamica/tests/test_dimsweep_threads.py similarity index 97% rename from pyAMICA/tests/test_dimsweep_threads.py rename to pamica/tests/test_dimsweep_threads.py index ec49e74..893bc4f 100644 --- a/pyAMICA/tests/test_dimsweep_threads.py +++ b/pamica/tests/test_dimsweep_threads.py @@ -14,10 +14,10 @@ import numpy as np -from pyAMICA.numpy_impl.data import load_data_file +from pamica.numpy_impl.data import load_data_file _REPO = Path(__file__).resolve().parents[2] -_FDT = _REPO / "pyAMICA" / "sample_data" / "eeglab_data.fdt" +_FDT = _REPO / "pamica" / "sample_data" / "eeglab_data.fdt" def _load_bench(): diff --git a/pyAMICA/tests/test_fortran_adapter.py b/pamica/tests/test_fortran_adapter.py similarity index 98% rename from pyAMICA/tests/test_fortran_adapter.py rename to pamica/tests/test_fortran_adapter.py index 2ff2550..6de7cab 100644 --- a/pyAMICA/tests/test_fortran_adapter.py +++ b/pamica/tests/test_fortran_adapter.py @@ -15,10 +15,10 @@ import numpy as np import pytest -from pyAMICA.numpy_impl.data import load_data_file +from pamica.numpy_impl.data import load_data_file _REPO = Path(__file__).resolve().parents[2] -SAMPLE_DIR = _REPO / "pyAMICA" / "sample_data" +SAMPLE_DIR = _REPO / "pamica" / "sample_data" FDT = SAMPLE_DIR / "eeglab_data.fdt" OUT_TXT = SAMPLE_DIR / "amicaout" / "out.txt" DATA_DIM, FIELD_DIM = 32, 30504 diff --git a/pyAMICA/tests/test_metrics_mir.py b/pamica/tests/test_metrics_mir.py similarity index 96% rename from pyAMICA/tests/test_metrics_mir.py rename to pamica/tests/test_metrics_mir.py index 045b3df..122de69 100644 --- a/pyAMICA/tests/test_metrics_mir.py +++ b/pamica/tests/test_metrics_mir.py @@ -1,6 +1,6 @@ -"""Unit tests for ``pyAMICA.metrics.mir`` (issue #134, MIR port phase 1). +"""Unit tests for ``pamica.metrics.mir`` (issue #134, MIR port phase 1). -MIR is backend-agnostic pure NumPy, so it lives directly under ``pyAMICA/tests/`` +MIR is backend-agnostic pure NumPy, so it lives directly under ``pamica/tests/`` (like ``test_amari_distance.py``) rather than ``tests/torch_tests/``. Per the NO-MOCK policy, every test exercises the real bundled sample EEG data; none use synthetic/random data as ground truth. The error-path tests construct @@ -16,8 +16,8 @@ import numpy as np import pytest -from pyAMICA.metrics.mir import _marginal_entropies, mir -from pyAMICA.torch_impl.utils import load_eeglab_data +from pamica.metrics.mir import _marginal_entropies, mir +from pamica.torch_impl.utils import load_eeglab_data SAMPLE_DIR = Path(__file__).resolve().parents[1] / "sample_data" DATA_FILE = SAMPLE_DIR / "eeglab_data.fdt" diff --git a/pyAMICA/tests/test_metrics_pmi.py b/pamica/tests/test_metrics_pmi.py similarity index 95% rename from pyAMICA/tests/test_metrics_pmi.py rename to pamica/tests/test_metrics_pmi.py index 38598ef..1e41b57 100644 --- a/pyAMICA/tests/test_metrics_pmi.py +++ b/pamica/tests/test_metrics_pmi.py @@ -1,7 +1,7 @@ -"""Unit tests for ``pyAMICA.metrics.pmi`` (issue #135, PMI metric phase 2). +"""Unit tests for ``pamica.metrics.pmi`` (issue #135, PMI metric phase 2). PMI is backend-agnostic pure NumPy, so it lives directly under -``pyAMICA/tests/`` (like ``test_metrics_mir.py``). Per the NO-MOCK policy, +``pamica/tests/`` (like ``test_metrics_mir.py``). Per the NO-MOCK policy, every test exercises the real bundled sample EEG data; none use synthetic/random data as ground truth. The error-path tests construct degenerate inputs (a constant channel, a non-finite sample) by modifying a @@ -13,13 +13,13 @@ import numpy as np import pytest -from pyAMICA.metrics._common import resolve_nbins -from pyAMICA.metrics.pmi import ( +from pamica.metrics._common import resolve_nbins +from pamica.metrics.pmi import ( _binned_entropy_from_counts, block_diagonal_order, pairwise_mi, ) -from pyAMICA.torch_impl.utils import load_eeglab_data +from pamica.torch_impl.utils import load_eeglab_data SAMPLE_DIR = Path(__file__).resolve().parents[1] / "sample_data" DATA_FILE = SAMPLE_DIR / "eeglab_data.fdt" diff --git a/pyAMICA/tests/test_native_engine.py b/pamica/tests/test_native_engine.py similarity index 98% rename from pyAMICA/tests/test_native_engine.py rename to pamica/tests/test_native_engine.py index ae9b58d..52687dc 100644 --- a/pyAMICA/tests/test_native_engine.py +++ b/pamica/tests/test_native_engine.py @@ -13,9 +13,9 @@ import numpy as np import pytest -from pyAMICA import AMICANative -from pyAMICA.native import resolver -from pyAMICA.numpy_impl.data import load_data_file +from pamica import AMICANative +from pamica.native import resolver +from pamica.numpy_impl.data import load_data_file SAMPLE = Path(__file__).resolve().parents[1] / "sample_data" DATA = SAMPLE / "eeglab_data.fdt" diff --git a/pyAMICA/tests/test_numpy_reject.py b/pamica/tests/test_numpy_reject.py similarity index 98% rename from pyAMICA/tests/test_numpy_reject.py rename to pamica/tests/test_numpy_reject.py index e98d9a0..7a37323 100644 --- a/pyAMICA/tests/test_numpy_reject.py +++ b/pamica/tests/test_numpy_reject.py @@ -12,8 +12,8 @@ import numpy as np import pytest -from pyAMICA import AMICA_NumPy as AMICA -from pyAMICA.numpy_impl.data import load_data_file +from pamica import AMICA_NumPy as AMICA +from pamica.numpy_impl.data import load_data_file _FDT = Path(__file__).resolve().parent.parent / "sample_data" / "eeglab_data.fdt" diff --git a/pyAMICA/tests/test_pyAMICA.py b/pamica/tests/test_pamica.py similarity index 95% rename from pyAMICA/tests/test_pyAMICA.py rename to pamica/tests/test_pamica.py index cc7589d..3696415 100644 --- a/pyAMICA/tests/test_pyAMICA.py +++ b/pamica/tests/test_pamica.py @@ -6,13 +6,13 @@ import tempfile import shutil -import pyAMICA -from pyAMICA import AMICA_NumPy as AMICA -from pyAMICA.numpy_impl.data import load_data_file, preprocess_data -from pyAMICA.numpy_impl.pdf import compute_pdf +import pamica +from pamica import AMICA_NumPy as AMICA +from pamica.numpy_impl.data import load_data_file, preprocess_data +from pamica.numpy_impl.pdf import compute_pdf # Setup test data path -data_path = op.join(pyAMICA.__path__[0], "data") +data_path = op.join(pamica.__path__[0], "data") @pytest.fixture diff --git a/pyAMICA/tests/test_sample_data.py b/pamica/tests/test_sample_data.py similarity index 98% rename from pyAMICA/tests/test_sample_data.py rename to pamica/tests/test_sample_data.py index ddb3ab5..9b120b8 100644 --- a/pyAMICA/tests/test_sample_data.py +++ b/pamica/tests/test_sample_data.py @@ -1,17 +1,17 @@ -"""Tests for pyAMICA using sample data.""" +"""Tests for pamica using sample data.""" import os.path as op import numpy as np import pytest from pathlib import Path -import pyAMICA -from pyAMICA import AMICA_NumPy as AMICA -from pyAMICA.numpy_impl.data import load_data_file -from pyAMICA.numpy_impl.load import loadmodout +import pamica +from pamica import AMICA_NumPy as AMICA +from pamica.numpy_impl.data import load_data_file +from pamica.numpy_impl.load import loadmodout # Setup sample data paths -sample_data_path = op.join(pyAMICA.__path__[0], "sample_data") +sample_data_path = op.join(pamica.__path__[0], "sample_data") sample_params_file = op.join(sample_data_path, "sample_params.json") eeglab_data_file = op.join(sample_data_path, "eeglab_data.fdt") amicaout_dir = op.join(sample_data_path, "amicaout") @@ -160,7 +160,7 @@ def test_amicaoutput_sources_contract_and_c_subtraction(): raises=AssertionError, ) def test_sample_data_scikit(tmp_path): - """Test pyAMICA using scikit-learn style API.""" + """Test pamica using scikit-learn style API.""" # Load original results for comparison orig_results = loadmodout(amicaout_dir) @@ -198,7 +198,7 @@ def test_sample_data_scikit(tmp_path): "(_check_convergence) now keeps the long run improving (seed=0 LL -3.399 at " "2000 iters, better than the ~150-iter -3.404 and Fortran's -3.402; 150-iter " "mean-corr parity preserved). Remaining: this test's strict all-32-components " - ">0.8 gate still fails (~27/32) because pyAMICA converges to a comparable/" + ">0.8 gate still fails (~27/32) because pamica converges to a comparable/" "slightly-better-LL but different-partition solution than the Fortran " "reference on a few ill-determined components -- a solution-basin difference " "(cf. the single-model tail of #27), not a drift. The project's other parity " @@ -228,7 +228,7 @@ def test_sample_data_cli(): [ sys.executable, "-m", - "pyAMICA.numpy_impl.cli", + "pamica.numpy_impl.cli", sample_params_file, "--outdir", str(test_outdir), @@ -377,8 +377,8 @@ def test_cli_output_format_roundtrip(tmp_path): import matplotlib matplotlib.use("Agg") - from pyAMICA.numpy_impl.data import load_results - from pyAMICA.numpy_impl import viz + from pamica.numpy_impl.data import load_results + from pamica.numpy_impl import viz data = load_data_file(eeglab_data_file, 32, 30504, dtype=np.float32).astype( np.float64 @@ -735,7 +735,7 @@ def test_cli_subprocess_output_loadable(tmp_path): [ sys.executable, "-m", - "pyAMICA.numpy_impl.cli", + "pamica.numpy_impl.cli", str(params_file), "--outdir", str(outdir), diff --git a/pyAMICA/tests/test_viz.py b/pamica/tests/test_viz.py similarity index 97% rename from pyAMICA/tests/test_viz.py rename to pamica/tests/test_viz.py index aa61d73..8854404 100644 --- a/pyAMICA/tests/test_viz.py +++ b/pamica/tests/test_viz.py @@ -1,4 +1,4 @@ -"""Tests for ``pyAMICA.viz`` (issue #136, Phase 4 visualizations). +"""Tests for ``pamica.viz`` (issue #136, Phase 4 visualizations). Per the NO-MOCK policy (`.rules/testing.md`), every test exercises the real bundled sample EEG data and a real (short but genuine) AMICA fit; none use @@ -21,14 +21,14 @@ import pytest from matplotlib.figure import Figure -from pyAMICA.amica import AMICA -from pyAMICA.metrics import block_diagonal_order, pairwise_mi -from pyAMICA.numpy_impl.load import ( +from pamica.amica import AMICA +from pamica.metrics import block_diagonal_order, pairwise_mi +from pamica.numpy_impl.load import ( AmicaOutput, loadmodout, read_eeglab_set_metadata, ) -from pyAMICA.viz import plot_model_probability, plot_pmi_heatmap +from pamica.viz import plot_model_probability, plot_pmi_heatmap SAMPLE_DIR = Path(__file__).resolve().parents[1] / "sample_data" DATA_FILE = SAMPLE_DIR / "eeglab_data.fdt" @@ -42,7 +42,7 @@ def real_data() -> np.ndarray: if not DATA_FILE.exists(): pytest.skip("sample data missing") - from pyAMICA.torch_impl.utils import load_eeglab_data + from pamica.torch_impl.utils import load_eeglab_data return load_eeglab_data(str(DATA_FILE), data_dim=NW, field_dim=FIELD).astype( np.float64 diff --git a/pyAMICA/tests/torch_tests/__init__.py b/pamica/tests/torch_tests/__init__.py similarity index 100% rename from pyAMICA/tests/torch_tests/__init__.py rename to pamica/tests/torch_tests/__init__.py diff --git a/pyAMICA/tests/torch_tests/test_amica_ng_wrapper.py b/pamica/tests/torch_tests/test_amica_ng_wrapper.py similarity index 97% rename from pyAMICA/tests/torch_tests/test_amica_ng_wrapper.py rename to pamica/tests/torch_tests/test_amica_ng_wrapper.py index 1d5a420..4d4cb9f 100644 --- a/pyAMICA/tests/torch_tests/test_amica_ng_wrapper.py +++ b/pamica/tests/torch_tests/test_amica_ng_wrapper.py @@ -15,9 +15,9 @@ import pytest import torch -from pyAMICA.amica import AMICA -from pyAMICA.metrics import mir, pairwise_mi -from pyAMICA.torch_impl.core import AMICATorchNG +from pamica.amica import AMICA +from pamica.metrics import mir, pairwise_mi +from pamica.torch_impl.core import AMICATorchNG SAMPLE_DIR = Path(__file__).resolve().parents[2] / "sample_data" DATA_FILE = SAMPLE_DIR / "eeglab_data.fdt" @@ -26,7 +26,7 @@ def _load_real_data() -> np.ndarray: - from pyAMICA.torch_impl.utils import load_eeglab_data + from pamica.torch_impl.utils import load_eeglab_data return load_eeglab_data(str(DATA_FILE), data_dim=NW, field_dim=FIELD).astype( np.float64 @@ -108,7 +108,7 @@ def test_ng_default_device_avoids_mps_float64(real_data, caplog): """The default float64 NG config must not crash when the auto-selected device is MPS; the wrapper falls back to CPU (regression for #29).""" model = AMICA(n_models=1, n_mix=3, verbose=False) # device=None - with caplog.at_level(logging.WARNING, logger="pyAMICA.amica"): + with caplog.at_level(logging.WARNING, logger="pamica.amica"): model.fit(real_data[:, :2048], max_iter=2, block_size=1024, seed=42) # float64 parity runs must never land on MPS. @@ -203,7 +203,7 @@ def test_write_amica_output_loadmodout_readable(fitted_ng, tmp_path): """A PyTorch NG fit written with write_amica_output() is a directory the EEGLAB reader (loadmodout / loadmodout15) loads with the expected shapes and correct Fortran (column-major) mixture-param layout (issue #92).""" - from pyAMICA.numpy_impl.load import loadmodout + from pamica.numpy_impl.load import loadmodout outdir = tmp_path / "amicaout" fitted_ng.write_amica_output(str(outdir)) @@ -269,7 +269,7 @@ def test_write_amica_output_llt_roundtrip(fitted_ng, tmp_path): per-sample-per-channel normalized log-likelihood, matching the Fortran ``LL`` file convention directly. """ - from pyAMICA.numpy_impl.load import loadmodout + from pamica.numpy_impl.load import loadmodout outdir = tmp_path / "amicaout" fitted_ng.write_amica_output(str(outdir)) @@ -288,7 +288,7 @@ def test_write_amica_output_llt_multimodel(real_data, tmp_path): relationship: the total per-sample log-likelihood is the log-sum-exp of the per-model log-likelihoods over models (issue #155). Few iterations -- this exercises the multi-model LLt code path, not convergence.""" - from pyAMICA.numpy_impl.load import loadmodout + from pamica.numpy_impl.load import loadmodout model = AMICA(n_models=2, n_mix=3, device="cpu", verbose=False) model.fit(real_data[:, :4096], max_iter=5, block_size=1024, seed=7) @@ -323,7 +323,7 @@ def test_loadmodout_sources_reproduce_live_transform_multimodel(real_data, tmp_p """ from scipy.optimize import linear_sum_assignment - from pyAMICA.numpy_impl.load import loadmodout + from pamica.numpy_impl.load import loadmodout model = AMICA(n_models=2, n_mix=3, device="cpu", verbose=False) model.fit(real_data[:, :4096], max_iter=8, block_size=1024, seed=4) @@ -403,7 +403,7 @@ def test_load_results_returns_internal_w_multimodel(real_data, tmp_path): 99.9% mismatch). The on-disk format itself is pinned by ``test_written_w_bytes_are_genuine_fortran_layout``. """ - from pyAMICA.numpy_impl.data import load_results + from pamica.numpy_impl.data import load_results model = AMICA(n_models=2, n_mix=3, device="cpu", verbose=False) model.fit(real_data[:, :4096], max_iter=5, block_size=1024, seed=7) @@ -432,7 +432,7 @@ def test_loadmodout_sources_roundtrip_with_share_comps(real_data, tmp_path): """ from scipy.optimize import linear_sum_assignment - from pyAMICA.numpy_impl.load import loadmodout + from pamica.numpy_impl.load import loadmodout model = AMICA(n_models=2, n_mix=3, device="cpu", verbose=False) model.fit( @@ -491,7 +491,7 @@ def test_loadmodout_llt_gm_reorder_alignment(real_data, tmp_path): whether the permutation is applied; seed=4 gives exactly that on real sample EEG. """ - from pyAMICA.numpy_impl.load import loadmodout + from pamica.numpy_impl.load import loadmodout model = AMICA(n_models=2, n_mix=3, device="cpu", verbose=False) model.fit(real_data[:, :4096], max_iter=8, block_size=1024, seed=4) @@ -527,7 +527,7 @@ def test_write_amica_output_llt_reject_zeroes_rejected_samples(real_data, tmp_pa sentinel (``sum(modloglik(:,i)) == 0.0``, amica15.f90:887-896). Good samples must stay non-zero and finite. """ - from pyAMICA.numpy_impl.load import loadmodout + from pamica.numpy_impl.load import loadmodout model = AMICA(n_models=1, n_mix=3, device="cpu", verbose=False) model.fit( @@ -567,7 +567,7 @@ def test_write_amica_output_llt_partial_final_block(real_data, tmp_path): ``block_size`` (issue #155 Fix 6d -- the existing LLt tests all used exactly-divisible counts, e.g. 4096/1024, so this branch was untested). """ - from pyAMICA.numpy_impl.load import loadmodout + from pamica.numpy_impl.load import loadmodout n = 4100 assert n % 1024 != 0, "fixture must not be block_size-divisible" @@ -593,7 +593,7 @@ def test_from_state_dict_write_amica_output_omits_llt(fitted_ng, tmp_path, caplo loaded = AMICA.load(path, device="cpu") outdir = tmp_path / "amicaout" - with caplog.at_level(logging.WARNING, logger="pyAMICA.torch_impl.core"): + with caplog.at_level(logging.WARNING, logger="pamica.torch_impl.core"): loaded.write_amica_output(str(outdir)) assert not (outdir / "LLt").exists() @@ -656,7 +656,7 @@ def test_degenerate_fit_refuses_output(real_data, tmp_path, caplog): bad = real_data[:, :4096].copy() bad[0, 0] = np.nan # propagates to a nan_ll stop in the backend model = AMICA(n_models=1, n_mix=3, device="cpu", verbose=False) - with caplog.at_level(logging.WARNING, logger="pyAMICA.amica"): + with caplog.at_level(logging.WARNING, logger="pamica.amica"): model.fit(bad, max_iter=3, block_size=1024, seed=0) assert model.stop_reason_ == "nan_ll" @@ -849,7 +849,7 @@ def flaky_mir(self, X, **kwargs): monkeypatch.setattr(AMICATorchNG, "mir", flaky_mir) model = AMICA(n_models=1, n_mix=3, device="cpu", verbose=False) - with caplog.at_level(logging.WARNING, logger="pyAMICA.torch_impl.core"): + with caplog.at_level(logging.WARNING, logger="pamica.torch_impl.core"): model.fit(real_data[:, :4096], max_iter=4, block_size=1024, seed=42, mir_step=1) # The fit survived and is usable. diff --git a/pyAMICA/tests/torch_tests/test_edge_cases.py b/pamica/tests/torch_tests/test_edge_cases.py similarity index 97% rename from pyAMICA/tests/torch_tests/test_edge_cases.py rename to pamica/tests/torch_tests/test_edge_cases.py index 147edea..df5c3fa 100644 --- a/pyAMICA/tests/torch_tests/test_edge_cases.py +++ b/pamica/tests/torch_tests/test_edge_cases.py @@ -12,8 +12,8 @@ import numpy as np import pytest -from pyAMICA.amica import AMICA -from pyAMICA.torch_impl.utils import load_eeglab_data +from pamica.amica import AMICA +from pamica.torch_impl.utils import load_eeglab_data SAMPLE_DIR = Path(__file__).resolve().parents[2] / "sample_data" DATA_FILE = SAMPLE_DIR / "eeglab_data.fdt" diff --git a/pyAMICA/tests/torch_tests/test_ng_backend.py b/pamica/tests/torch_tests/test_ng_backend.py similarity index 99% rename from pyAMICA/tests/torch_tests/test_ng_backend.py rename to pamica/tests/torch_tests/test_ng_backend.py index ff98676..590a35c 100644 --- a/pyAMICA/tests/torch_tests/test_ng_backend.py +++ b/pamica/tests/torch_tests/test_ng_backend.py @@ -15,9 +15,9 @@ import scipy.special as sp import torch -from pyAMICA.torch_impl import AMICATorchNG -from pyAMICA.torch_impl.core import _KEEP_BEST_TOL -from pyAMICA.numpy_impl.core import AMICA as AMICA_NumPy +from pamica.torch_impl import AMICATorchNG +from pamica.torch_impl.core import _KEEP_BEST_TOL +from pamica.numpy_impl.core import AMICA as AMICA_NumPy SAMPLE_DIR = Path(__file__).resolve().parents[2] / "sample_data" DATA_FILE = SAMPLE_DIR / "eeglab_data.fdt" @@ -28,7 +28,7 @@ def _load_real_data() -> np.ndarray: - from pyAMICA.torch_impl.utils import load_eeglab_data + from pamica.torch_impl.utils import load_eeglab_data return load_eeglab_data(str(DATA_FILE), data_dim=NW, field_dim=FIELD).astype( np.float64 @@ -1091,7 +1091,7 @@ def test_end_to_end_correlation_vs_fortran(): data, params = load_sample_data() params = dict(params) params["max_iter"] = 100 - out = root / "pyAMICA" / "tests" / "torch_tests" / "_ng_e2e_tmp" + out = root / "pamica" / "tests" / "torch_tests" / "_ng_e2e_tmp" out.mkdir(parents=True, exist_ok=True) fortran = run_fortran_amica(data, params, out, SEED) assert fortran is not None, "Fortran binary run failed" @@ -1174,7 +1174,7 @@ def test_end_to_end_correlation_vs_fortran_from_sample_params_json(): "whitening instead of symmetric ZCA sphering)" ) - out = root / "pyAMICA" / "tests" / "torch_tests" / "_ng_e2e_json_tmp" + out = root / "pamica" / "tests" / "torch_tests" / "_ng_e2e_json_tmp" out.mkdir(parents=True, exist_ok=True) fortran = run_fortran_amica(data, params, out, SEED) assert fortran is not None, "Fortran binary run failed" diff --git a/pyAMICA/tests/torch_tests/test_ng_float32_stability.py b/pamica/tests/torch_tests/test_ng_float32_stability.py similarity index 97% rename from pyAMICA/tests/torch_tests/test_ng_float32_stability.py rename to pamica/tests/torch_tests/test_ng_float32_stability.py index 578e52b..656f6ec 100644 --- a/pyAMICA/tests/torch_tests/test_ng_float32_stability.py +++ b/pamica/tests/torch_tests/test_ng_float32_stability.py @@ -21,9 +21,9 @@ import pytest import torch -from pyAMICA.torch_impl import AMICATorchNG -from pyAMICA.torch_impl.core import _score -from pyAMICA.torch_impl.utils import load_eeglab_data +from pamica.torch_impl import AMICATorchNG +from pamica.torch_impl.core import _score +from pamica.torch_impl.utils import load_eeglab_data SAMPLE_DIR = Path(__file__).resolve().parents[2] / "sample_data" DATA_FILE = SAMPLE_DIR / "eeglab_data.fdt" diff --git a/pyAMICA/tests/torch_tests/test_ng_pdf_families.py b/pamica/tests/torch_tests/test_ng_pdf_families.py similarity index 98% rename from pyAMICA/tests/torch_tests/test_ng_pdf_families.py rename to pamica/tests/torch_tests/test_ng_pdf_families.py index 7330836..d740cc0 100644 --- a/pyAMICA/tests/torch_tests/test_ng_pdf_families.py +++ b/pamica/tests/torch_tests/test_ng_pdf_families.py @@ -17,8 +17,8 @@ import pytest import torch -from pyAMICA.torch_impl import AMICATorchNG -from pyAMICA.torch_impl.core import _log_pdf_and_deriv, _log_pdf_only, _score +from pamica.torch_impl import AMICATorchNG +from pamica.torch_impl.core import _log_pdf_and_deriv, _log_pdf_only, _score SAMPLE_DIR = Path(__file__).resolve().parents[2] / "sample_data" DATA_FILE = SAMPLE_DIR / "eeglab_data.fdt" @@ -33,7 +33,7 @@ def _load_real_data() -> np.ndarray: - from pyAMICA.torch_impl.utils import load_eeglab_data + from pamica.torch_impl.utils import load_eeglab_data return load_eeglab_data(str(DATA_FILE), data_dim=NW, field_dim=FIELD).astype( np.float64 diff --git a/pyAMICA/tests/torch_tests/test_ng_sharing.py b/pamica/tests/torch_tests/test_ng_sharing.py similarity index 98% rename from pyAMICA/tests/torch_tests/test_ng_sharing.py rename to pamica/tests/torch_tests/test_ng_sharing.py index f1a52e8..6354b6c 100644 --- a/pyAMICA/tests/torch_tests/test_ng_sharing.py +++ b/pamica/tests/torch_tests/test_ng_sharing.py @@ -16,9 +16,9 @@ import pytest import torch -from pyAMICA.amica import AMICA -from pyAMICA.torch_impl.core import AMICATorchNG -from pyAMICA.torch_impl.utils import load_eeglab_data +from pamica.amica import AMICA +from pamica.torch_impl.core import AMICATorchNG +from pamica.torch_impl.utils import load_eeglab_data SAMPLE_DIR = Path(__file__).resolve().parents[2] / "sample_data" DATA_FILE = SAMPLE_DIR / "eeglab_data.fdt" diff --git a/pyAMICA/tests/torch_tests/test_ng_utils.py b/pamica/tests/torch_tests/test_ng_utils.py similarity index 97% rename from pyAMICA/tests/torch_tests/test_ng_utils.py rename to pamica/tests/torch_tests/test_ng_utils.py index 16cba3c..f406aaa 100644 --- a/pyAMICA/tests/torch_tests/test_ng_utils.py +++ b/pamica/tests/torch_tests/test_ng_utils.py @@ -1,4 +1,4 @@ -"""Unit tests for ``pyAMICA.torch_impl.utils`` helpers. +"""Unit tests for ``pamica.torch_impl.utils`` helpers. These exercise the preprocessing / device / comparison utilities that back the PyTorch path (previously ~20% covered). They are pure-function tests of linear @@ -14,7 +14,7 @@ import pytest import torch -from pyAMICA.torch_impl.utils import ( +from pamica.torch_impl.utils import ( check_numerical_stability, compute_correlation_matrix, find_best_permutation, diff --git a/pyAMICA/torch_impl/__init__.py b/pamica/torch_impl/__init__.py similarity index 100% rename from pyAMICA/torch_impl/__init__.py rename to pamica/torch_impl/__init__.py diff --git a/pyAMICA/torch_impl/core.py b/pamica/torch_impl/core.py similarity index 98% rename from pyAMICA/torch_impl/core.py rename to pamica/torch_impl/core.py index e856c99..1a17ca8 100644 --- a/pyAMICA/torch_impl/core.py +++ b/pamica/torch_impl/core.py @@ -6,7 +6,7 @@ backends, since removed in issue #32), this module is a direct, vectorized port of the closed-form E-step/M-step fixed-point updates used by the Fortran reference (``amica17.f90``) and the legacy NumPy implementation -(``pyAMICA.numpy_impl.core.AMICA._get_block_updates`` / ``_update_parameters``, which +(``pamica.numpy_impl.core.AMICA._get_block_updates`` / ``_update_parameters``, which is this module's line-by-line spec). There is no autograd and no Adam: every parameter update is a closed-form function of the E-step responsibilities and simple moments, matching the natural-gradient @@ -32,7 +32,7 @@ ``sldet`` Jacobian (matching ``amica17.f90:1341-1350``), the mathematically correct per-source log-density required to hit the Fortran-normalized LL target (~-3.4/sample-channel). As of issue #24 the legacy NumPy port -(``pyAMICA.numpy_impl.core.AMICA``) computes it the same way; both backends now converge +(``pamica.numpy_impl.core.AMICA``) computes it the same way; both backends now converge to the Fortran solution (component correlation > 0.95). Source-density families (issue #26): the default GG path cites ``amica17.f90``, @@ -81,7 +81,7 @@ # restores it when the final LL falls more than this tolerance below that peak. # Units: mean log-likelihood per sample-channel (the same scale as Fortran's # min_dll, amica17.f90:1866, which normalizes LL(iter) by numgoodsum*nw before -# comparing -- NOT the legacy NumPy pyAMICA.min_dll, which compares un-normalized +# comparing -- NOT the legacy NumPy pamica.min_dll, which compares un-normalized # summed LL), so 1e-9 reads as "numerical noise, not a real overshoot". The # threshold also keeps a # monotone single-model run (issue #24 parity) a bit-exact no-op: its final @@ -100,7 +100,7 @@ def _log_pdf_and_deriv( ) -> tuple[torch.Tensor, torch.Tensor]: """Vectorized source-density log-density and density derivative. - Elementwise port of ``pyAMICA.numpy_impl.core.AMICA._compute_log_pdf``: branches via + Elementwise port of ``pamica.numpy_impl.core.AMICA._compute_log_pdf``: branches via ``torch.where`` instead of Python control flow so it runs over full ``(block, source, mixture)`` tensors with no source/mixture loop. ``y``, ``rho`` and ``pdtype`` must be broadcastable to a common shape. @@ -248,13 +248,13 @@ def _log_pdf_only( class AMICATorchNG: """ - Natural-gradient EM AMICA, ported from ``pyAMICA.numpy_impl.core.AMICA``. + Natural-gradient EM AMICA, ported from ``pamica.numpy_impl.core.AMICA``. Not an ``nn.Module``: there are no learnable ``nn.Parameter``s and no autograd. Parameters (``A``, ``W``, ``c``, ``mu``, ``alpha``, ``beta``, ``rho``, ``gm``) are plain tensors mutated in place by closed-form E-step/M-step updates each iteration, mirroring - ``pyAMICA.AMICA._get_block_updates``/``_update_parameters``. + ``pamica.AMICA._get_block_updates``/``_update_parameters``. Parameters ---------- @@ -370,14 +370,14 @@ class AMICATorchNG: Cosine-similarity cutoff (in the de-sphered/sensor-space metric) above which two mixing columns are identified and merged. do_mean, do_sphere, do_approx_sphere : bool - Preprocessing options, matching ``pyAMICA.AMICA._preprocess_data``. + Preprocessing options, matching ``pamica.AMICA._preprocess_data``. pcakeep, pcadb : int, float, optional PCA dimensionality-reduction options (rarely used; see - ``pyAMICA.AMICA._preprocess_data``). + ``pamica.AMICA._preprocess_data``). seed : int, optional Seed for parameter initialization. Uses ``numpy.random.RandomState`` internally (not ``torch``'s RNG) with the exact same draw order as - ``pyAMICA.AMICA._initialize_parameters``, so the same seed produces + ``pamica.AMICA._initialize_parameters``, so the same seed produces bit-identical starting parameters to the NumPy reference. device : str or torch.device, optional Compute device for the block loop. Preprocessing (mean/cov/eigh) is @@ -646,7 +646,7 @@ def __init__( # Preprocessing # ------------------------------------------------------------------ def _preprocess(self, X: np.ndarray) -> torch.Tensor: - """Mean-removal + sphering, matching ``pyAMICA.AMICA._preprocess_data``. + """Mean-removal + sphering, matching ``pamica.AMICA._preprocess_data``. Done in float64 on CPU (eigh is not reliably supported on MPS and this is a one-time O(n_channels^3) cost, not the per-block hot @@ -716,7 +716,7 @@ def _preprocess(self, X: np.ndarray) -> torch.Tensor: # Initialization # ------------------------------------------------------------------ def _initialize_parameters(self): - """Initialize parameters, mirroring ``pyAMICA.AMICA._initialize_parameters`` + """Initialize parameters, mirroring ``pamica.AMICA._initialize_parameters`` exactly (same RNG draws, same order) so the same seed gives bit-identical starting parameters to the NumPy reference. """ @@ -895,7 +895,7 @@ def _compute_full_posterior_ll( and ``write_output`` writes both -- so Fortran's on-disk LLt is stale by one M-step relative to the parameters written alongside it. This method recomputes from the POST-update (and, here, post-keep-best- - restore) parameters, so pyAMICA's LLt is self-consistent with the + restore) parameters, so pamica's LLt is self-consistent with the written W/A (better-behaved, not "fixed toward Fortran" -- do not change this to match Fortran's staleness). @@ -1134,7 +1134,7 @@ def _newton_direction(self, dA_h, sigma2_h, lambda_h, kappa_h): """Per-model Newton direction ``H`` from the natural gradient ``dA_h``. Vectorized port of the per-source-pair 2x2 solve (amica17.f90:1817-1832, - pyAMICA.py:802-813): + pamica.py:802-813): H[i,i] = dA_h[i,i] / lambda[i] sk1 = sigma2[i]*kappa[k]; sk2 = sigma2[k]*kappa[i] (i != k) @@ -1162,7 +1162,7 @@ def _newton_direction(self, dA_h, sigma2_h, lambda_h, kappa_h): def _update_parameters(self, acc: Dict[str, torch.Tensor], n_samples: int): """Apply the M-step parameter update, matching - ``pyAMICA.AMICA._update_parameters`` (natural-gradient and Newton). + ``pamica.AMICA._update_parameters`` (natural-gradient and Newton). ``n_samples`` is the number of samples that fed the accumulators (the good-sample count when ``do_reject`` is active), so ``gm`` and the @@ -2002,7 +2002,7 @@ def mir( Composes the full raw-data-to-sources transform ``W_fort @ sphere`` -- i.e. ``get_unmixing_matrix(model_idx) @ sphere`` -- and delegates to - :func:`pyAMICA.metrics.mir`. MIR is shift-invariant, so the data-space + :func:`pamica.metrics.mir`. MIR is shift-invariant, so the data-space mean/``c`` centering ``transform`` applies is irrelevant here. Parameters @@ -2012,7 +2012,7 @@ def mir( model_idx : int, default=0 Which model's unmixing to use. nbins : int, optional - Histogram bin count; see :func:`pyAMICA.metrics.mir`. + Histogram bin count; see :func:`pamica.metrics.mir`. Returns ------- @@ -2046,7 +2046,7 @@ def pmi( ) -> np.ndarray: """Pairwise Mutual Information (issue #137) between this model's sources on ``X``. - Delegates to :func:`pyAMICA.metrics.pairwise_mi` on + Delegates to :func:`pamica.metrics.pairwise_mi` on ``transform(X, model_idx)``. Parameters @@ -2056,7 +2056,7 @@ def pmi( model_idx : int, default=0 Which model's sources to use. nbins : int, optional - Histogram bin count; see :func:`pyAMICA.metrics.pairwise_mi`. + Histogram bin count; see :func:`pamica.metrics.pairwise_mi`. Returns ------- @@ -2137,7 +2137,7 @@ def write_amica_output(self, outdir) -> None: """Write this fitted model as the Fortran/EEGLAB AMICA output directory. Produces the raw binary files that EEGLAB's ``loadmodout15.m`` (and the - Python port :func:`pyAMICA.numpy_impl.load.loadmodout`) read: ``gm``, + Python port :func:`pamica.numpy_impl.load.loadmodout`) read: ``gm``, ``W``, ``S``, ``mean``, ``c``, ``alpha``, ``mu``, ``sbeta``, ``rho``, ``comp_list``, ``LL``, so a PyTorch NG fit drops directly into an EEGLAB workflow (issue #92). ``loadmodout15`` performs the @@ -2204,7 +2204,7 @@ def _np(t): c=_np(self.c), alpha=_np(self.alpha), mu=_np(self.mu), - sbeta=_np(self.beta), # Fortran's 'sbeta' is pyAMICA's beta (scale) + sbeta=_np(self.beta), # Fortran's 'sbeta' is pamica's beta (scale) rho=_np(self.rho), comp_list=_np(self.comp_list), ll=ll, diff --git a/pyAMICA/torch_impl/utils.py b/pamica/torch_impl/utils.py similarity index 100% rename from pyAMICA/torch_impl/utils.py rename to pamica/torch_impl/utils.py diff --git a/pyAMICA/version.py b/pamica/version.py similarity index 86% rename from pyAMICA/version.py rename to pamica/version.py index bcb5d02..e47bb82 100644 --- a/pyAMICA/version.py +++ b/pamica/version.py @@ -26,7 +26,7 @@ ] # Description should be a one-liner: -description = "pyAMICA: Python implementation of Adaptive Mixture ICA algorithm" +description = "pamica: Python implementation of Adaptive Mixture ICA algorithm" # Long description will go up on the pypi page long_description = """ AMICA (Adaptive Mixture ICA) is an advanced blind source separation algorithm @@ -39,15 +39,15 @@ - Outlier rejection - Data preprocessing (mean removal, sphering) -For more information, visit: http://github.com/neuromechanist/pyAMICA +For more information, visit: http://github.com/neuromechanist/pamica """ -NAME = "pyAMICA" +NAME = "pamica" MAINTAINER = "Seyed Yahya Shirazi" MAINTAINER_EMAIL = "shirazi@ieee.org" DESCRIPTION = description LONG_DESCRIPTION = long_description -URL = "http://github.com/neuromechanist/pyAMICA" +URL = "http://github.com/neuromechanist/pamica" DOWNLOAD_URL = "" LICENSE = "BSD-3-Clause" AUTHOR = "Seyed Yahya Shirazi" @@ -57,6 +57,6 @@ MINOR = _version_minor MICRO = _version_micro VERSION = __version__ -PACKAGE_DATA = {"pyAMICA": [pjoin("data", "*")]} +PACKAGE_DATA = {"pamica": [pjoin("data", "*")]} REQUIRES = ["numpy", "scipy", "matplotlib", "tqdm", "json5"] PYTHON_REQUIRES = ">= 3.10" diff --git a/pyAMICA/viz.py b/pamica/viz.py similarity index 97% rename from pyAMICA/viz.py rename to pamica/viz.py index 1ae0efc..dd42e6c 100644 --- a/pyAMICA/viz.py +++ b/pamica/viz.py @@ -3,7 +3,7 @@ Unlike ``numpy_impl.viz`` (numpy-backend-scoped, built on the legacy ``load_results`` dict, returns ``None`` and mutates pyplot global state), the functions here consume ``AmicaOutput`` from -:func:`pyAMICA.numpy_impl.load.loadmodout` -- so they work for any backend's +:func:`pamica.numpy_impl.load.loadmodout` -- so they work for any backend's written amicaout directory -- and **return a `Figure`**, accepting an optional ``ax``/``axes`` to draw on. That is what makes them testable beyond "did not crash". ``numpy_impl/viz.py`` is left untouched; this module is not a @@ -52,10 +52,10 @@ def plot_pmi_heatmap( ---------- mi_matrix : np.ndarray (n, n) symmetric mutual-information matrix, e.g. from - `pyAMICA.metrics.pairwise_mi`. + `pamica.metrics.pairwise_mi`. order : np.ndarray, optional Length-n permutation to reorder both axes before plotting. Defaults to - `pyAMICA.metrics.block_diagonal_order(mi_matrix)`. + `pamica.metrics.block_diagonal_order(mi_matrix)`. labels : sequence of str, optional Per-original-component labels; tick label at reordered position ``k`` is ``labels[order[k]]``. Defaults to the original (0-based) component @@ -165,12 +165,12 @@ def plot_model_probability( ---------- out : AmicaOutput Must have `out.Lht` populated (per-model per-sample log-likelihood; - see `pyAMICA.numpy_impl.load.loadmodout`). + see `pamica.numpy_impl.load.loadmodout`). srate : float, optional - Sampling rate in Hz. pyAMICA has no built-in notion of sample rate + Sampling rate in Hz. pamica has no built-in notion of sample rate (`AmicaOutput`/`loadmodout`/`load_data_file` carry none), so a seconds x-axis is opt-in: without it, the x-axis is in samples ("Sample"). - Use `pyAMICA.numpy_impl.load.read_eeglab_set_metadata` to get a real + Use `pamica.numpy_impl.load.read_eeglab_set_metadata` to get a real recording's srate. smooth_sec : float, optional Hanning-smoothing window, in seconds (requires `srate`). Applied to @@ -204,7 +204,7 @@ def plot_model_probability( ) if smooth_sec is not None and srate is None: raise ValueError( - "plot_model_probability: smooth_sec requires srate (pyAMICA has " + "plot_model_probability: smooth_sec requires srate (pamica has " "no built-in sample rate; pass srate explicitly, e.g. from " "read_eeglab_set_metadata)." ) diff --git a/paper.md b/paper.md index af650a2..d50babf 100644 --- a/paper.md +++ b/paper.md @@ -1,5 +1,5 @@ --- -title: 'pyAMICA: GPU-accelerated Adaptive Mixture Independent Component Analysis in Python with Fortran parity' +title: 'pamica: GPU-accelerated Adaptive Mixture Independent Component Analysis in Python with Fortran parity' tags: - Python - PyTorch @@ -35,14 +35,14 @@ and produces the most dipolar (and thus most physiologically interpretable) comp Its reference implementation is a Fortran program parallelized with the Message Passing Interface (MPI) and distributed as a compiled binary driven from MATLAB/EEGLAB, which is difficult to install, runs only on the CPU, and is not usable from a Python scientific workflow. -`pyAMICA` is a Python implementation of AMICA that reproduces the reference Fortran results within numerical tolerance while running on the CPU, NVIDIA GPUs (CUDA), and Apple GPUs (Apple's MLX array framework [@mlx2023]). +`pamica` is a Python implementation of AMICA that reproduces the reference Fortran results within numerical tolerance while running on the CPU, NVIDIA GPUs (CUDA), and Apple GPUs (Apple's MLX array framework [@mlx2023]). It is a complete NumPy/PyTorch reimplementation, not a wrapper around the Fortran binary, built on PyTorch [@paszke2019pytorch], NumPy [@harris2020array], and SciPy [@virtanen2020scipy] (Python $\geq$3.12, PyTorch $\geq$2.12; tested on Apple Silicon/ARM64 and `x86_64`/CUDA Linux), and exposes a scikit-learn-style estimator under a BSD-3-Clause license. In double precision it reproduces the reference score-function algebra to machine precision; it also runs in single precision (float32), which the CPU-only binary does not offer and which is numerically faithful (agreeing with double precision to four to five significant digits) yet required to use Apple GPUs, which have no float64 and host the fastest backend (MLX). -`pyAMICA` writes output in the binary format that EEGLAB's AMICA loader reads: +`pamica` writes output in the binary format that EEGLAB's AMICA loader reads: a single-model output file is byte-identical in layout to a native AMICA file and needs no manual re-interpretation, and multi-model output round-trips through the same loader. Correctness is defined as parity with the Fortran reference for the single-model case and, because multi-model AMICA is not partition-identifiable, as a similar distribution of solutions for the multi-model case; both are validated on real EEG against the reference binary. @@ -60,21 +60,21 @@ and that is validated to reproduce the Fortran reference numerically, is needed General-purpose Python ICA implementations do not fill this gap. `scikit-learn` and `MNE-Python` provide FastICA [@hyvarinen2000independent] and Infomax [@bell1995information; @lee1999independent], and Picard [@ablin2018faster] provides a faster maximum-likelihood ICA, but none implement AMICA's mixture of models, adaptive generalized-Gaussian source densities, or Newton updates, -and so they do not reproduce AMICA decompositions. `pyAMICA` targets researchers who need AMICA: +and so they do not reproduce AMICA decompositions. `pamica` targets researchers who need AMICA: EEG/EMG analysts who want AMICA-quality decompositions inside a Python pipeline, users of GPU hardware who want faster runs than the CPU-only binary, and methodologists who need a transparent reference implementation to inspect and build on. # Implementation and validation -`pyAMICA` provides a natural-gradient [@amari1998natural] expectation-maximization backend that ports the full AMICA algorithm: +`pamica` provides a natural-gradient [@amari1998natural] expectation-maximization backend that ports the full AMICA algorithm: exact-EM mixture updates, a positive-definite Newton step [@palmer2008newton], symmetric zero-phase-component-analysis (ZCA) sphering, the five source-density families of the reference (generalized Gaussian, Gaussian, logistic, sub-Gaussian, and the extended-Infomax kurtosis switcher), a mixture of ICA models, and component sharing across models. -`pyAMICA`'s conformity with the reference binary is measured with two complementary metrics: Hungarian-matched component correlation, +`pamica`'s conformity with the reference binary is measured with two complementary metrics: Hungarian-matched component correlation, and the Amari distance [@amari1996new], a standard unmixing-matrix comparison metric that needs no assignment step since it is permutation- and scale-invariant by construction. Both implementations were run for AMICA's usual 2000 iterations with Newton off (`do_newton=0`) and otherwise-default parameters -(two separate configuration files drive them, JSON for `pyAMICA` and Fortran's own text format, with a transcribed subset of settings; a shared-format reader is planned). +(two separate configuration files drive them, JSON for `pamica` and Fortran's own text format, with a transcribed subset of settings; a shared-format reader is planned). The single-model headline (Table 1) uses an external recording (OpenNeuro ds002718, 70 channels, $k\approx153$), well past the ~60 threshold where cross-backend agreement plateaus (documentation); the bundled 32-channel sample used below sits at that threshold's boundary ($k\approx30$) and gives a consistent Amari distance. Score functions and per-block sufficient statistics are exact to floating-point resolution against the literal Fortran expressions on the bundled sample. @@ -83,23 +83,23 @@ it is instead assessed by whether the two implementations sample a similar distr A run-level permutation test, which permutes the 40 runs as intact units to respect the dependence among pairwise values, finds no evidence that cross-implementation agreement is worse than Fortran's own run-to-run agreement, so single-run values reflect intrinsic estimator spread rather than a shortfall. The multi-model log-likelihood distributions still differ slightly at a matched 100-iteration budget -(`pyAMICA` reaches Fortran's mean with about twice as many iterations), so full-likelihood similarity is not yet claimed. Per-run detail for both metrics is in the documentation. +(`pamica` reaches Fortran's mean with about twice as many iterations), so full-likelihood similarity is not yet claimed. Per-run detail for both metrics is in the documentation. | Regime | Metric | Result (mean; correlation / Amari distance) | |---|---|---| | Single-model | Log-likelihood gap to Fortran (mean per-sample-channel LL, $-3.6993$, $k\approx153$) | within ~0.0005 | | Single-model | Conformity with Fortran | 0.998 ($k\approx153$) / 0.006 (bundled, $k\approx30$) | | Single-model | Score functions (non-default families) and sufficient statistics | exact to floating-point resolution ($\sim\!10^{-15}$) | -| Multi-model | Single-run magnitude (`pyAMICA`-Fortran; Fortran-Fortran) | 0.65; 0.64 (sd 0.05) / 0.163; 0.174 (sd 0.02) | +| Multi-model | Single-run magnitude (`pamica`-Fortran; Fortran-Fortran) | 0.65; 0.64 (sd 0.05) / 0.163; 0.174 (sd 0.02) | | Multi-model | Ensemble similarity, 20 runs each: mean difference, between $-$ within-Fortran (permutation $p$) | $+0.011$ ($p=0.96$) / $-0.011$ ($p>0.999$) | : Single-model parity (external ds002718, $k\approx153$; Amari and score-function checks on the bundled sample) and -multi-model distributional similarity (bundled sample) of `pyAMICA` with the Fortran reference. All values are means +multi-model distributional similarity (bundled sample) of `pamica` with the Fortran reference. All values are means (sd shown where relevant) over matched components (single-model) or 190 within- and 400 cross-implementation pairs (multi-model). Full methodology and reproduction steps are in the documentation. -![Multi-model solution-ensemble partition-correlation distributions (panel A) and log-likelihood distributions (panel B) for 20 `pyAMICA` and 20 Fortran fits of the sample EEG; dashed lines mark each distribution's mean. -The within-Fortran, within-`pyAMICA`, and between-implementation correlation distributions overlap, +![Multi-model solution-ensemble partition-correlation distributions (panel A) and log-likelihood distributions (panel B) for 20 `pamica` and 20 Fortran fits of the sample EEG; dashed lines mark each distribution's mean. +The within-Fortran, within-`pamica`, and between-implementation correlation distributions overlap, so the single-run correlation reflects the estimator's intrinsic run-to-run spread rather than a gap to the reference. Panel B's apparent separation is a 0.009 log-likelihood gap on a ~0.035 axis.\label{fig:ensemble}](docs/assets/figures/multimodel-ensemble.png){ width=100% } @@ -132,7 +132,7 @@ The two native-Fortran rows are from a separate core-count sweep (documentation) the other CPU rows above use the platform default thread count, so they are not core-matched to Fortran. Unlike the correctness comparison, this benchmark uses external data (OpenNeuro ds002718, one subject so far) and specific GPU hardware. -The correctness harness compares `pyAMICA` against Fortran with two metrics, Hungarian-matched component correlation and Amari distance, and never uses synthetic data; +The correctness harness compares `pamica` against Fortran with two metrics, Hungarian-matched component correlation and Amari distance, and never uses synthetic data; the multi-model and score-function checks need no external download (bundled sample only). The full per-channel and multi-model performance tables, the per-run Amari-distance detail, and the data-size sweep, along with the step-by-step commands to reproduce every number here, @@ -140,10 +140,10 @@ are in the documentation (). # State of the field -`pyAMICA` complements rather than replaces EEGLAB [@delorme2004eeglab] and its Fortran AMICA plugin: it reads and writes the same output format, so results move between the two, while adding GPU support and a Python API. +`pamica` complements rather than replaces EEGLAB [@delorme2004eeglab] and its Fortran AMICA plugin: it reads and writes the same output format, so results move between the two, while adding GPU support and a Python API. Two other Python AMICA reimplementations have appeared [@esmaeili2025amica; @herforth2026pyamica], -both of which provide MNE-Python-compatible objects; `pyAMICA` instead offers a scikit-learn-style array API and byte-identical EEGLAB I/O, and does not yet ship an MNE-Python wrapper. -What distinguishes `pyAMICA` is the rigor and scope of its Fortran-parity validation, +both of which provide MNE-Python-compatible objects; `pamica` instead offers a scikit-learn-style array API and byte-identical EEGLAB I/O, and does not yet ship an MNE-Python wrapper. +What distinguishes `pamica` is the rigor and scope of its Fortran-parity validation, beyond what either alternative publishes: source-density score functions exact to floating-point resolution ($\sim\!10^{-15}$), single-model component correlation and Amari distance against the reference, and a distributional-similarity framework for the non-identifiable multi-model case, together with byte-identical EEGLAB output and an MLX backend for Apple GPUs. @@ -152,7 +152,7 @@ together with byte-identical EEGLAB output and an MLX backend for Apple GPUs. We thank Jason Palmer and Ken Kreutz-Delgado, co-developers of AMICA, for the reference implementation, and the EEGLAB community for the tools and sample data used to validate this work. -Two of the authors are original developers of the methods `pyAMICA` builds on: S.M. co-developed the AMICA algorithm (Palmer et al., +Two of the authors are original developers of the methods `pamica` builds on: S.M. co-developed the AMICA algorithm (Palmer et al., cited above) and A.D. is a lead developer of EEGLAB [@delorme2004eeglab]. This work was supported by The Swartz Foundation (Old Field, NY) to the Swartz Center for Computational Neuroscience and by National Institutes of Health grant R01-NS047293 (to A.D. and S.M.). diff --git a/paper.pdf b/paper.pdf index fcb2191..ef962ce 100644 Binary files a/paper.pdf and b/paper.pdf differ diff --git a/pyAMICA/numpy_impl/__init__.py b/pyAMICA/numpy_impl/__init__.py deleted file mode 100644 index 69565cd..0000000 --- a/pyAMICA/numpy_impl/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Legacy NumPy reference implementation of AMICA. - -Topic-named modules (``core``, ``newton``, ``pdf``, ``data``, ``load``, ``viz``, -``utils``, ``cli``), renamed from the old ``pyAMICA.py``/``amica_*.py`` sprawl in -issue #34. The scikit-learn-style :class:`~pyAMICA.numpy_impl.core.AMICA` is also -exposed at the package root as ``pyAMICA.AMICA_NumPy``. -""" - -from .core import AMICA - -__all__ = ["AMICA"] diff --git a/pyproject.toml b/pyproject.toml index fd1b5ac..96d7a62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools>=64.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "pyAMICA" +name = "pamica" version = "0.1.2" -description = "pyAMICA: Python implementation of the Adaptive Mixture ICA algorithm" +description = "pamica: Python implementation of the Adaptive Mixture ICA algorithm" readme = "README.md" authors = [ {name = "Seyed Yahya Shirazi", email = "shirazi@ieee.org"}, @@ -36,7 +36,7 @@ dependencies = [ # Optional Apple-Silicon GPU backend (AMICAMLXNG). MLX is Apple-only, so it is # NOT a core dependency; install with `uv pip install mlx` or `pip install -# pyAMICA[mlx]`. `import pyAMICA` never requires it (mlx_impl is imported lazily). +# pamica[mlx]`. `import pamica` never requires it (mlx_impl is imported lazily). [project.optional-dependencies] mlx = ["mlx>=0.32"] # Documentation stack (MkDocs Material + mkdocstrings). Built and deployed to @@ -56,11 +56,11 @@ Repository = "https://github.com/sccn/pyAMICA" [tool.setuptools] # List subpackages explicitly so the wheel is deterministic across platforms -# (a bare ["pyAMICA"] dropped torch_impl/ on some setuptools versions). -packages = ["pyAMICA", "pyAMICA.numpy_impl", "pyAMICA.torch_impl", "pyAMICA.mlx_impl", "pyAMICA.metrics", "pyAMICA.native"] +# (a bare ["pamica"] dropped torch_impl/ on some setuptools versions). +packages = ["pamica", "pamica.numpy_impl", "pamica.torch_impl", "pamica.mlx_impl", "pamica.metrics", "pamica.native"] # numpy_impl/params.json is the NumPy backend's default parameter file, loaded at # runtime via importlib/__file__, so it must ship in the wheel. -package-data = {"pyAMICA" = ["data/*"], "pyAMICA.numpy_impl" = ["params.json"]} +package-data = {"pamica" = ["data/*"], "pamica.numpy_impl" = ["params.json"]} [dependency-groups] dev = [ @@ -73,16 +73,16 @@ dev = [ ] [tool.coverage.run] -source = ["pyAMICA"] +source = ["pamica"] branch = true omit = [ - "pyAMICA/tests/*", + "pamica/tests/*", # argparse CLI entrypoint: exercised via subprocess, not unit-covered here. - "pyAMICA/numpy_impl/cli.py", + "pamica/numpy_impl/cli.py", # Optional MLX backend (Apple Silicon only): its tests require MLX + an Apple # GPU, so CI (ubuntu, no mlx) cannot exercise it and always measures 0%. It is # covered locally by tests/mlx_tests/ on Apple hardware (issue #76). - "pyAMICA/mlx_impl/*", + "pamica/mlx_impl/*", ] [tool.coverage.report] @@ -107,7 +107,7 @@ exclude = [".context/**"] [tool.ty.rules] # Optional-extra lazy imports (mne: benchmarks/benchmark_decompose.py; mlx: -# pyAMICA/mlx_impl/core.py, benchmarks/benchmark_dimsweep.py) carry +# pamica/mlx_impl/core.py, benchmarks/benchmark_dimsweep.py) carry # `# ty: ignore[unresolved-import]` comments, since CI's base env (no extras, # matching the other jobs) can't resolve either. Whether such a comment is # "used" or "unused" then depends on whether the local env happens to have the diff --git a/pytest.ini b/pytest.ini index 40cfc5e..433d5dc 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,12 +1,12 @@ [pytest] -testpaths = pyAMICA/tests +testpaths = pamica/tests python_files = test_*.py python_classes = Test* python_functions = test_* # Coverage settings addopts = - --cov=pyAMICA + --cov=pamica --cov-report=term-missing --cov-report=html --cov-report=xml diff --git a/uv.lock b/uv.lock index 82ed69d..8920c08 100644 --- a/uv.lock +++ b/uv.lock @@ -1189,6 +1189,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] +[[package]] +name = "pamica" +version = "0.1.2" +source = { editable = "." } +dependencies = [ + { name = "json5" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, + { name = "torch" }, + { name = "tqdm" }, +] + +[package.optional-dependencies] +docs = [ + { name = "mkdocs-git-revision-date-localized-plugin" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] +mlx = [ + { name = "mlx" }, +] +viz = [ + { name = "mne" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "json5" }, + { name = "matplotlib" }, + { name = "mkdocs-git-revision-date-localized-plugin", marker = "extra == 'docs'", specifier = ">=1.2" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.26" }, + { name = "mlx", marker = "extra == 'mlx'", specifier = ">=0.32" }, + { name = "mne", marker = "extra == 'viz'", specifier = ">=1.6" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl", specifier = ">=3" }, + { name = "torch", specifier = ">=2.12.1" }, + { name = "tqdm" }, +] +provides-extras = ["mlx", "docs", "viz"] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=4.0" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "ruff", specifier = ">=0.15.0" }, + { name = "ty", specifier = ">=0.0.57" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -1317,70 +1381,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] -[[package]] -name = "pyamica" -version = "0.1.2" -source = { editable = "." } -dependencies = [ - { name = "json5" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, - { name = "torch" }, - { name = "tqdm" }, -] - -[package.optional-dependencies] -docs = [ - { name = "mkdocs-git-revision-date-localized-plugin" }, - { name = "mkdocs-material" }, - { name = "mkdocstrings", extra = ["python"] }, -] -mlx = [ - { name = "mlx" }, -] -viz = [ - { name = "mne" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "pytest-xdist" }, - { name = "ruff" }, - { name = "ty" }, -] - -[package.metadata] -requires-dist = [ - { name = "json5" }, - { name = "matplotlib" }, - { name = "mkdocs-git-revision-date-localized-plugin", marker = "extra == 'docs'", specifier = ">=1.2" }, - { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, - { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.26" }, - { name = "mlx", marker = "extra == 'mlx'", specifier = ">=0.32" }, - { name = "mne", marker = "extra == 'viz'", specifier = ">=1.6" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl", specifier = ">=3" }, - { name = "torch", specifier = ">=2.12.1" }, - { name = "tqdm" }, -] -provides-extras = ["mlx", "docs", "viz"] - -[package.metadata.requires-dev] -dev = [ - { name = "pre-commit", specifier = ">=4.0" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, - { name = "pytest-xdist", specifier = ">=3.6.1" }, - { name = "ruff", specifier = ">=0.15.0" }, - { name = "ty", specifier = ">=0.0.57" }, -] - [[package]] name = "pygments" version = "2.20.0" diff --git a/validate_implementations.py b/validate_implementations.py index cfc8c5b..6cd5141 100644 --- a/validate_implementations.py +++ b/validate_implementations.py @@ -17,9 +17,9 @@ import argparse from typing import Dict, Tuple, Optional -from pyAMICA import AMICA -from pyAMICA.torch_impl import AMICATorchNG -from pyAMICA.torch_impl.utils import load_eeglab_data +from pamica import AMICA +from pamica.torch_impl import AMICATorchNG +from pamica.torch_impl.utils import load_eeglab_data # Constructor kwargs accepted by AMICATorchNG, used to filter the sample # params.json down to what the natural-gradient backend understands. @@ -60,7 +60,7 @@ def set_all_seeds(seed: int): def load_sample_data() -> Tuple[np.ndarray, Dict]: """Load the sample EEG data and parameters.""" - sample_dir = Path("pyAMICA/sample_data") + sample_dir = Path("pamica/sample_data") data_file = sample_dir / "eeglab_data.fdt" params_file = sample_dir / "sample_params.json" @@ -85,7 +85,7 @@ def run_fortran_amica( ) -> Optional[Dict]: """Run Fortran AMICA binary and collect results.""" # Use the macOS binary in sample_data directory - binary_path = Path("pyAMICA/sample_data/amica15mac") + binary_path = Path("pamica/sample_data/amica15mac") if not binary_path.exists(): print( f"Warning: Fortran binary not found at {binary_path}. Skipping Fortran comparison." @@ -97,12 +97,12 @@ def run_fortran_amica( fortran_dir.mkdir(exist_ok=True) # Copy the sample data file to working directory - sample_data_file = Path("pyAMICA/sample_data/eeglab_data.fdt") + sample_data_file = Path("pamica/sample_data/eeglab_data.fdt") working_data_file = fortran_dir / "eeglab_data.fdt" shutil.copy(sample_data_file, working_data_file) # Copy and modify the parameter file - sample_param_file = Path("pyAMICA/sample_data/input.param") + sample_param_file = Path("pamica/sample_data/input.param") working_param_file = fortran_dir / "input.param" with open(sample_param_file, "r") as f: