Skip to content

Refuse degenerate fits in the AMICA wrapper (#50) - #55

Merged
neuromechanist merged 2 commits into
mainfrom
50-amica-wrapper-marks-is_fitted_-even-on-a-degenerate-nan_llsingular_ll-fit
Jul 7, 2026
Merged

neuromechanist merged 2 commits into
mainfrom
50-amica-wrapper-marks-is_fitted_-even-on-a-degenerate-nan_llsingular_ll-fit

Conversation

@neuromechanist

Copy link
Copy Markdown
Member

Closes #50. Supersedes #54 (auto-closed when its stacked base, the #51 branch, was deleted on merge of #53). Review was completed on #54 (3 Sonnet reviewers) and all findings addressed — see the #54 review-response comment. Now rebased directly onto main (#50-only diff).

Problem

AMICA.fit() set is_fitted_ = True unconditionally, so transform()/get_mixing_matrix()/get_unmixing_matrix() ran on a degenerate fit (stop_reason in nan_ll/singular_ll) and returned NaN sources with no exception, while state_dict()/save() already refused such a model.

Fix — consistent, fail-loud contract (mirrors state_dict)

  • fit() trains a local backend and only publishes it to self on success, then sets is_fitted_ only when the fit converged; it exposes converged_ (bool) and stop_reason_ (str) and logs a wrapper-level warning on a degenerate fit. Publishing only on success means a mid-fit exception leaves self untouched (first fit → model_ is None; refit → last known-good model), closing the review's critical finding.
  • New _check_usable() guard raises a clear degenerate RuntimeError (naming the stop_reason) from transform/get_mixing_matrix/get_unmixing_matrix/save; an unfitted model still raises a distinct "must be fitted" ValueError.
  • load() carries stop_reason_/converged_ through.

Contract chosen per maintainer decision: expose attributes + refuse output (not a hard fit() error), so a diverged run stays inspectable.

Tests

Real sample EEG only. test_degenerate_fit_refuses_output injects a single NaN into the real data to force a genuine nan_ll divergence (an error-path robustness test, not a parity oracle), exercising the real fit() bookkeeping (is_fitted_=False, the warning) and fit_transform; transform/get_*/save all refuse. Plus normal-fit attribute exposure, unfitted-vs-degenerate error distinction, and load() round-trip of the new attributes. 10 wrapper tests pass; ruff clean; ty no new diagnostics.

AMICA.fit() marked is_fitted_ = True unconditionally, so transform()/
get_mixing_matrix()/get_unmixing_matrix() ran on a degenerate fit
(stop_reason nan_ll/singular_ll) and returned NaN sources with no error,
while state_dict()/save() already refused such a model.

Make the wrapper's contract consistent:
- fit() sets is_fitted_ only when the fit converged, and exposes converged_
  (bool) and stop_reason_ (str) for inspection; a degenerate fit logs a
  wrapper-level warning.
- A new _check_usable() guard raises a clear degenerate error (naming the
  stop_reason) from transform/get_mixing/get_unmixing/save, mirroring
  state_dict()'s refusal. An unfitted model still raises a distinct
  "must be fitted" error.
- load() carries stop_reason_/converged_ through (a saved model is always
  converged, since state_dict refuses degenerate ones).

Tested: 3 new wrapper tests (converged/stop_reason exposed on a normal fit;
unfitted vs degenerate error messages; transform/get_*/save all refuse a
degenerate model) following the established real-fit-then-force-stop_reason
pattern; 10 wrapper tests pass. ruff clean; ty no new diagnostics.
PR review (3 Sonnet reviewers):
- CRITICAL (regression I introduced): _check_usable keyed on model_ is None,
  but fit() assigned self.model_ before training, so a mid-fit exception left a
  half-built backend that the guard let through (and, on refit, stale
  is_fitted_=True). fit() now trains a LOCAL backend and only publishes it to
  self on success -- a first-fit crash keeps model_ None (clean "not fitted"),
  a failed refit keeps the last good model.
- Strengthened the degenerate test to a REAL divergence: a single NaN injected
  into the real EEG forces an actual nan_ll stop (error-path robustness test,
  not a parity oracle), so it exercises fit()'s real is_fitted_=False/warning
  bookkeeping instead of a forced marker, and now also covers fit_transform.
- Added load() converged_/stop_reason_ round-trip assertions.

10 wrapper tests pass; ruff clean; ty no new diagnostics.
@neuromechanist
neuromechanist merged commit 552d2a4 into main Jul 7, 2026
5 checks passed
@neuromechanist
neuromechanist deleted the 50-amica-wrapper-marks-is_fitted_-even-on-a-degenerate-nan_llsingular_ll-fit branch July 7, 2026 05:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AMICA wrapper marks is_fitted_ even on a degenerate (nan_ll/singular_ll) fit

1 participant