Skip to content

Commit 4e063cf

Browse files
Address #51 review: degenerate final_ll_, n_kurt_done, tests
PR review (4 Sonnet reviewers) findings: - final_ll_ was left at the last finite ll_history value after a degenerate stop while the model held diverged params; now set to NaN there (silent-failure review). - Roll back n_kurt_done with pdtype in the best-iterate snapshot so a restored model's adaptive-PDF switch count stays consistent with its pdtype. - Warn once when keep_best is requested together with do_reject (the safeguard is silently inactive there). - Fix the restore-skip comment (a singular_ll stop leaves A/W finite, not non-finite; the skip is a scope decision deferred to #50) and qualify the _KEEP_BEST_TOL min_dll scale reference (Fortran's, not the legacy NumPy one). Tests added/expanded: - keep_best inactive under do_reject (final_ll_ == ll_history[-1]). - keep_best False-vs-True differential on an overshooting multi-model run, with an explicit skip when the run is monotone so restore-branch coverage is visible, not vacuous. - wrapper final_ll_ survives save/load. 69 -> 72 torch tests pass; ruff clean.
1 parent 8712c59 commit 4e063cf

3 files changed

Lines changed: 112 additions & 43 deletions

File tree

pyAMICA/tests/torch_tests/test_amica_ng_wrapper.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ def test_ng_save_load_roundtrip(fitted_ng, real_data, tmp_path):
6363
assert loaded.n_models == fitted_ng.n_models
6464
assert loaded.n_mix == fitted_ng.n_mix
6565
assert loaded.ll_history_ == fitted_ng.ll_history_
66+
# final_ll_ (the fitted model's LL, issue #51) is populated and survives the
67+
# round-trip -- use it, not ll_history_[-1], as the model's log-likelihood.
68+
assert fitted_ng.final_ll_ is not None
69+
assert loaded.final_ll_ == fitted_ng.final_ll_
6670

6771
# torch.save/load restores tensors bit-exactly and CPU matmul is
6872
# deterministic, so transform() on the restored tensors reproduces the

pyAMICA/tests/torch_tests/test_ng_backend.py

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,32 +1162,68 @@ def test_keep_best_snapshot_restore_roundtrip():
11621162
assert torch.equal(getattr(m, name), snap[name]), name
11631163

11641164

1165+
def _multimodel_keep_best(seed: int, keep_best: bool) -> AMICATorchNG:
1166+
m = AMICATorchNG(
1167+
n_channels=NW, n_models=2, n_mix=NMIX, seed=seed, device="cpu",
1168+
dtype=torch.float64, block_size=512, lrate=0.05, maxdecs=3,
1169+
do_newton=True, newt_start=50, newt_ramp=10, newtrate=1.0,
1170+
keep_best=keep_best,
1171+
) # fmt: skip
1172+
m.fit(_load_real_data(), max_iter=100, verbose=False)
1173+
return m
1174+
1175+
11651176
@pytest.mark.skipif(not DATA_FILE.exists(), reason="sample data missing")
11661177
def test_keep_best_returns_within_tol_of_peak():
11671178
"""On a real multi-model fit the safeguard (issue #51) returns a model whose
11681179
log-likelihood is within tolerance of the best iterate seen and never below
11691180
the last iterate, and ``final_ll_`` reflects the *returned* parameters (not
1170-
the raw ``ll_history[-1]``, which stays the true trajectory). seed 8 reaches
1171-
the plateau where natural-gradient AMICA dips below its own peak, so the
1172-
restore branch runs; the assertions are contract invariants that also hold
1173-
if a platform's BLAS makes the run monotone."""
1174-
data = _load_real_data()
1175-
m = AMICATorchNG(
1176-
n_channels=NW, n_models=2, n_mix=NMIX, seed=8, device="cpu",
1177-
dtype=torch.float64, block_size=512, lrate=0.05, maxdecs=3,
1178-
do_newton=True, newt_start=50, newt_ramp=10, newtrate=1.0, keep_best=True,
1179-
) # fmt: skip
1180-
m.fit(data, max_iter=100, verbose=False)
1181+
the raw ``ll_history[-1]``, which stays the true trajectory). keep_best does
1182+
not change the optimization path, only which iterate is returned, so the
1183+
``keep_best=False`` run has the same trajectory but returns the (lower) last
1184+
iterate. seed 8 reaches the plateau where natural-gradient AMICA dips below
1185+
its own peak, so the restore branch runs; the invariants also hold if a
1186+
platform's BLAS makes the run monotone (see the explicit skip below)."""
1187+
on = _multimodel_keep_best(8, keep_best=True)
1188+
off = _multimodel_keep_best(8, keep_best=False)
1189+
1190+
# keep_best does not alter the trajectory, only the returned iterate.
1191+
assert on.ll_history == off.ll_history
1192+
# return-last returns exactly the last iterate; keep_best is never worse.
1193+
assert off.final_ll_ == off.ll_history[-1]
1194+
assert on.final_ll_ >= off.final_ll_
11811195

1182-
peak = max(m.ll_history)
1196+
peak = max(on.ll_history)
11831197
# The returned LL is within tolerance of the peak and never below the last.
1184-
assert abs(m.final_ll_ - peak) <= _KEEP_BEST_TOL
1185-
assert m.final_ll_ >= m.ll_history[-1] - 1e-12
1186-
# ll_history stays the true (possibly-overshooting) trajectory: keep_best
1187-
# returns a value at least as good as the raw last iterate would give.
1188-
assert m.final_ll_ >= m.ll_history[-1]
1198+
assert abs(on.final_ll_ - peak) <= _KEEP_BEST_TOL
1199+
assert on.final_ll_ >= on.ll_history[-1]
11891200
# The returned parameters really sit at final_ll_ (recompute the E-step LL).
1190-
X_t = m._preprocess(data)
1191-
acc = m._accumulate_blocks(X_t)
1201+
data = _load_real_data()
1202+
X_t = on._preprocess(data)
1203+
acc = on._accumulate_blocks(X_t)
11921204
ll_model = float(acc["ll"] / (X_t.shape[1] * NW))
1193-
assert abs(ll_model - m.final_ll_) < 1e-9
1205+
assert abs(ll_model - on.final_ll_) < 1e-9
1206+
1207+
# Make branch coverage visible rather than silently vacuous: if this run did
1208+
# not overshoot on this platform, the restore branch was not exercised.
1209+
if peak - on.ll_history[-1] <= _KEEP_BEST_TOL:
1210+
pytest.skip("seed 8 did not overshoot here; restore branch not exercised")
1211+
# It did overshoot, so keep_best strictly beat return-last.
1212+
assert on.final_ll_ > off.final_ll_
1213+
1214+
1215+
@pytest.mark.skipif(not DATA_FILE.exists(), reason="sample data missing")
1216+
def test_keep_best_inactive_under_reject():
1217+
"""The safeguard is disabled under ``do_reject`` (the good-sample set, hence
1218+
the LL normalization, changes across iterations, so per-iteration LLs are not
1219+
comparable): ``final_ll_`` is exactly the last trajectory value, no restore
1220+
fires (issue #51)."""
1221+
data = _load_real_data()
1222+
m = AMICATorchNG(
1223+
n_channels=NW, n_models=2, n_mix=NMIX, seed=SEED, device="cpu",
1224+
dtype=torch.float64, block_size=512, do_reject=True, rejsig=2.0,
1225+
rejstart=2, rejint=3, maxrej=2, keep_best=True,
1226+
) # fmt: skip
1227+
m.fit(data, max_iter=12, verbose=False)
1228+
assert m.numrej >= 1 # rejection actually fired, so the good set changed
1229+
assert m.final_ll_ == m.ll_history[-1] # no best-iterate restore under reject

pyAMICA/torch_impl/amica_torch_ng.py

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,11 @@
7575
# multi-model LL variance -- one seed peaked at -3.357 then crashed to -3.545 in
7676
# its last iterations). fit() therefore tracks the highest-LL iterate and
7777
# restores it when the final LL falls more than this tolerance below that peak.
78-
# Units: mean log-likelihood per sample-channel (same scale as min_dll), so 1e-9
79-
# reads as "numerical noise, not a real overshoot". The threshold also keeps a
78+
# Units: mean log-likelihood per sample-channel (the same scale as Fortran's
79+
# min_dll, amica17.f90:1866, which normalizes LL(iter) by numgoodsum*nw before
80+
# comparing -- NOT the legacy NumPy pyAMICA.min_dll, which compares un-normalized
81+
# summed LL), so 1e-9 reads as "numerical noise, not a real overshoot". The
82+
# threshold also keeps a
8083
# monotone single-model run (issue #24 parity) a bit-exact no-op: its final
8184
# iterate already IS the best, the gap is 0 < tol, and no restore fires.
8285
_KEEP_BEST_TOL = 1e-9
@@ -1138,18 +1141,27 @@ def _pdtype_from_kurtosis(
11381141
)
11391142
return result
11401143

1141-
def _snapshot_params(self) -> Dict[str, torch.Tensor]:
1142-
"""Clone the fitted parameter tensors for the best-iterate safeguard
1143-
(issue #51). Clones (not aliases) so the live in-place M-step updates do
1144-
not roll the snapshot forward. Covers exactly ``_PARAM_TENSORS``; the
1145-
constant preprocessing tensors (``mean``/``sphere``) are included so a
1146-
restore is a total, unambiguous rollback of model state."""
1147-
return {name: getattr(self, name).clone() for name in self._PARAM_TENSORS}
1144+
def _snapshot_params(self) -> Dict[str, object]:
1145+
"""Snapshot the fitted state for the best-iterate safeguard (issue #51).
1146+
1147+
Clones each ``_PARAM_TENSORS`` tensor (not an alias) so the live in-place
1148+
M-step updates do not roll the snapshot forward; the constant
1149+
preprocessing tensors (``mean``/``sphere``) are included so a restore is a
1150+
total rollback. Also captures the scalar ``n_kurt_done`` (the adaptive-PDF
1151+
switch counter that gates ``pdtype``) so a restored model's switch count
1152+
stays consistent with its rolled-back ``pdtype`` -- otherwise a switch
1153+
applied after the peak iterate would leave the two out of sync in a saved
1154+
model (silent-failure review)."""
1155+
snap: Dict[str, object] = {
1156+
name: getattr(self, name).clone() for name in self._PARAM_TENSORS
1157+
}
1158+
snap["n_kurt_done"] = self.n_kurt_done
1159+
return snap
11481160

1149-
def _restore_params(self, snapshot: Dict[str, torch.Tensor]) -> None:
1150-
"""Restore parameter tensors captured by :meth:`_snapshot_params`."""
1151-
for name, tensor in snapshot.items():
1152-
setattr(self, name, tensor)
1161+
def _restore_params(self, snapshot: Dict[str, object]) -> None:
1162+
"""Restore the state captured by :meth:`_snapshot_params`."""
1163+
for name, value in snapshot.items():
1164+
setattr(self, name, value)
11531165

11541166
# ------------------------------------------------------------------
11551167
# Public API
@@ -1201,7 +1213,15 @@ def fit(
12011213
# LLs are not comparable.
12021214
track_best = self.keep_best and not self.do_reject
12031215
best_ll = -math.inf
1204-
best_snapshot: Optional[Dict[str, torch.Tensor]] = None
1216+
best_snapshot: Optional[Dict[str, object]] = None
1217+
if self.keep_best and self.do_reject:
1218+
# keep_best defaults on, so a user enabling rejection would otherwise
1219+
# silently lose the safeguard; surface it once (silent-failure review).
1220+
logger.warning(
1221+
"keep_best is inactive under do_reject: the good-sample set (and "
1222+
"the per-iteration LL normalization) changes as samples are "
1223+
"rejected, so best-iterate selection by LL is not well-defined."
1224+
)
12051225

12061226
iterator = tqdm(range(max_iter), desc="AMICA-NG", disable=not verbose)
12071227
for it in iterator:
@@ -1309,17 +1329,26 @@ def fit(
13091329

13101330
iterator.set_postfix({"LL": f"{ll:.4f}", "lrate": f"{self.lrate:.4g}"})
13111331

1312-
# Log-likelihood of the parameters fit() returns. Defaults to the last
1313-
# trajectory value; overwritten with the best iterate's LL below if the
1314-
# safeguard restores it.
1315-
self.final_ll_ = self.ll_history[-1] if self.ll_history else float("nan")
1332+
# Log-likelihood of the parameters fit() returns. A degenerate stop
1333+
# leaves the model on the diverged parameters, whose LL is NOT the last
1334+
# finite ll_history value (the guard breaks before appending), so report
1335+
# NaN there rather than a stale healthy-looking number (silent-failure
1336+
# review). Otherwise it is the last trajectory value, overwritten with the
1337+
# best iterate's LL below if the safeguard restores it.
1338+
if self.stop_reason in self._DEGENERATE_STOP_REASONS:
1339+
self.final_ll_ = float("nan")
1340+
else:
1341+
self.final_ll_ = self.ll_history[-1] if self.ll_history else float("nan")
13161342

13171343
# Restore the best iterate if the run ended materially below it (issue
1318-
# #51). Skipped for a degenerate stop (params are already non-finite and
1319-
# state_dict()/the wrapper reject them, so there is nothing good to keep)
1320-
# and when the final LL is within _KEEP_BEST_TOL of the best -- a monotone
1321-
# single-model run has final == best, so no restore fires and issue #24
1322-
# parity stays bit-exact.
1344+
# #51). Skipped for a degenerate stop -- not because the parameters are
1345+
# necessarily non-finite (a singular_ll stop leaves A/W finite but
1346+
# singular) but because salvaging a diverged run here would pre-empt issue
1347+
# #50's degenerate-fit contract; state_dict() already refuses to persist
1348+
# any model whose stop_reason is degenerate. Also skipped when the final
1349+
# LL is within _KEEP_BEST_TOL of the best -- a monotone single-model run
1350+
# has final == best, so no restore fires and issue #24 parity stays
1351+
# bit-exact.
13231352
if (
13241353
track_best
13251354
and best_snapshot is not None

0 commit comments

Comments
 (0)