Skip to content

scipy.optimize: report ftol termination in L-BFGS as converged - #40227

Open
VaggelisGian wants to merge 1 commit into
jax-ml:mainfrom
VaggelisGian:fix-lbfgs-ftol-status
Open

scipy.optimize: report ftol termination in L-BFGS as converged#40227
VaggelisGian wants to merge 1 commit into
jax-ml:mainfrom
VaggelisGian:fix-lbfgs-ftol-status

Conversation

@VaggelisGian

Copy link
Copy Markdown

Description

In jax.scipy.optimize.minimize(method="l-bfgs-experimental-do-not-rely-on-this"), an iteration whose function decrease satisfied (f_k - f_{k+1}) < ftol was recorded as status 4 ("insufficient progress") while the converged mask tested only the gradient norm. Such runs ended with success=False even though they met the requested tolerance. SciPy's L-BFGS-B treats the same condition as convergence.

The ftol condition now feeds converged, so an ftol-stopped run reports converged=True, status 0, success=True, provided the line search at that iteration succeeded. If the line search failed at that iteration, the run still ends as a failure exactly as before (a failed search returns a finite objective decrease that would otherwise masquerade as ftol convergence). Status value 4 no longer occurs; termination points are unchanged for every input - such iterations previously stopped the loop via failed and now stop it via converged.

Deliberate scope limit: the ftol criterion stays an absolute decrease, unlike SciPy's relative reduction over max{|f_k|, |f_{k+1}|, 1}. The absolute form is what the docstring has always documented, switching would move termination points for existing inputs, and JAX already disclaims result parity with SciPy for this method.

AI assistance disclosure: this fix was developed with AI coding assistance under my direction; I verified the root cause analysis, ran all tests shown below, and take responsibility for the change.

Test Plan

python -m pytest tests/scipy_optimize_test.py -k "ftol_termination or default_termination or gtol_convergence or ftol_not_reported or ftol_takes_precedence" -q
  default flags: green after the fix; test_ftol_not_reported_when_line_search_fails fails against the unfixed tree with
  AssertionError: Array(True, dtype=bool) is not false
python -m pytest tests/scipy_optimize_test.py -k "minimize or bfgs or lbfgs" -q
  default flags: 18 passed, 1 skipped.
JAX_ENABLE_X64=1 python -m pytest tests/scipy_optimize_test.py -k "minimize or bfgs or lbfgs" -q
  x64: 19 passed.
python -m pytest tests/scipy_optimize_test.py -q   (full file)
  default flags: 18 passed, 1 skipped; x64: 19 passed.

Byte-level probes confirm identical nit/nfev/x/fun before and after the change for gtol-only and default runs; only the success/status reporting of ftol stops flips.

References

  • L-BFGS fails when ftol is met #15486 reported exactly this ("L-BFGS fails when ftol is met") and closed without a fix. This PR is the minimal reporting fix for the method as shipped; no parity beyond clean stops is claimed, and the absolute-ftol semantics are unchanged. I am aware of the sentiment in that thread favoring directing users to jaxopt - until any deprecation actually lands, users of this method still hit the failure reporting, and this change alters nothing except that reporting.

Root cause: in jax/_src/scipy/optimize/_lbfgs.py an iteration whose
function decrease satisfied `(f_k - f_{k+1}) < ftol` set status=4 while
the converged mask tested only the gradient norm, so such a run ended
with converged=False, failed=True, and minimize() reported success=False
for a run that met its requested tolerance. For a clean stop this
matches the outcome SciPy L-BFGS-B reports for its ftol stopping rule;
no parity is claimed beyond that.

Change: the ftol condition now feeds `converged`, gated on line-search
success (`(~ls_results.failed) & (f_k - f_{k+1} < ftol)`), so an
ftol-stopped run reports converged=True, status=0, success=True while an
iteration whose line search failed never counts as converged via ftol.
The gate matters because a failed search can return a far-away point
whose spurious decrease satisfies the ftol test: on a scaled Rosenbrock
(float32, x0=[1.5, 1.5]) minimize() then reported success=True at
x=[-4.51e+10, 1.50e+10]; with the gate the run ends failed with status 5,
exactly as in the code before this change. The now-unreachable status=4
assignment is removed and the LBFGSResults docstring no longer lists
status 4; the ftol argument docstring states the failure exception.
Termination points are unchanged: every iteration that ended the loop
before this change still ends it at the same point (an ftol iteration
stopped via failed=True and now stops via converged=True), so iteration
and evaluation counts are identical for every input. The gtol criterion
itself is untouched.

Deliberate scope limits: the ftol criterion stays an absolute decrease,
unlike SciPy's relative reduction over max{|f_k|, |f_{k+1}|, 1}. The
absolute form is what this docstring has always documented, switching to
a relative form would move termination points for existing inputs, and
parity with SciPy holds only for clean stops, not for the stopping
criterion itself (minimize() already documents that results may differ
due to the line search). A relative criterion belongs to a separate
change. minimize() tol wiring is untouched; tests drive the solver
through options directly. A CHANGELOG entry under Unreleased documents
the behavior change.

Test Plan:
  python -m pytest tests/scipy_optimize_test.py -k "ftol" -q with the
  ftol term ungated, i.e. the state before the gate was added:
    FAILED tests/scipy_optimize_test.py::TestLBFGS::test_ftol_not_reported_when_line_search_fails
    AssertionError: Array(True, dtype=bool) is not false
    1 failed, 2 passed, 16 deselected in 6.61s
  python -m pytest tests/scipy_optimize_test.py -k "ftol" -q
    3 passed, 16 deselected in 3.21s
  Direct probe of the scaled-Rosenbrock repro (float32, x0=[1.5, 1.5]),
  two deterministic runs per variant:
    ungated mask:
      success=True status=0 nit=1 nfev=20 x=[-4.51e+10  1.50e+10] fun=5.65e+09
      success=True status=0 nit=1 nfev=20 x=[-4.51e+10  1.50e+10] fun=5.65e+09
    gated mask (this change):
      success=False status=5 nit=1 nfev=20 x=[-4.51e+10  1.50e+10] fun=5.65e+09
      success=False status=5 nit=1 nfev=20 x=[-4.51e+10  1.50e+10] fun=5.65e+09
    HEAD~1 mask swapped back in:
      success=False status=5 nit=1 nfev=20 x=[-4.51e+10  1.50e+10] fun=5.65e+09
      success=False status=5 nit=1 nfev=20 x=[-4.51e+10  1.50e+10] fun=5.65e+09
  python -m pytest tests/scipy_optimize_test.py -k "minimize or bfgs or lbfgs" -q
    18 passed, 1 skipped in 10.42s
  JAX_ENABLE_X64=1 python -m pytest tests/scipy_optimize_test.py -k "minimize or bfgs or lbfgs" -q
    19 passed in 10.85s
  python -m pytest tests/scipy_optimize_test.py -q
    18 passed, 1 skipped in 10.52s
  JAX_ENABLE_X64=1 python -m pytest tests/scipy_optimize_test.py -q
    19 passed in 26.88s
  python -m ruff check jax/_src/scipy/optimize/_lbfgs.py tests/scipy_optimize_test.py
    All checks passed!
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.

1 participant