Implement save/load persistence for AMICATorchNG - #44
Conversation
AMICATorchNG.state_dict()/from_state_dict() serialize config + fitted tensors as plain tensors/primitives (dtype by name) so the payload round-trips through torch.save/load with weights_only=True. AMICA.save() writes it; AMICA.load() is a classmethod that rebuilds a transform-ready model with the same MPS/float64 device fallback as fit(). Replaces the NotImplementedError stubs (closes #36) and the placeholder tests with a real fit->save->load->transform round-trip on sample EEG (#15).
Review findings (4 Sonnet reviewers): - silent-failure: state_dict() refuses to serialize a degenerate model (stop_reason nan_ll/singular_ll) or one with non-finite params, so a NaN model can no longer round-trip silently into NaN sources. Added descriptive errors for malformed payloads (missing sections/keys) and A/comp_list shape-drift guards in _load_params. - code-review: state_dict() now .clone()s param tensors so the snapshot is independent of the live model (fit() mutates A/mu/beta in place); an aliased CPU snapshot would silently roll forward if captured mid-fit. - test-coverage: added backend-level round-trip asserting all 11 param tensors and every extra field with do_reject+do_newton (exercises mu/alpha/beta/rho/ gm and the good_idx branch), plus unfitted/degenerate/non-finite/snapshot guard tests. - comments: corrected _PARAM_TENSORS scope, state_dict 'optimizer state', and the test's lossless-restore comment.
Review pass (4 Sonnet reviewers) — all findings addressedRan the pr-review-toolkit reviewers (code, silent-failure, test-coverage, comments) on the diff. Every material finding was addressed in commit 8746236; no false positives skipped. Silent-failure (critical): a fit that ended on a non-finite log-likelihood (
Code-review (important): Test-coverage: the round-trip only exercised 6 of 11 param tensors. Added a backend-level round-trip asserting every param tensor and every Comments: corrected Full torch suite: 31 passed (26 baseline + 5 new persistence tests). Ruff clean. |
Closes #36. Also closes the save/load half of #15 (test coverage).
Problem
AMICA.save()/AMICA.load()raisedNotImplementedErrorafter #32 removed the oldAMICATorchstate_dict path. There was no way to persist a fitted PyTorch model.Change
AMICATorchNG.state_dict()serializes the model as three parts:config(constructor args, dtype stored by name),params(fitted tensors moved to CPU: A/W/c/mu/alpha/beta/rho/gm/comp_list/mean/sphere), andextra(sldet, iteration, ll_history, optimizer state). Every value is a tensor or plain Python primitive, so the payload round-trips throughtorch.save/torch.loadwithweights_only=True(no custom classes ortorch.dtypeobjects).AMICATorchNG.from_state_dict(state, device=None)rebuilds the object via the constructor and restores the tensors/scalars. Raises on an unfittedstate_dict()and on an unknownformat_version.AMICA.save()writes the wrapper config + backend state;AMICA.load()is now a classmethod returning a fitted, transform-ready model. The MPS/float64 device fallback used byfit()was factored into_select_device()and reused byload().Tests (real sample EEG, no mocks)
Replaces the two
NotImplementedErrorplaceholder tests with:test_ng_save_load_roundtrip- fit -> save -> load reproduces mixing/unmixing matrices andtransform()output exactly (lossless CPU float64 restore).test_ng_save_requires_fit- saving an unfitted model raises.test_ng_load_rejects_unknown_version- a tamperedformat_versionfails loudly (no silent-failure).Full torch suite: 26 passed.
Notes
load()changed from an instance method to a classmethod (the old body only raised, so no callers relied on the instance form).