Changelog¶
Release notes are also published on the GitHub releases page.
0.3.3¶
MLX fitting parity (convergence stops, component sharing, Newton, all five
source-density families), best-of-N restarts on every backend, the
Fortran-faithful stashed LLt, an OOM-safe block-size auto-tuner,
annotation-based rejection in the MNE wrapper, and share_comps on
rank-reduced fits; validated end to end on real Maxwell-filtered MEG by an
external tester (#221).
- Best-of-N random restarts on all three backends (issue #198, from the
#145 investigation).
n_restartsandrestart_seedsare constructor parameters with identical semantics onAMICATorchNG,AMICAMLXNGandAMICA_NumPy(forwarded by theAMICAwrapper): the fit runs from several seeds and keeps the highest final log-likelihood, extending #51's within-runkeep_bestacross runs -- #145 showed random-init disagreement with the reference is basin choice on weak components, not a dynamics difference. Seeds default toseed, seed+1, ..., seed+N-1;n_restarts > 1without a base seed or explicit seeds is refused (best-of-N must be reproducible, and pamica never seeds itself from the clock). Degenerate restarts are excluded from selection but recorded (restart_seeds_/restart_lls_/restart_stop_reasons_, NaN likelihood where degenerate); ties keep the earlier restart, so selection is deterministic.n_restarts=1(the default) is bit-identical to before. Fortran has no equivalent; recorded indocs/guides/amica-differences.md. mir()'s PCA-reduction guard now checks the fitted sphere's geometry, not just which parameter caused the reduction (issue #283).AMICATorchNG._pca_reduced()previously only checked whetherpcakeep/pcadbwere passed explicitly, so a fit whose rank reduction came from automaticmineig/mineig_relnumerical-rank detection slipped past the guard, andmir()then crashed with an opaquenumpy.linalg.LinAlgErrorinstead of the documentedValueError("mir() is incompatible with PCA reduction"). The guard is now derived from the fitted sphere's shape (sphere.shape[0] != sphere.shape[1]), which catches both causes. The separate upfrontmir_step > 0gate insidefit(), which runs before that fit's sphere exists, keeps an explicit-parameter check (_pca_reduction_requested) as a fail-fast for the one cause it can know about ahead of a fit; the auto-detected case there was already caught gracefully (warn + NaN, not a crash) at the first mid-fit MIR waypoint, so behavior there is unchanged. No NumPy-backend counterpart exists to fix:mir()/pmi()areAMICATorchNG-only (issues #137/#143), so there is no equivalent guard on that backend.LLtis written from the E-step's stashed per-sample log-likelihood (issue #157), on both the PyTorch and NumPy backends, instead of being recomputed by a fresh full-dataset forward pass at write time. This is the reference's own design --modloglik/loglikare allocated once (amica15.f90:2617-2620), filled by every E-step and dumped verbatim bywrite_output-- and it removes the last full pass the write path paid for: a NumPywritestepcheckpoint drops from 78 ms to 0.8 ms on the bundled 32-channel sample, and a PyTorch fit no longer spends an extra E-step (12.8 ms, about half an EM iteration) computingLLteven when nothing is written. Behaviour change: pamica now inherits Fortran's one-M-step staleness. The writtenLLtis the E-step of the parameters as they stood before the M-step whoseW/Asit beside it, so it satisfies the reference's own invariantLt.sum()/(n_good*nw) == LL[-1]-- which the committed reference output satisfies exactly, and which the previous self-consistent recompute did not. That comparability was the point: seedocs/guides/amica-differences.mdfor the decision (2026-08-23) and its Fortran citations. Under the PyTorchkeep_bestsafeguard, which the reference has no counterpart for, the stash is rolled back with the parameters, so the exportedLLtis the E-step that produced the exportedfinal_ll_.model_loglik(X)still gives the log-likelihood of the written parameters if that is what you need. Thedo_rejectzero sentinel is unchanged (a rejected sample's entries are zeroed exactly as amica15.f90:2231-2234 does), and one reference-faithful exception to the invariant is now pinned as behavior: ado_rejectfit that rejects on the same iteration as the write leaves a small residual, because Fortran normalizesLL(iter)(amica15.f90:1770) beforereject_datashrinks the good count (amica15.f90:1138, :2252) -- the binary shows it too.- Block-size auto-tuner with an OOM-safe fallback (issue #232, split out of
#216/#230), on all three backends behind Fortran's own four parameter names
(
do_opt_block,blk_min,blk_max,blk_step). Withdo_opt_block=True,fittimes one accumulate pass per candidate block size on the real data and device before the first EM iteration and keeps the fastest; the choice and every timing are logged at INFO. Off by default, and the staticblock_size=8192default is unchanged: the winner is decided by measured time, so two machines can pick different sizes and their trajectories then differ at the ~1e-6 level any block-size change produces, which a Fortran-parity run cannot have (pinblock_sizeand leave the search off). The tuner changes nothing about a fit beyond the block size itself -- the timed passes only read model state and consume no RNG, so a post-tune fit is bit-identical to one started directly at the chosen size, tested on every backend. It is not free either -- two passes per candidate, about 16 EM iterations' worth under the defaults -- which pays for itself over a normal multi-hundred-iteration fit and not over a very short one. The point of porting it is the failure mode the reference gets wrong: Fortran'sdetermine_block_sizewalks upward into larger blocks and callsallocate_blockswith nostat=, so a candidate that cannot be allocated aborts the whole run. Here such a candidate is skipped, the upward walk stops, and the fit continues at the largest size that ran (or at the configuredblock_sizeif nothing could be timed). Candidates are additionally clamped ton_samples-- Fortran silently NaNs whenblock_sizeexceeds the frames available per thread (issue #292) -- and to a conservative estimate of one block's peak memory, so the search usually finds its ceiling without walking into a failure at all. The sweep bounds are re-derived rather than copied: Fortran's 128-1024 sits far below where any pamica backend peaks, so the pamica defaults (4096-32768 by 4096) bracket the measured CPU optimum and include the 8192 default, whileblk_stepkeeps Fortran's arithmetic meaning so aninput.paramreads the same on both sides. Two consequences on the NumPy backend:do_opt_blockthere used to default to on (following Fortran's header) with a 128-1024 sweep, so every NumPy fit quietly re-tuned itself to a small block and ignored theblock_sizeit was given -- it is now off by default, and that backend's defaultblock_sizeis the shipped 8192 rather than 128. Its naivedetermine_block_sizehelper (which timed a bareX.T @ X, the shape of no work AMICA actually does, and could not fall back at all) is replaced outright by the sharedpamica/blocktune.py. That helper was worse than mistuned:X.T @ Xcosts more as the block grows, so it was structurally guaranteed to pickblk_min, and every NumPy fit ran at 128 whatever the bounds said. This specifically affectedtest_sample_data_numpy_vs_fortran, the issue #24 NumPy-vs-Fortran gating test, which requestsblock_size=512to match the referenceinput.parambut whose historical effective block size was 128. It has been re-verified at the literal 512 it now actually gets, under the new default, and still passes: Hungarian-matched component correlation 0.981 (gate > 0.9) and final log-likelihood -3.4039 after 150 iterations.do_opt_block/blk_min/blk_max/blk_stepalso move from the Fortran param reader's unsupported table to identity mappings. Seedocs/guides/amica-differences.md. AMICA.from_params_filenow reads the literal Fortraninput.paramtext format directly (issue #132, JOSS reviewer feedback), content-sniffed (not extension-trusted) alongside the existing JSON schema so the same file can drive both the reference binary and pamica for a parity run. The translation (pamica/fortran_params.py,read_fortran_param_file) parses all 89 keywordsamica15.f90's parser accepts into pamica's actual constructor/fit()names, renaming the three that Fortran spells differently (min_grad_norm->min_nd,max_decs->maxdecs,numrej->maxrej), and warns loudly (never drops silently) about the 32 known keywords with no pamica equivalent (checkpoint warm-start, per-family EM freeze toggles, FIR/DFT pre-filtering, reporting cadence, ...; thedo_opt_blocksearch keys moved to identity mappings with #232, see below), about any keyword it does not recognize at all, and (hardValueError) when a non-empty file yields zero recognized settings.fit()applies the translated dict as per-call defaults -- an explicitly passed argument always wins over the file's value -- and warns once about any file setting that matches neither afit()norAMICATorchNGparameter (data-location metadata likefiles/outdir/data_dim). Seedocs/guides/validation.md#parameter-filesfor the mapping table.- Widened CI coverage (issue #246, building on #247's macOS Apple Silicon
job):
ci.ymlnow also triggers on pushes todev(the integration branch), not justmainand pull requests, so post-merge drift is caught immediately rather than only at the next PR. The macOS job installs themneextra alongsidemlx, sopamica/tests/mne_tests(the AMICAICA/MNE wrapper) runs against Accelerate as well as Linux/OpenBLAS. A new schedule-onlyweekly-macos-slow.ymlworkflow runs the full suite on macOS every Sunday, including the@pytest.mark.slowtests and the Fortran-parity tests (AMICA_RUN_FORTRAN,PAMICA_NATIVE_BINARY,AMICA_FORTRAN_BIN), none of which had ever run in CI before, without slowing down or blocking any PR. - Guarded the MLX backend's unmixing-matrix inversion against an
uncatchable process abort (issue #274). MLX 0.32's CPU-stream
mx.linalg.invdoes not raise a Python exception on a singular per-modelA[:, comp_list[:, h]]— LAPACK's LU failure aborts the whole process (libc++abi: ... [Inverse::eval_cpu] LU factorization failed), which notry/exceptaroundfitcan catch (found while verifying #271'sWfiniteness guard)._update_unmixing_matricesnow condition-checks each per-model matrix host-side immediately before callinginvand raises a catchableRuntimeErrornaming the model, iteration and condition number instead. The threshold (1e12) is calibrated empirically, not from float32's ~1/eps precision-loss point: isolated-subprocess measurement showed the true LU-abort onset is not a clean function of condition number (observed anywhere from ~9e8 to beyond ~5e10, matrix-structure-dependent), and an existing adversarial test legitimately reaches cond~4.4e9 without aborting, so 1e7 (the precision-loss estimate) would have been a false positive on real, currently-passing behavior. 1e12 clears that observed legitimate maximum by ~225x while staying far below where a genuinely singularA(e.g. a duplicated component column) actually lands (~1e15-1e17). Read-only: verified bit-identicalA/Won a short fit with and without the guard, and negligible added cost (~30 microseconds per model per iteration, measured on the bundled sample). The upstream MLX behavior is also being reported separately. Non-finite entries get their own handling: a matrix that is non-finite in EVERY entry (the observed shape of a dead, zero-responsibility model's corruption) carries no structural signal to check and is left to flow through toinv/nan_paramsexactly as before; any other non-finite pattern has its non-finite entries 0-filled (a neutral, non-scale-distorting value) before the condition check runs. This closes a gap a review pass found in the first version of the guard: a matrix that was BOTH non-finite in one entry AND structurally singular elsewhere (an exact duplicate column plus one stray NaN) used to skip the check entirely on any non-finite entry and still reach the uncatchable abort. Containment is intentionally not total — no scalar condition-number threshold can guarantee catching every abort-capable matrix, since the observed LU-abort onset (cond~9e8 to beyond cond~5e10) sits below the 1e12 threshold — and both docs and code comments now say so explicitly, alongside noting (and rejecting, as disproportionate to a now-rare defect) a fully complete alternative: runninginvitself in a disposable per-call subprocess. Tests:pamica/tests/mlx_tests/test_mlx_inv_guard.py(singular and near-singularAon a real fitted model raiseRuntimeErrorrather than aborting, the duplicate-column-plus-stray-NaN combination raises rather than aborting, a purely non-finite dead-modelAflows through tonan_paramswithout raising or aborting, the guard is bit-identical to an unguardedinv/slogdetcall, and a standard fit is unaffected). - Pinned
mir_history_against thekeep_bestrollback and against save/load (issue #161, follow-up from #137/#160). Both claims already held before this PR and were already tested:test_mir_history_survives_keep_best_restoreandtest_mir_history_empty_after_save_load(tests/torch_tests/test_ng_convergence.py, added in #213, whose commit message already noted "Folds in issue #161") already force a genuinekeep_bestrestore on real EEG (the establishedn_models=2, do_newton=True, newt_start=1, lrate=0.5, seed=0recipe) and assertmir_history_is neither truncated nor rewritten, and already round-trip a fit throughAMICA.save/loadand assert an emptymir_history_(AMICATorchNG.from_state_dictreconstructs via__init__, which setsmir_history_ = [], and_load_paramsnever touches it). #245 later fixed a waypoint assertion in the first test and documented the one-update offset betweenmir_history_[i](computed AFTER iterationi's update) andll_history[i](the pre-update likelihood) onAMICA.mir_history_and inAMICATorchNG.__init__. This PR verified both tests and both docstrings against the code and added the one place that was missing the offset note:AMICATorchNG.fit'smir_stepdocstring, the first place a reader would look. Documentation and test verification only, no logic change. - Documented that
final_ll_/self.ll[-1]trails ashare_compsmerge landing on the final fit iteration (issue #269). When a merge fires on the last iteration, the returnedA/W/comp_listare already post-merge but the reported log-likelihood still reflects the pre-merge state, sinceidentify_shared_compsruns after that iteration's LL is recorded (matching the reference ordering, amica15.f90:1856-1858). Documentation only, across all three backends (torch_impl/core.py,numpy_impl/core.py,mlx_impl/core.py) plusdocs/guides/amica-differences.md; the behavior is unchanged and now pinned by a matching pin test in each oftests/torch_tests/test_ng_sharing.pyandtests/test_numpy_share_comps.py(mirroring the MLX test added in #268). - The MLX backend now supports all five source-density families (issue
#265, epic #260 Phase 4, porting the PyTorch backend's issue #26).
AMICAMLXNGtakespdftype/kurt_start/num_kurt/kurt_intwithAMICATorchNG's names, defaults and semantics: the fixed families (2 Gaussian, 3 logistic, 4 sub-Gaussian cosh+, 1 super-Gaussian cosh-) via a per-sourcepdtypedispatch in_score/_log_pdf, and thepdftype=1extended-Infomax adaptive switcher between codes 1/4 by kurtosis sign on the usual schedule, plus a newget_pdftype()accessor.pdftype=0(the default) is byte-for-byte the pre-#265 implementation: the_pdtype_hNonefast path adds zero graph nodes, verified by an epic-tip-vs-new before/after fit comparison (bit-identicalA/ll_history, single- and multi-model). The fixed families'z0/fpmatch the literalamica15.f90forms through MLX's float32 evaluation to 1e-6 (rtol=atol), and a matched 100-iteration fit lands on the float64 PyTorch likelihood to within ~1e-7 for every family (four orders inside the 0.05 gate).rhois frozen for every non-GG family (self.dorho = pdftype == 0), which also skips the per-iteration lgamma-table refresh here and thedrho_naccumulation, whichAMICATorchNGstill pays unconditionally in its_get_block_updatesfor a frozenrho(its digamma pull is already gated behind the sameself.dorhoflag, so that part is not a divergence -- a deliberate MLX-only WORK divergence, not a numeric one). The switcher accumulates its kurtosis moments in numpy float64 on the host (an MLX-motivated mechanism difference, not a decision difference) and has no bit-exact oracle -- the reference declaresdo_choose_pdfsbut never accumulates the moments that would drive it -- so it is behavior-validated on real EEG, as ADR 0002 already scoped for the PyTorch backend.share_compsdoes not synchronizepdtypeacross a merged pair, documented onshared_components(). Corrected an inaccurate cell indocs/guides/amica-differences.md's backend table along the way: the legacy NumPy backend's fit path (_compute_log_pdf) has nopdtypeparameter and only ever implemented the generalized-Gaussian family, not "all five" as the table previously (incorrectly) claimed. Evidence:.context/issue-265/pdf_family_findings.md. - The MLX backend now supports Newton (issue #264).
AMICAMLXNGtakesdo_newton/newt_start/newtrate/newt_rampwithAMICATorchNG's names, defaults and semantics: the same curvature accumulators, the same per-source-pair 2x2 solve behind the same unguardedprod > 1positive-definiteness test, the same learning-rate ramp tonewtrate(and tolrate_capon a fallback), the samemaxdecsratchets, and the samen_newton_fallbackscounter. It runs entirely in float32, which was pre-registered as a go/no-go rather than assumed: on the bundled sample the finalized curvature matches a float64 PyTorch twin to 4e-7 relative, one warmed Newton M-step movesAto within 2.4e-7 of the twin's, a matched 100-iteration fit reaches -3.41149 against float64's -3.41149, and the positive-definiteness guard never comes within 1.9 of its boundary across six full-data fits (zero fallbacks, monotone likelihood). Evidence and the gate script:.context/issue-264/.do_newtonis off by default and every accumulator it needs is gated on it, so natural-gradient fits — including multi-model andshare_compsones — are bit-identical to before. - Multi-model Newton no longer crashes on the NumPy backend (issue #267).
numpy_implfinalized the curvature by dividing its(data_dim, num_models)accumulators bydgm[:, None], a(num_models, 1)model mass that broadcasts only for one model, so every multi-model Newton fit raisedValueError: operands could not be broadcast togetheron the first iteration Newton was active. The issue reported it from ashare_compscollapse, but it needed no sharing at all. Nowdgm[None, :], matching the PyTorch backend'sdgm.unsqueeze(0). Single-model fits are unaffected. - The MLX backend now supports component sharing (issue #263).
AMICAMLXNGtakesshare_comps/share_start/share_iter/comp_threshwithAMICATorchNG's names, defaults and validation, runs the same merge schedule and 6-iteration post-merge A-freeze, masks the mixture updates and the gradient norm bycomp_used, and exposescomp_usedandshared_components(). The merge decision is not reimplemented: it calls the NumPyidentify_shared_componentskernel on host float64pinv(sphere) @ A, the metric the PyTorch and NumPy backends already share, so all three decide identically from the same fitted state. Sharing is off by default and inert forn_models=1; with it off, every masking and freezing step added here is a no-op, so a fit is bit-identical to the same fit with sharing enabled but never scheduled (see thegmentry below for the one float32-ULP shift multi-model fits see relative to the previous release). - The MLX multi-model A-update now weights with the previous iteration's
gm(issue #263; issue #219 raised the same ordering question fornumpy_impl'sndtmpsumand flagged the array backends as follow-up, since fixed in PyTorch and now here). Fortran buildsdAkin the accumulation pass, beforeupdate_paramsreassignsgm(amica15.f90:1749-1761, :1788); MLX used the just-updatedgm. The weights cancel analytically for a disjointcomp_list, so single-model fits stay byte-for-byte identical and default multi-model fits are unaffected except at float32-ULP scale (the twogmsnapshots genuinely differ, so the cancelling division rounds differently; measured at most 2.98e-8 indAkon the bundled sample). A fit that shares components moves its shared columns differently (by ~1e-2 inA) and now matches the PyTorch backend to float32 precision. - NumPy
share_compsnow measures similarity on de-sphered sensor-space maps, matching the PyTorch backend and the Fortran reference (issue #258).identify_shared_componentsused to compare mixing columns directly in the sphered space; it now takespinv(sphere) @ A, the sameSpinvback-mapAMICATorchNGandamica15.f90use (:1916, :568-578), so both backends reach the identical merge decision from the same fitted state. Borderline merge decisions nearcomp_threshcan change relative to a pre-#258 NumPy fit, even on a full-rank sphere. - The MLX backend now has convergence stops (issue #248).
AMICAMLXNGimplemented neither, so an Apple-GPU fit always ran tomax_iter; it now carriesuse_min_dll/min_dll/maxincs,use_grad_norm/min_ndand the likelihood-decrease branch's gradient-norm half, with the same names, defaults andstop_reasonstrings asAMICATorchNG, and stops at the same iteration as it on the same data. share_compson the NumPy backend now runs the same algorithm as the PyTorch one (issues #240, #242). A column shared by two models took one A-step per contributing model, the second against an already-steppedA, instead of the reference's singlegm-weighted average applied once (amica15.f90:1749-1761, :1807); the post-merge A-freeze was missing entirely; and merged-away columns were divided 0/0 and masked, which also silenced a genuine collapse in a live column. Merged-away columns are now indexed out of the mixture updates instead of masked, and the share settings that would freezeApermanently (share_int <= 6) are rejected at construction, as inAMICATorchNG. Sharing is off by default, and fits with it off are bit- identical.comp_usedno longer goes stale acrossshare_compsschedule points on the NumPy backend (issue #240).identify_shared_componentsrebuilt the mask all-True on every call, and since the merge loop skips already-merged pairs, a later schedule point resurrected merged-away columns as live: the unmasked mixture updates then divided 0/0 for every merged-away column and the fit returned NaN mixture parameters while reporting success.comp_usedis now derived from the finalcomp_list(exactly the set of referenced columns, matching howAMICATorchNG.comp_usedis a derived property that cannot go stale), and merged-away columns are skipped and frozen at their last finite value instead of carrying NaN.- The
ndgradient-norm metric is weighted by the pre-update model weights (issue #219), on the NumPy and PyTorch backends, matching the reference's ordering: Fortran accumulatesdAk/ndbeforeupdate_paramsreassignsgm(amica15.f90:1749-1761, :1788). Single-model fits are unchanged (the weight cancels); the MLX counterpart landed with the #263 sharing port (see that entry). Affects theuse_grad_norm/min_ndstop under multi-model fits. - A NumPy fit that ends non-finite no longer reports success (issue #240).
fit()checks the fitted parameters at exit, not only the likelihood, and setsconverged=Falsewith astop_reasonnaming what went non-finite. Periodicwritestep/histstepcheckpoints are gated by the same check and skipped with a logged reason rather than persisting NaN thatloadmodoutwould read back without complaint; the last valid checkpoint stays on disk. - Checkpoint cadence matches the reference (issue #240).
writestepandhiststepare now anchored on the Fortran-style 1-indexed iteration (mod(iter, writestep) == 0, amica15.f90:1124/1130), so the first checkpoint lands at iterationwritestep. The 0-indexed transcription fired at iteration 0, so every fit wrote a checkpoint after its first iteration whateverwritestepsaid. Final results are unaffected:fit()always writes the converged result. - The MNE wrapper honors
bad_*annotations during fitting (issue #251, contributed by the project's external MEG tester).AMICAICA.fit(..., reject_by_annotation=True)(the default, matchingmne.preprocessing.ICA.fit's convention) excludes samples covered by annotations whose description starts withbadfrom the AMICA fit. The original-timeline mask is kept ingood_sample_mask_, and scoring stays timeline-faithful:get_model_probabilityreturns full-length, original-time-axis output with NaN over the rejected spans. share_compsworks on rank-reduced and rank-deficient fits (issue #253, reported from Maxwell-filtered MEG in #221). The PyTorch merge metric mapped mixing columns back to sensor space withinv(sphere), which raised "Component sharing needs an invertible sphere" on exactly the data class that rank detection had just made fittable. It now usespinv(sphere), the reference's ownSpinvback-map under reduction (amica15.f90:568-578), andshare_compswithpcakeep/pcadbis no longer rejected at construction. Full-rank fits are unaffected:pinvequalsinvto ~1e-15 there, and the bundled sample reproduces its previouscomp_listand log-likelihood bit for bit.
0.3.2¶
Rank-deficient input support across every backend, a much faster default block size, and a reproducible Fortran reference for parity runs.
- Rank-deficient data now works (issue #223, reported from Maxwell-filtered
MEG in #221). The numerical rank of the data covariance is detected and the
model is sized to it, porting the reference's
mineig/numeigs/Spinvmachinery, which pamica had not implemented: previously such a fit died withnan_llon the first iteration. Newget_sensor_mixing_matrix()returns sensor-space scalp maps when the sphere is no longer square. The rank policy is shared by the PyTorch, NumPy and MLX backends so they cannot disagree. - Rank detection defaults to a relative eigenvalue floor (
mineig_rel=1e-12) rather than the reference's absolutemineig=1e-15, which is unit-dependent: MEG in Tesla yields rank zero under it, and average-referenced EEG is detected only by luck. Passmineig_rel=Nonefor the reference's exact behavior. Well conditioned data is unaffected and stays bit-identical. See ADR 0004 anddocs/guides/amica-differences.md, which now lists every deliberate difference from the reference in one table. - The MNE wrapper scales by channel type before fitting, following MNE's own
ICA convention (issue #225). Required for mixed magnetometer/gradiometer data,
whose units differ by orders of magnitude; it decides which directions survive
rank reduction. A single channel type is unaffected.
AMICAICAalso exports rank-reduced fits, and no longer rejectspcakeep/pcadb. block_sizedefault raised from 512 to 8192 (issue #216), ~6x faster per iteration on CPU float64 for the bundled sample. Every backend was dispatch-bound at the old value. Runs compared bit-for-bit against the binary must set the same value on both sides.- EEGLAB output of a rank-reduced fit is readable (issue #164): the sphere is
padded to the
nx*nxrecord the reference writes, and read back column-major. - Parity runs are now controlled experiments (issue #228). The harness forwards every setting the binary understands instead of six hardcoded keys, seeds the reference run and pins it to one thread, and defaults to the seedable native engine rather than the unseedable bundled fixture. Two reference runs are now bit-identical where before they differed by up to 0.59.
benchmarks/reproduce_table1.pyreproduces the paper's parity table from the bundled sample, and the validation guide states what each row costs to verify (issue #144).- Both Fortran convergence criteria were dead in
numpy_impland now work (issue #212).AMICA_NumPystored the raw log-likelihood sum instead of Fortran's per-sample-per-channel normalization, reporting-3317862.78where the reference reports-3.3;min_dlldefaults to1e-9, souse_min_dllcould never fire from genuine convergence. Separately,ndwas built from the raw block sum rather than thegm-weighted mapped directions, reporting ~5.4e3 against Fortran's ~5.7e-2 and staying flat across iterations, souse_grad_norm/min_ndwas equally unreachable. Both now match the reference formulas, andAMICA_NumPy.ll_historyis on the same scale as the other backends. - Added the three missing Fortran convergence stops to
AMICATorchNG(issue #207):use_min_dll/min_dll/maxincs(small-likelihood-increase stop),use_grad_norm/min_nd(weight-gradient-norm stop), and the lrate-decrease branch's missing gradient-norm half (stop_reason="grad_norm_floor"). Fixes the reported case where, underdo_newton=True,lratesettles atnewtrateand oscillates instead of annealing, so the pre-existinglrate_floorcheck never fired andmax_iterwas the only working stop. All five new constructor arguments persist throughstate_dict()/from_state_dict(); older saved files (missing these keys) still load, falling back to the Fortran-faithful defaults.
0.3.1¶
Rho-rate schedule fixes across all backends and a reproducible-seed option in the native binary build.
- Fixed the rho learning-rate (
rholrate) schedule to match Fortranamica15.f90: it is amaxdecs-ratcheted ceiling (reset torholrate0each iteration/fit, tightened only aftermaxdecspersistent log-likelihood decreases, gated oniter > newt_start), not a per-decrease monotone decay. The previous decay collapsed the rho rate toward ~1e-5 and froze the source shape. Fixed in the PyTorch and NumPy backends (#194, issue #193) and the MLX backend (#197, issue #195). - Native binary build: reproducible
seedoption. Aseed <int>line ininput.paramnow seeds the random initialization deterministically (per-rank, no system clock), so a native run is reproducible run to run; without it the default stays clock-random. Also makesrandom_seedportable across compilers viarandom_seed(SIZE=...). Adopted from sccn/amica PR #54; the released binaries (rebuilt by CI) carry the option (#196). - Documented the #145 investigation (Newton-vs-Fortran weak-component divergence at long budgets): resolved as init-basin sensitivity on under-determined components, not a dynamics bug (identical init gives matching results); the optional init-robustness enhancement is tracked in #198.
0.3.0¶
MNE-Python compatibility layer (epic #139), additive: the scikit-learn-style
AMICA API and the byte-identical EEGLAB I/O are unchanged.
pamica.mne_compat.AMICAICA, an MNE-facing wrapper that fits AMICA directly from anmne.io.Raw/Epochs(picks=..., epochs concatenated along time like MNE's own ICA) and interoperates with the standard MNE ICA consumer surface:get_sources,apply,get_components,plot_componentsandplot_sources.to_mne_ica()returns a fully-populatedmne.preprocessing.ICA(includingreject_/n_samples_, soICA.saveandplot_propertieswork), so the whole MNE ICA ecosystem (component plotting,find_bads_eog/_ecg, exclusion workflows) works on an AMICA decomposition. The export maps pamica's mean, symmetric-ZCA sphere and unmixing into MNE'spca_mean_/pca_components_/unmixing_matrix_, writing the sphere asV diag(1/sqrt(e)) V^TwithVorthonormal so MNE's scalp maps are in channel space;to_mne_ica().get_sources(raw)reproducesAMICA.transform(X)to float64 precision, pinned on real sample EEG.fitrejects PCA reduction (pcakeep/pcadb, which leaves the sphere rank-deficient and the export invalid) and non-finite input, and a degenerate fit is refused by the consumer methods rather than emitting NaNs. MNE is an optional extra (pip install pamica[mne]);import pamicanever requires it, and a dedicated CI job runs the wrapper tests with the extra installed (phase 1, single-model, #140).- Multi-model exposure through the MNE wrapper:
AMICAICA(n_models=...)fits a mixture of ICA models, and since MNE'sICArepresents only one unmixing, each model is exported as its own single-modelmne.preprocessing.ICAviato_mne_ica(model_idx=...)(and themodel_idxargument onget_sources/apply/get_components/plot_components/plot_sources). The per-sample model dominance MNE cannot represent is exposed directly:get_model_probability(inst)returnsP(model | sample)((n_models, n_samples), columns sum to 1) andplot_model_probability(inst)draws the per-model probability plus best-model log-likelihood over time. These build on a new public live accessor,AMICA.model_loglik/model_probability(and theAMICATorchNGequivalents), which score arbitrary data through the stored sphere/mean; the training-data path (withoutdo_reject) is pinned bit-for-bit against the E-step's ownLht. The per-model export folds each model's data-space centercintopca_mean_, so the round trip holds for the multi-model case too.pamica.viz.plot_model_probabilitynow also accepts a livelhtarray, not only a writtenAmicaOutput(phase 2, #141). - pamica-specific fitted metadata is inspectable through the MNE wrapper rather
than silently dropped by the
mne.preprocessing.ICAexport:get_pdftype(model_idx=...)returns each component's source-density family code (0-4, named bypamica.mne_compat.PDFTYPE_NAMES),get_rho(model_idx=...)the generalized-Gaussian shape parameters, andshared_components()the components merged across models byshare_comps. The same accessors are added toAMICA/AMICATorchNG(phase 3, #142). - Separation-quality metrics are available directly on an MNE object:
AMICAICA.mir(inst, model_idx=...)(Mutual Information Reduction, in nats) andAMICAICA.pmi(inst, model_idx=...)(pairwise mutual information between the fitted sources), so MNE-side users get the same metrics as EEGLAB-side users. Both extract the fitted channels from theRaw/Epochsand delegate toAMICA.mir/pmi(#133); the results match the array API exactly (phase 4, #143).
0.2.2¶
GitHub repository rename to pAMICA and a __version__ fix.
- Fixed
pamica.__version__reporting the stale0.1.2:version.pyhardcoded the version and the release sync never touched it, so the 0.2.1 wheel shipped correct distribution metadata but a wrong runtime attribute.__version__now derives from the installed package metadata, sopyproject.tomlis the single source of truth and it can never drift again (#182). - Canonicalized
pyAMICA->pAMICAURLs after the GitHub repository was renamedsccn/pyAMICA->sccn/pAMICA. The documentation site moved to https://eeglab.org/pAMICA/, so the oldeeglab.org/pyAMICAlinks (including the README docs badge) now 404; the repository URLs, codecov, the native binary resolver's default repository, the docs badge, andgit clone/cdsnippets are updated to match. GitHub redirects the old repo URLs, and the package/import name stays lowercasepamica(#184).
0.2.1¶
PyPI publishing, release-metadata sync, the pAMICA display title, and native-engine documentation.
- Packaging and release: a PyPI publish workflow (
publish.yml) uploads thepamicasdist and wheel via Trusted Publishing (OIDC) when a GitHub release is published, andscripts/sync_version.pykeeps the release version in step acrosspyproject.toml,CITATION.cffand.zenodo.json(the publish job fails a release whose tag disagrees with them). The display title is now pAMICA; the package, import andpip install pamicastay lowercasepamica(pip name matching is case-insensitive, sopip install pAmicaresolves to the same project) (#177). - Native engine docs and validation wiring: a dedicated
AMICANativedocumentation page (usage, binary cache/SHA-256 verification,PAMICA_NATIVE_BINARY, thepython -m pamica.nativeinstaller, and the offlinenative/build.shfallback), andvalidate_implementations.pygains--native-engine/--fortran-binaryso the real Fortran reference runs as a backend on any platform, not only through the bundled macOSamica15macfixture (#147 phase 5, #179).
0.2.0¶
Package rename to align with the reserved PyPI name.
- Renamed the Python package
pyAMICA->pamica: the import path is nowimport pamicaand the distribution installs aspip install pamica(pip name matching is case-insensitive, sopip install pAmicaresolves to the same project). The GitHub repository (sccn/pyAMICA), the documentation domain (eeglab.org/pyAMICA), and the release-asset repository are unchanged (#176).
0.1.3¶
Native Fortran run engine, separation-quality metrics, LLt output parity, and
the loadmodout byte-order fix.
- Native Fortran run engine (
AMICANative), the fourth backend alongside NumPy, PyTorch and MLX. It runs the AMICA Fortran reference itself and returns anAmicaOutputwith the usual accessors, so it is the parity oracle the Python backends are checked against. The reference is now built dependency-free (a single-rank MPI shim removes the Open MPI runtime, on top of sccn/amica PR #53's no-MKL recipe; proven identical to real Open MPI at machine epsilon) and 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 pamica.nativeinstalls it explicitly, or setPAMICA_NATIVE_BINARYto a local build (epic #165). - Fixed
loadmodoutreadingW,sbetaandrhoin the wrong byte order: it used C order where the writer, genuine Fortran output and EEGLAB'sloadmodout15.mall use column-major (F order). The consequence was thatAmicaOutput.Wcame back transposed, silently corrupting genuine Fortran output and everything derived from it (A,svar,origord), andsbeta/rhowere scrambled whenevernum_mix > 1(the default). A write-then-read round trip cancels the error, so no self-consistency test could catch it; the fix is pinned by recomputing the bundled Fortran fixture's own reported log-likelihood from the loaded parameters (an external oracle). The writer's multi-modelWlayout, which interleaved models and was not EEGLAB-readable, is corrected to genuine Fortran (model axis slowest); single-model output is byte-identical to before.AmicaOutputgains a supportedsources(X, model=0)accessor (the loaded-fit counterpart of the live model'stransform) so downstream source derivations no longer hand-roll the sphere/unmixing composition (#159). Migration note: a multi-modelamicaoutdirectory written by an earlier pamica (whoseWused the old model-interleaved layout) must be regenerated withwrite_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-modelWwas never in the correct convention regardless. Single-model directories are unaffected (byte-identical before and after). - 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 ofgetMIR.mfrom bigdelys/pre_ICA_cleaning (Apache-2.0; seeTHIRD_PARTY_NOTICES.md), verified against the original at 1.7e-15 relative on the bundled sample EEG (#134). pairwise_miandblock_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.min 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).LLtoutput parity with the Fortran reference: both backends now write the per-timepoint, per-model log-likelihood file that the reference binary produces on every run, andloadmodoutreads it with the correct column-major layout (it previously used C order, scramblingLht/Lt). Verified bit-exactly in both directions against EEGLAB's realloadmodout15.m. Underdo_reject, rejected samples are written as exactly0.0, matching Fortran: those zeros are load-bearing, since itsload_rejreconstructs the rejection mask from them (#155).AMICATorchNG/AMICAgainmir()/pmi()accessors that compose the fitted unmixing the documented way (get_unmixing_matrix(model_idx) @ spherefor MIR,transform(X, model_idx)for PMI) and delegate topamica.metrics.mir/pairwise_mi, so callers no longer hand-compose the transform themselves.fit()also acceptsmir_step(default0, off) to record MIR waypoints during training inmir_history_as(iteration, mir_nats, variance); likell_history_, it is a true trajectory that akeep_bestrestore 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 (
pamica.viz):plot_pmi_heatmapandplot_model_probability, backend-agnostic views overAmicaOutputthat return aFigure(and accept an optionalax/axes) rather than mutating pyplot global state, plusread_eeglab_set_metadatafor the sample rate pamica itself has no notion of. Both plots are verified against the MATLAB reference: the smoothed model probability matchessmooth_amica_probat r=0.9886, andpairwise_mimatchesminfojpat r=0.9887 (#136). - Fixed
numpy_impl.pdf.compute_pdfusinggammalnwhere the generalized Gaussian needsgamma, which made the returned density negative for everyrhooutside the special-cased 1 and 2 (it integrated to -8.82 at the defaultrho0=1.5). Affectednumpy_impl.viz.plot_pdf_fits; the fit path was never affected, as it uses its own log-space implementation (#136).
0.1.2¶
Outlier-rejection parity in the NumPy backend, repo-wide type-checking, and the full validation-evidence documentation.
- NumPy backend outlier rejection: the Fortran
do_rejectoutlier-rejection path is ported toAMICA_NumPyvia the samegood_idxmechanism as the PyTorch backend, so the NumPy reference now drops per-sample outliers on therejstart/rejint/maxrejschedule (#123). - Rejection robustness: a non-finite log-likelihood is now distinguished from an
over-aggressive
rejsig, so an over-tight rejection threshold fails with a clear message instead of a silent non-finite result (#127). - Type checking enforced: repo-wide
tydiagnostics fixed (496 to 0) andtyadded to CI alongside a pre-commit config (ruff + ty) (#124, #125). - Documentation: the validation guide is expanded into a full evidence page, source-density bit-exactness, cross-platform device/precision invariance (cross-backend equivalence matrix and IC topomaps), the EEGLAB drop-in round-trip, and the other validated behaviors (#108).
0.1.1¶
Validation-methodology and correctness fixes since 0.1.0.
- Amari distance: a second, permutation- and scale-invariant unmixing-matrix comparison metric (Amari, Cichocki & Yang 1996) alongside Hungarian-matched correlation, used throughout the Fortran-parity validation (#120).
- Multi-model equivalence test: switched to a valid run-level permutation test that respects the dependence among the 40 runs' pairwise correlations, instead of a pseudoreplicated Mann-Whitney/TOST (#115).
- Parity and performance tables added to the paper, with the full results, native-Fortran CPU core-scaling rows, and per-run detail in the docs (#112).
- Type-safety fixes in
validate_implementations.py(run_fortran_amicareturn type,load_eeglab_datadtype annotation) (#118). - JOSS draft-PDF build workflow,
.zenodo.jsonwith ROR-based citation metadata, and an MLX backend API reference page (#110, #105, #107). - Corrected a stale float32-speedup claim and added a funding acknowledgement (#114).
0.1.0¶
First public release.
- PyTorch natural-gradient EM backend (
AMICATorchNG) at Fortran parity on real EEG (single-model log-likelihood ~ -3.40, Hungarian-matched component correlation ~ 0.997). - Backends: CPU, NVIDIA GPU (CUDA), and Apple GPU (MLX); float64 for parity, float32 for speed.
- All five source-density families, mixture of ICA models, Newton updates, component sharing, and outlier rejection.
- EEGLAB drop-in output:
write_amica_outputwrites theloadmodout15format, andvariance_ordergives the EEGLAB back-projected-variance component order. - Spatially-distributed channel-subset selection and a data-size (k-factor) cross-backend equivalence sweep for the benchmarks.
- scikit-learn-style
AMICAinterface, save/load, and a documentation site.