Fall back to the best iterate when terminating with a numerical error - #229
Fall back to the best iterate when terminating with a numerical error#229batterseapower wants to merge 4 commits into
Conversation
…error On some ill-conditioned problems the last iterations before a NumericalError or InsufficientProgress termination degrade the iterate severely (e.g. the primal residual blows up by orders of magnitude chasing the final decade of duality gap), so the reduced-tolerance 'almost solved' check fails even though an excellent iterate was visited a few steps earlier. Track the best iterate seen (by the worst of gap_rel/res_primal/res_dual, only while kappa/tau <= 1) and restore it before post-processing when the solver terminates in an error state, mirroring how commercial solvers report their best point on stall.
Covers the three behaviours the fallback has to get right: a degraded final iterate is discarded in favour of the best one seen, a final iterate that is itself the best is left untouched, and iterates on the infeasibility path (ktratio > 1) are never eligible to be saved or restored.
|
Two suggested refinements, from testing this mechanism against a wider set of problems. 1. Normalise the merit by the reduced tolerancesThe merit is currently Concretely, with those defaults:
A is selected, and the solve is reported as a failure even though B was available. Normalising each quantity by the tolerance that will judge it, and combining them exactly as let gap = T::min(
self.gap_abs / settings.reduced_tol_gap_abs,
self.gap_rel / settings.reduced_tol_gap_rel,
);
let feas = T::max(
self.res_primal / settings.reduced_tol_feas,
self.res_dual / settings.reduced_tol_feas,
);
T::max(gap, feas)This also picks up 2. Restore on
|
The merit was the largest of gap_rel, res_primal and res_dual, comparing the raw quantities. But the check that decides the outcome on an unsuccessful exit is check_convergence_almost, which tests each quantity against a different tolerance: by default reduced_tol_feas is 1e-4 while reduced_tol_gap_rel is 5e-5, so feasibility is allowed to be twice as loose. Ranking by the raw maximum therefore misweights the quantities and can prefer an iterate that fails that check over one that passes it. With those defaults, an iterate with gap_rel 8e-5 and residuals 1e-5 scores 8e-5 and fails, while one with gap_rel 2e-5 and residuals 9e-5 scores 9e-5 -- ranked worse -- and passes. Dividing each quantity by the tolerance that will judge it, and combining them exactly as is_solved does so the gap enters through whichever of its absolute/relative forms is closer to passing, makes the selection safe: the chosen iterate has the smallest reduced merit of all candidates, so if any candidate would have passed the acceptance check, the chosen one passes it too. It also brings in gap_abs, which the previous merit ignored even though is_solved accepts on it. The restore now also covers MaxIterations and MaxTime, because post_process runs the same acceptance check for those exits and their final iterate is no more likely to be the best one seen. Verified against stock on 224 public problems (Maros-Meszaros, Netlib, Netlib-Kennington, Mittelmann, structured conic): no status changes, no iteration-count changes, and one objective difference, on mm_yao, which terminates AlmostSolved in both and returns its better iterate. Also on 45 MIPLIB 2017 LP relaxations and 84 CBLIB instances: no status changes and no objective disagreements above 1e-6 on either. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CqGdm2vZZ6HA5HT8DsRePa
|
Pushed a commit applying both of the refinements suggested above. Merit now ranks by distance from the acceptance check. Each quantity is divided by the tolerance that will judge it, and they are combined exactly as fn termination_merit(&self, settings: &DefaultSettings<T>) -> T {
let gap = T::min(
self.gap_abs / settings.reduced_tol_gap_abs,
self.gap_rel / settings.reduced_tol_gap_rel,
);
let feas = T::max(
self.res_primal / settings.reduced_tol_feas,
self.res_dual / settings.reduced_tol_feas,
);
T::max(gap, feas)
}This makes the selection safe rather than merely reasonable: the chosen iterate has the smallest reduced merit of all candidates, so if any candidate would have passed The restore covers TestsA fourth test, Verification against stock
Timing is unchanged on all three (−0.1% and −0.0%, i.e. noise), as expected for a path that is inert unless a solve fails. Two MIPLIB relaxations, |
The improvement test was `merit >= best_merit`, and every comparison against NaN is false. A NaN merit therefore counted as an improvement: the NaN iterate was stored as the best one, and because the restore requires a finite best_merit, the fallback was then disabled for the rest of the solve. A solve whose iterates have gone non-finite is exactly the case this fallback exists to rescue, so that is the worst place to lose it. Testing strict improvement as `!(merit < best_merit)` rejects a non-finite merit while behaving identically on finite ones. The same test rejects an infinite merit, which is what a reduced tolerance of zero would produce (the tolerances are not validated positive). A test covers it, and fails against the previous comparison. No change on any finite input: 224 public problems, 45 MIPLIB 2017 LP relaxations and 84 CBLIB instances all report exactly the same statuses, iteration counts and objectives as before this commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CqGdm2vZZ6HA5HT8DsRePa
|
One more commit, from re-reading the save path: a NaN iterate could displace the best one and silently disarm the fallback. The improvement test was Testing strict improvement instead rejects it, and is identical on finite inputs: if !(merit < self.best_merit) {
return;
}That also rejects an infinite merit, which is what a reduced tolerance of zero would produce — the tolerances are not validated positive, so this makes a misconfiguration degrade to "feature inert" rather than to something stranger.
Nothing changes on finite input, as expected for a defensive fix — 224 public problems, 45 MIPLIB 2017 LP relaxations and 84 CBLIB instances report exactly the same statuses, iteration counts and objectives as the previous commit, with timing within noise (+0.1% / +0.2%). |
The bug
When the interior-point loop terminates with
NumericalErrororInsufficientProgress, the reduced-tolerance ("almost solved") acceptance check is evaluated against the final iterate. But on a numerical failure the final iterate is typically the one that just went bad -- that is why the solver stopped. The good iterate from one step earlier is discarded, and the solve is reported as a failure on the strength of a point the solver had already superseded.This shows up on problems whose attainable gap floor sits close to the requested tolerance: the primal residual jumps from ~1e-10 to ~3e-5 in a single step, the loop bails out, and the almost-solved test is then applied to the ruined point and fails. The solver had a perfectly acceptable answer in hand and threw it away.
The fix
Track the best iterate seen so far and restore it before the almost-solved check runs.
is_solvedat the reduced tolerances: each quantity divided by the tolerance that will judge it, combined exactly as in that test, so the duality gap enters through whichever of its absolute or relative forms is closer to passing. Those are the tolerancescheck_convergence_almostapplies to the restored iterate, so ranking by them makes the choice safe rather than merely reasonable: the selected iterate has the smallest reduced merit of all candidates, so if any candidate would have passed that check, the selected one passes it too. Comparing the raw quantities instead can prefer an iterate that fails the check over one that passes it, because the tolerances differ from each other -- by defaultreduced_tol_feasis 1e-4 whilereduced_tol_gap_relis 5e-5.ktratio > 1are never candidates: those lie on the infeasibility-certificate path and must not be presented as approximate solutions.merit >= besttest would let a NaN iterate be stored as the best one and, because the restore requires a finite best merit, silently disarm the fallback for the rest of the solve -- on exactly the solves it exists to rescue. Testing strict improvement rejects it, and also rejects the infinite merit a reduced tolerance of zero would produce.Solved.MaxIterationsandMaxTimeas well as the error statuses, becausepost_processruns the same acceptance check for those exits and their final iterate is no more likely to be the best one seen.Cost is one extra
DefaultVariablesand a copy on strict improvement.Evidence
It only ever helps. Verified against stock on 224 public problems (101 Maros-Meszaros QPs, 85 Netlib LPs, 24 structured conic, 12 Netlib-Kennington, 2 Mittelmann): no status changes and no iteration-count changes, and a single objective difference, on
mm_yao, which terminatesAlmostSolvedin both and returns its better iterate. That is the expected result -- problems that terminate normally never reach the restore -- and it bounds the blast radius: the patch cannot regress a solve that was already succeeding.Two further suites, used only as gates: 45 MIPLIB 2017 benchmark instances as LP relaxations and 84 CBLIB instances. No status changes and no objective disagreements above 1e-6 on either, and timing unchanged throughout (-0.1% and -0.0%, i.e. noise), as expected for a path that is inert unless a solve fails.
Two of those MIPLIB relaxations,
brazil3andcomp07_2idx, are the cases most sensitive to how the merit is defined; both return objectives identical to stock (1.9999982324 and 1.5433363423e-9).Tests
Five unit tests cover the semantics: a degraded final iterate is discarded in favour of the best seen; a final iterate that is itself the best is left untouched; iterates on the infeasibility path are never eligible to be saved or restored; the merit ranks by distance from the acceptance check rather than by raw magnitude, encoding a case where the raw comparison would prefer an iterate that fails the check over one that passes; and a NaN iterate cannot displace the best one or disarm the fallback. The last two were each checked to fail against the code they replaced, so they test what they claim to.
Full suite green:
cargo test, 20 test binaries, 0 failures.