Repository navigation
Always pass p explicitly to instantiated functions (stale p after reinit!, data iterators silently use the first batch) #1383
Description
Summary
instantiate_function bakes p into the derivative and constraint closures (grad, fg, hess, hv, cons, cons_j, lag_h, ...) when the cache is built. The objective f.f is left unwrapped, so solvers must pass p to it. Solvers call the derivatives both ways, grad(G, θ) (baked p) and grad(G, θ, p) (explicit p), and the backends don't agree on which forms they support. This causes three problems:
reinit!(cache; p = ...)is silently ignored by every baked closure.reinit!only setscache.reinit_cache.pand does not re-instantiate anything.- With a data iterator as
p, the closures are baked with the first batch (OptimizationBase/src/cache.jl:84-90), whilecache.pholds the whole iterator. Solvers that don't handle iterators silently fit batch 1. - Backends are inconsistent about whether a passed
pis used, ignored, or appended as an extra argument (details below).
Proposal: always pass p explicitly. Every instantiated closure takes the parameters as an argument, solvers always pass cache.p (or the current batch), and nothing depends on the p seen at instantiation. Data iterators need a decision first; see the last section.
Reproductions
All runs below use master (bf6d75a) plus #1381, on Julia 1.12.7.
Stale p after reinit!
using OptimizationBase, OptimizationOptimisers, Optimisers, ForwardDiff
using SciMLBase: OptimizationFunction, OptimizationProblem
objective(x, p) = (p[1] - x[1])^2
grad!(G, x, p) = (G[1] = -2 * (p[1] - x[1]))
for optf in (OptimizationFunction(objective, OptimizationBase.AutoForwardDiff()),
OptimizationFunction(objective; grad = grad!))
cache = OptimizationBase.init(OptimizationProblem(optf, zeros(1), [1.0]), Optimisers.Adam(0.1), maxiters = 1000)
OptimizationBase.solve!(cache)
cache = OptimizationBase.reinit!(cache; p = [2.0])
@show OptimizationBase.solve!(cache).u cache.p
endafter reinit! to p = [2.0] (expected u ≈ 2) |
|
|---|---|
AutoForwardDiff() |
u = [1.0]: the baked fg never sees the new p |
no AD, user grad |
u = [1.273]: the gradient uses the old p (baked grad(G, θ)), while the objective uses the new one (cache.f(θ, d)); save_best then picks an iterate under the new objective |
This is the existing @test_broken in lib/OptimizationOptimisers/test/core_tests.jl:99-101. Related @test_brokens: lib/OptimizationMOI/test/core_tests.jl:251,263 ("needs reinit/remake fixes") and lib/OptimizationIpopt/test/core_tests.jl:192-202 (MTK). Ipopt's other reinit test passes because Ipopt forwards p explicitly when it can (_accepts_live_p).
A solver that doesn't handle iterators fits only batch 1
using OptimizationBase, OptimizationOptimJL, ForwardDiff, MLUtils
using SciMLBase: OptimizationFunction, OptimizationProblem
data = MLUtils.DataLoader(([1.0, 1.0, 5.0, 5.0],), batchsize = 2)
loss(u, batch) = sum(abs2, u[1] .- batch[1])
optf = OptimizationFunction(loss, OptimizationBase.AutoForwardDiff())
solve(OptimizationProblem(optf, [0.0], data), LBFGS(), maxiters = 50).u # [1.0]; the full-data mean is 3.0There is no error or warning. The objective only ever receives the first batch, because all evaluations go through the closures baked with batch 1.
Data iterators: current handling
cache.jl:84-90instantiates the closures fromReInitCache(u0, iterate(prob.p)[1])and stores the iterator incache.p.reinit!never updates the first-batch copy.- Iterator-aware solvers:
- OptimizationOptimisers and OptimizationSophia pass the batch
dexplicitly in their iterator branches (objective,grad,fg, andhvin Sophia). That part works: withsave_best = false, Adam on theDataLoaderabove converges to the full-data mean. - OptimizationAuglag passes the whole iterator to
cons(by design, seeauglag_function.jl:65-80).cons_jis baked with batch 1, which is documented intest/core_tests.jl:339. The inner problem gets the iterator asp, so if the inner solver isn't iterator-aware, the objective gets the whole iterator while the derivatives use batch 1 (from reading the code; not run).
- OptimizationOptimisers and OptimizationSophia pass the batch
- All other solvers ignore iterators. They pass
cache.p(the whole iterator) to the objective, while baked derivatives use batch 1. Solvers that passpexplicitly to derivatives (Ipopt, SciPy, ODE, the NLPModelsgrad) hand the whole iterator to them. - Side finding: with minibatches,
save_bestin OptimizationOptimisers compares single-batch losses from different batches. The Adam run above returnsu = [1.03]with the defaultsave_best = true, and[3.0]withsave_best = false.
With "always pass p", the p slot holds the batch in iterator-aware solvers. Solvers that aren't iterator-aware should reject a data iterator (for example through a trait or a check in __solve) instead of silently using batch 1. We also need to decide what instantiate_function sees for an iterator: probably just a prototype batch for preparation, never a value that gets used.
Backend inconsistencies
Verified by running:
- NoAD's
if p != NullParameters()guards around the 3-arg methods (function.jl) don't do anything. Julia hoists local method definitions, sograd(G, x, p)exists even forNullParameters(). Both arities always exist; the guards are dead code. The same pattern appears inOptimizationDISparseExt.jl.
From reading the code (not run):
| closure | NoAD (function.jl) |
DI dense | DI sparse | MTK (OptimizationMTKBaseExt.jl, OptimizationMTKExt.jl) |
|---|---|---|---|---|
grad, fg, hess, fgh, hv |
p optional |
p = p default |
p optional |
(G, θ, args...) -> f.grad(G, θ, mtkprob.p, args...): a passed p becomes an extra argument; no fg/fgh |
cons, cons_j |
p_call = p |
p = p |
p = p |
(res, θ) only, so p isn't accepted |
cons_vjp, cons_jvp |
(res, x, p_call = p): no v argument, so a caller's v lands in p (function.jl:202-205) |
baked | baked | not provided |
cons_h |
p_call = p |
baked | baked | (res, θ) only |
lag_h |
only (res, x) and (res, x, p), but callers pass (res, θ, σ, μ[, p]) (function.jl:210-219) |
p = p, but the constraint term uses the baked cons_oop |
p-taking method calls cons_h(H, θ) (the Bool kwarg) where the other method uses cons_h!, and H *= λ rebinds H instead of writing into it (OptimizationDISparseExt.jl:285-290) |
not provided |
The MTK objective is mtkprob.f.f, which expects mtkprob.p, while solvers pass cache.p.
Solver call sites (from reading the code)
The objective always gets p explicitly, after #1381, which fixed the only exceptions: the grad-only branches of Optimisers and Sophia. For derivatives and constraints:
- Baked
p, so stale afterreinit!and batch 1 with iterators:- OptimizationOptimisers (
fg122,grad127 in the non-iterator branch) - OptimizationSophia (128, 130)
- OptimizationOptimJL (all derivative and constraint sites)
- OptimizationLBFGSB, OptimizationNLopt, OptimizationManopt, OptimizationGCMAES, OptimizationSpeedMapping
- NLPModels / MadNLP (
hess,lag_h, all constraints) - OptimizationMOI
nlp.jl(all derivatives and constraints) - Ipopt
cons_h(evaluator.jl:239) - constraints in OptimizationODE, OptimizationEvolutionary, OptimizationPRIMA
- OptimizationOptimisers (
- Explicit
p:- Ipopt (when
_accepts_live_p), SciPy, ODE (derivatives), NLPModelsgrad, Auglag - SimpleOptimization builds its gradient from
Fix2(cache.f.f, cache.p)at solve time
- Ipopt (when
- Explicit
pcan't work with MTK closures (the extra-argument issue): NLPModelsgrad, SciPy. Ipopt excludes AutoSymbolics from its explicit-ppath.
Related
- Pass parameters to the objective when only
gradis supplied (Optimisers, Sophia) #1381: objective called withoutpin thegrad-only branch. - Clarify data iterator interface #1203: clarify the data iterator interface.
Written by Claude Code on Sebastian's behalf.
🤖 Generated with Claude Code · model: claude-opus-5-5[1m]
Review: unreviewed
Activity
Keep instantiated closures in sync with the live `p` (SciML#1383)
ChrisRackauckas-Claude commentedon Oct 9, 2026
A fix is in review in #1400 (thanks @greekera1000). The review found three blocking items before it can merge: stale sparse Hessian metadata after
reinit!, a NaN Hessian when the objective weight is zero, and a non-public cross-package call. Details are in the PR review.🤖 Status update by an AI agent (Claude Code, model claude-opus-5-5[1m]) on behalf of Chris Rackauckas — https://claude.ai/code/session_01AXwyJDRaMKgnYMG9M1Ut49
Keep instantiated closures in sync with the live `p` (SciML#1383)