|
75 | 75 | # multi-model LL variance -- one seed peaked at -3.357 then crashed to -3.545 in |
76 | 76 | # its last iterations). fit() therefore tracks the highest-LL iterate and |
77 | 77 | # 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 |
80 | 83 | # monotone single-model run (issue #24 parity) a bit-exact no-op: its final |
81 | 84 | # iterate already IS the best, the gap is 0 < tol, and no restore fires. |
82 | 85 | _KEEP_BEST_TOL = 1e-9 |
@@ -1138,18 +1141,27 @@ def _pdtype_from_kurtosis( |
1138 | 1141 | ) |
1139 | 1142 | return result |
1140 | 1143 |
|
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 |
1148 | 1160 |
|
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) |
1153 | 1165 |
|
1154 | 1166 | # ------------------------------------------------------------------ |
1155 | 1167 | # Public API |
@@ -1201,7 +1213,15 @@ def fit( |
1201 | 1213 | # LLs are not comparable. |
1202 | 1214 | track_best = self.keep_best and not self.do_reject |
1203 | 1215 | 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 | + ) |
1205 | 1225 |
|
1206 | 1226 | iterator = tqdm(range(max_iter), desc="AMICA-NG", disable=not verbose) |
1207 | 1227 | for it in iterator: |
@@ -1309,17 +1329,26 @@ def fit( |
1309 | 1329 |
|
1310 | 1330 | iterator.set_postfix({"LL": f"{ll:.4f}", "lrate": f"{self.lrate:.4g}"}) |
1311 | 1331 |
|
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") |
1316 | 1342 |
|
1317 | 1343 | # 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. |
1323 | 1352 | if ( |
1324 | 1353 | track_best |
1325 | 1354 | and best_snapshot is not None |
|
0 commit comments