feat!: make a short fit an error and raise the default iteration cap
`ITERATIONS` was 30, and overrunning it returned `Ok` with `converged: false`. Both halves were wrong. The cap is a runaway guard, not a budget: the sweep exits as soon as the step falls below `epsilon`, so a high cap costs nothing on a history that converges. Measured on one needing four sweeps, `max_iter` 30 and 100_000 both finish in 4 iterations and ~130us. So 30 could never make anything faster — it could only stop a healthy history early, and it did: 160 events over 100 competitors already needs 42. Not scaled to the history, because iteration count tracks how loopy the graph is rather than how big it is. At a fixed 320 events over 40 slices, varying only the competitors sharing them: 3 competitors needs 2_789 sweeps, 10 needs 1_068, 100 needs 90, 400 needs 2. Three orders of magnitude on identical event and slice counts, so any formula in those two numbers would be badly wrong on some real shape. A single value set high enough that reaching it means oscillation is the honest version. With the cap raised, stopping at it means something is genuinely wrong, so `converge` now returns `InferenceError::NotConverged` rather than a flag on a success. A short fit is wrong by a little — every rating finite, the ordering sensible, nothing saying the numbers were still moving — and a flag has to be checked while `let _ = h.converge()` is the natural way not to. That is not hypothetical: it is how a real defect hid in this crate's own test suite. `converge_partial` returns the short fit for callers who want one. Only a single existing test needed it, which is the evidence that a capped fit is a deliberate choice rather than the common case. Also corrects the `ITERATIONS` docs, which claimed convergence cost is "roughly linear in the cap". It is linear in the iterations actually run. BREAKING CHANGE: `History::converge` returns `Err(NotConverged)` where it previously returned `Ok` with `converged: false`. Callers that want the old behaviour should use `History::converge_partial`. The default `max_iter` changes from 30 to 10_000, so a history that was silently truncated will now converge properly and its numbers will move. Closes #50 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+55
-6
@@ -1390,17 +1390,62 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
))
|
||||
}
|
||||
|
||||
/// Run the full forward+backward convergence loop and return a summary.
|
||||
/// Run the full forward+backward convergence loop to a fixed point.
|
||||
///
|
||||
/// Failing to reach `epsilon` within `max_iter` is not an error: the
|
||||
/// returned report carries `converged: false` and the final step.
|
||||
/// # Stopping short is an error
|
||||
///
|
||||
/// Hitting `max_iter` without reaching `epsilon` returns `NotConverged`.
|
||||
///
|
||||
/// It used to return `Ok` with `converged: false`, which was the worst
|
||||
/// available shape. A fit that stops short is *wrong by a little*: every
|
||||
/// rating is finite, the ordering looks sensible, and nothing about the
|
||||
/// output says the numbers were still moving. Detection was opt-in, and
|
||||
/// `let _ = h.converge()` silently opted out — which is how a real defect
|
||||
/// hid in this crate's own test suite.
|
||||
///
|
||||
/// The default `max_iter` is [`ITERATIONS`](crate::ITERATIONS), which is
|
||||
/// set high enough that reaching it means something is genuinely wrong
|
||||
/// rather than that the history is merely large. Raising the cap costs
|
||||
/// nothing when it is not needed, because the loop exits at `epsilon`.
|
||||
///
|
||||
/// Use [`History::converge_partial`] when a capped, unconverged fit is
|
||||
/// what you actually want.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `NotConverged` if the sweep hits `max_iter` with the step still above
|
||||
/// `epsilon`.
|
||||
///
|
||||
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
|
||||
/// broken down at that point and further iterations cannot recover, so the
|
||||
/// loop stops rather than reporting a NaN step as convergence.
|
||||
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
|
||||
let report = self.converge_partial()?;
|
||||
|
||||
if report.converged {
|
||||
Ok(report)
|
||||
} else {
|
||||
Err(InferenceError::NotConverged {
|
||||
iterations: report.iterations,
|
||||
final_step: report.final_step,
|
||||
epsilon: self.convergence.epsilon,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// As [`History::converge`], but a fit that stops at `max_iter` is
|
||||
/// returned rather than reported as an error.
|
||||
///
|
||||
/// The report's `converged` flag says which happened. Use this when a
|
||||
/// deliberately capped sweep is the point — a cheap approximate fit, or a
|
||||
/// test that pins what a fixed number of iterations produces. Prefer
|
||||
/// `converge` everywhere else: an unconverged fit that nobody checks is
|
||||
/// indistinguishable from a converged one.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `NonFiniteResult` if a sweep produces a NaN or infinite step.
|
||||
pub fn converge_partial(&mut self) -> Result<ConvergenceReport, InferenceError> {
|
||||
use std::time::Instant;
|
||||
|
||||
use smallvec::SmallVec;
|
||||
@@ -2799,13 +2844,15 @@ mod tests {
|
||||
epsilon = 1e-6
|
||||
);
|
||||
|
||||
// run exactly 11 iterations (old test used convergence(11, ...))
|
||||
// Run exactly 11 iterations. `converge_partial` rather than
|
||||
// `converge`: stopping at the cap is the point here, and `converge`
|
||||
// now reports that as `NotConverged`.
|
||||
h.convergence = ConvergenceOptions {
|
||||
max_iter: 11,
|
||||
epsilon: EPSILON,
|
||||
alpha: 1.0,
|
||||
};
|
||||
let _ = h.converge().unwrap();
|
||||
let _ = h.converge_partial().unwrap();
|
||||
|
||||
let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt();
|
||||
|
||||
@@ -3172,7 +3219,9 @@ mod tests {
|
||||
})
|
||||
.build();
|
||||
events_for(&mut h_capped);
|
||||
let _ = h_capped.converge().unwrap();
|
||||
// A one-iteration cap is deliberate here, so the short fit is the
|
||||
// result rather than an error.
|
||||
let _ = h_capped.converge_partial().unwrap();
|
||||
|
||||
let mut h_full: History<i64, _, _, &'static str> = History::builder().build();
|
||||
events_for(&mut h_full);
|
||||
|
||||
Reference in New Issue
Block a user