Skip to content

Commit 2fc455e

Browse files
authored
Merge pull request #75 from JuliaNLSolvers/pkm/callbacks
Add callbacks throughout and convergence test in neldermead
2 parents 5926cbb + 9179731 commit 2fc455e

14 files changed

Lines changed: 381 additions & 15 deletions

File tree

docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ nav:
2727
- Tutorials:
2828
- Minimizing a function: 'optimization.md'
2929
- Solving non-linear equations: 'nonlineareq.md'
30+
- Callbacks: 'callbacks.md'
3031
# - Gradients and Hessians: 'user/gradientsandhessians.md'
3132
# - Configurable Options: 'user/config.md'
3233
# - Linesearch: 'algo/linesearch.md'

docs/src/callbacks.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Callbacks
2+
3+
NLSolvers.jl supports user-defined callbacks that are invoked after every iteration of any optimization solver. Callbacks let you monitor progress, log intermediate state, build a convergence trace, or stop the solver early based on custom criteria.
4+
5+
## Basic usage
6+
7+
A callback is any function (or callable object) that takes a single `info` argument and returns a `Bool`:
8+
9+
```julia
10+
using NLSolvers
11+
12+
callback = info -> begin
13+
println("iter=$(info.iter), f=$(info.state.fz)")
14+
return false # continue optimization
15+
end
16+
17+
solve(prob, x0, LineSearch(BFGS()),
18+
OptimizationOptions(callback = callback))
19+
```
20+
21+
Returning `true` stops the solver early; returning `false` lets it continue. The default `callback = nothing` disables the mechanism with zero overhead.
22+
23+
## What the callback receives
24+
25+
The `info` argument is a `NamedTuple` with three fields:
26+
27+
| Field | Type | Description |
28+
|---|---|---|
29+
| `iter` | `Int` | Current iteration count (1-based) |
30+
| `time` | `Float64` | Elapsed seconds since `solve` started |
31+
| `state` | `NamedTuple` | Solver-specific state (see below) |
32+
33+
## Solver-specific `state`
34+
35+
The contents of `info.state` depend on which solver is running. Use `haskey` if you write generic callbacks.
36+
37+
### Line search solvers (BFGS, DBFGS, DFP, SR1, L-BFGS, CG, Gradient Descent, Newton)
38+
39+
`state` is the internal `objvars` named tuple:
40+
41+
| Field | Description |
42+
|---|---|
43+
| `x` | Previous iterate |
44+
| `fx` | Objective value at `x` |
45+
| `∇fx` | Gradient at `x` |
46+
| `z` | New iterate (after line search) |
47+
| `fz` | Objective value at `z` |
48+
| `∇fz` | Gradient at `z` |
49+
| `B` | Current Hessian (or inverse) approximation; `nothing` for L-BFGS/CG |
50+
| `Pg` | Preconditioned gradient if using a preconditioner; otherwise `nothing` |
51+
52+
### Trust region solvers
53+
54+
Same fields as line search, plus:
55+
56+
| Field | Description |
57+
|---|---|
58+
| `Δ` | Current trust region radius |
59+
| `rejected` | `true` if the previous step was rejected by the trust region rule |
60+
61+
### Nelder-Mead
62+
63+
| Field | Description |
64+
|---|---|
65+
| `simplex_vector` | Vector of simplex vertices |
66+
| `simplex_value` | Function values at each vertex |
67+
| `x_centroid` | Centroid of the simplex (excluding the worst vertex) |
68+
| `nm_obj` | Convergence metric — standard deviation of `simplex_value` |
69+
70+
### Simulated Annealing
71+
72+
| Field | Description |
73+
|---|---|
74+
| `x_best` | Best point found so far |
75+
| `f_best` | Best objective value found so far |
76+
| `x_now` | Current state of the chain |
77+
| `f_now` | Objective value at `x_now` |
78+
| `temperature` | Current temperature |
79+
80+
### Particle Swarm
81+
82+
| Field | Description |
83+
|---|---|
84+
| `X` | Current particle positions |
85+
| `X_best` | Each particle's personal best |
86+
| `Fs` | Function values at `X` |
87+
| `Fs_best` | Function values at `X_best` |
88+
| `x` | Global best particle |
89+
| `best_f` | Global best objective value |
90+
| `swarm_f` | Convergence metric for the swarm |
91+
92+
### Brent's method (univariate)
93+
94+
| Field | Description |
95+
|---|---|
96+
| `x` | Current best point |
97+
| `fx` | Function value at `x` |
98+
| `a`, `b` | Current bracketing interval `[a, b]` |
99+
| `v`, `w` | Two previous iterates |
100+
| `fv`, `fw` | Function values at `v` and `w` |
101+
102+
### Active Box (projected Newton)
103+
104+
| Field | Description |
105+
|---|---|
106+
| `x` | Previous iterate |
107+
| `z` | New iterate |
108+
| `fz` | Objective value at `z` |
109+
| `∇fz` | Gradient at `z` |
110+
| `B` | Hessian approximation |
111+
| `activeset` | Boolean vector indicating active bound constraints |
112+
113+
## Examples
114+
115+
### Build a convergence trace
116+
117+
```julia
118+
trace = Float64[]
119+
solve(prob, x0, LineSearch(BFGS()),
120+
OptimizationOptions(
121+
callback = info -> (push!(trace, info.state.fz); false),
122+
maxiter = 100,
123+
))
124+
```
125+
126+
### Stop when the gradient is sufficiently small
127+
128+
```julia
129+
gtol = 1e-6
130+
solve(prob, x0, LineSearch(BFGS()),
131+
OptimizationOptions(
132+
callback = info -> norm(info.state.∇fz, Inf) < gtol,
133+
g_abstol = 0.0, # disable built-in g-tolerance so callback wins
134+
))
135+
```
136+
137+
### Time-limited optimization
138+
139+
```julia
140+
time_limit = 5.0 # seconds
141+
solve(prob, x0, LineSearch(BFGS()),
142+
OptimizationOptions(callback = info -> info.time > time_limit))
143+
```
144+
145+
### Save iterates for plotting later
146+
147+
Arrays in `info.state` (such as `z`, `∇fz`, `simplex_vector`) are aliases of live solver buffers — they will be overwritten on the next iteration. **Copy them if you need to retain them past the callback call.**
148+
149+
```julia
150+
history = Vector{Vector{Float64}}()
151+
solve(prob, x0, LineSearch(BFGS()),
152+
OptimizationOptions(
153+
callback = info -> (push!(history, copy(info.state.z)); false),
154+
))
155+
```
156+
157+
Scalar fields like `info.iter`, `info.time`, `info.state.fz` are values and do not need copying.
158+
159+
## Performance
160+
161+
The callback machinery has zero runtime overhead when `callback === nothing` (the default). The callback type is captured as a type parameter on `OptimizationOptions`, so the compiler eliminates the dispatch entirely. Passing a concrete callback function adds only the cost of calling that function once per iteration.

src/optimize/directsearch/neldermead.jl

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@ function _solve(
193193
iter = 0
194194
nm_obj = f0
195195
is_converged = false
196-
while iter <= options.maxiter && !any(is_converged)
196+
callback_stopped = false
197+
while iter <= options.maxiter && !any(is_converged) && !callback_stopped
197198
iter += 1
198199
nm_obj, x_centroid = iterate!(
199200
prob,
@@ -213,6 +214,7 @@ function _solve(
213214
if nm_obj options.nm_tol
214215
is_converged = true
215216
end
217+
callback_stopped = _check_callback(options.callback, (iter=iter, time=time()-t0, state=(simplex_vector=simplex_vector, simplex_value=simplex_value, x_centroid=x_centroid, nm_obj=nm_obj)))
216218
end
217219
f_centroid_min = value(prob, x_centroid)
218220
f_min, i_f_min = findmin(simplex_value)
@@ -385,8 +387,9 @@ function _solve(
385387
is_converged = false
386388
iter = 0
387389
nm_obj = f0
390+
callback_stopped = false
388391

389-
while iter <= options.maxiter && !any(is_converged)
392+
while iter <= options.maxiter && !any(is_converged) && !callback_stopped
390393
iter += 1
391394

392395
# Augment the iteration counter
@@ -467,10 +470,10 @@ function _solve(
467470
x_centroid = centroid(simplex_vector, i_order[end])
468471

469472
nm_obj = nmobjective(simplex_value)
470-
# if nm_x < 1e-18
471-
# break
472-
# end
473-
# check conv
473+
if nm_obj options.nm_tol
474+
is_converged = true
475+
end
476+
callback_stopped = _check_callback(options.callback, (iter=iter, time=time()-t0, state=(simplex_vector=simplex_vector, simplex_value=simplex_value, x_centroid=x_centroid, nm_obj=nm_obj)))
474477
end
475478
f_centroid_min = value(prob, x_centroid)
476479
f_min, i_f_min = findmin(simplex_value)

src/optimize/linesearch/conjugategradient.jl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,16 @@ function _solve(
236236
cgvars = CGVars(y, d, α, β, true)
237237

238238
k = 1
239+
callback_stopped = false
239240
objvars, P, cgvars = iterate(mstyle, cgvars, objvars, approach, problem, options)
240241
is_converged = converged(approach, objvars, ∇f0, options)
241-
while k < options.maxiter && !any(is_converged)
242+
callback_stopped = _check_callback(options.callback, (iter=k, time=time()-t0, state=objvars))
243+
while k < options.maxiter && !any(is_converged) && !callback_stopped
242244
k += 1
243245
objvars, P, cgvars =
244246
iterate(mstyle, cgvars, objvars, approach, problem, options, P, false)
245247
is_converged = converged(approach, objvars, ∇f0, options)
248+
callback_stopped = _check_callback(options.callback, (iter=k, time=time()-t0, state=objvars))
246249
end
247250
x, fx, ∇fx, z, fz, ∇fz, B = objvars
248251
return ConvergenceInfo(

src/optimize/linesearch/limitedquasinewton.jl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,11 @@ function _solve(
6363
objvars, qnvars, P =
6464
iterate(mstyle, 1, qnvars, objvars, P, approach, problem, problem, options)
6565
iter = 1
66+
callback_stopped = false
6667
# Check for gradient convergence
6768
is_converged = converged(approach, objvars, ∇f0, options)
68-
while iter <= options.maxiter && !any(is_converged)
69+
callback_stopped = _check_callback(options.callback, (iter=iter, time=time()-t0, state=objvars))
70+
while iter <= options.maxiter && !any(is_converged) && !callback_stopped
6971
iter += 1
7072
# take a step and update approximation
7173
objvars, qnvars, P = iterate(
@@ -82,6 +84,7 @@ function _solve(
8284
)
8385
# Check for gradient convergence
8486
is_converged = converged(approach, objvars, ∇f0, options)
87+
callback_stopped = _check_callback(options.callback, (iter=iter, time=time()-t0, state=objvars))
8588
end
8689
x, fx, ∇fx, z, fz, ∇fz, B, Pg = objvars
8790
return ConvergenceInfo(

src/optimize/linesearch/quasinewton.jl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,12 @@ function _solve(
9191
==============================#
9292
objvars, P, qnvars = iterate(mstyle, qnvars, objvars, P, approach, problem, options)
9393
iter = 1
94+
callback_stopped = false
9495
# Check for gradient convergence
9596
is_converged = converged(approach, objvars, ∇f0, options)
9697
print_trace(approach, options, iter, t0, objvars)
97-
while iter < options.maxiter && !any(is_converged)
98+
callback_stopped = _check_callback(options.callback, (iter=iter, time=time()-t0, state=objvars))
99+
while iter < options.maxiter && !any(is_converged) && !callback_stopped
98100
iter += 1
99101
#==============================
100102
iterate
@@ -107,6 +109,7 @@ function _solve(
107109
==============================#
108110
is_converged = converged(approach, objvars, ∇f0, options)
109111
print_trace(approach, options, iter, t0, objvars)
112+
callback_stopped = _check_callback(options.callback, (iter=iter, time=time()-t0, state=objvars))
110113
end
111114
x, fx, ∇fx, z, fz, ∇fz, B, Pg = objvars
112115
return ConvergenceInfo(

src/optimize/problem_types.jl

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ function Base.show(io::IO, ci::ConvergenceInfo)
199199
println(io, " Iterations: $(info.iter)")
200200
end
201201

202-
struct OptimizationOptions{T1,T2,T3,T4,Txn,Tgn}
202+
struct OptimizationOptions{T1,T2,T3,T4,Txn,Tgn,Tcb}
203203
x_abstol::T1
204204
x_reltol::T1
205205
x_norm::Txn
@@ -212,6 +212,7 @@ struct OptimizationOptions{T1,T2,T3,T4,Txn,Tgn}
212212
nm_tol::T3
213213
maxiter::T4
214214
show_trace::Bool
215+
callback::Tcb
215216
end
216217

217218
OptimizationOptions(;
@@ -222,11 +223,12 @@ OptimizationOptions(;
222223
g_reltol = 0.0,
223224
g_norm = x -> norm(x, Inf),
224225
f_limit = -Inf,
225-
f_abstol = -Inf, #
226+
f_abstol = -Inf, #
226227
f_reltol = -Inf, # Not useful at 0 if for example we have quadric and trust region accept but objective is the same
227228
nm_tol = 1e-8,
228229
maxiter = 10000,
229230
show_trace = false,
231+
callback = nothing,
230232
) = OptimizationOptions(
231233
x_abstol,
232234
x_reltol,
@@ -240,8 +242,16 @@ OptimizationOptions(;
240242
nm_tol,
241243
maxiter,
242244
show_trace,
245+
callback,
243246
)
244247

248+
_check_callback(::Nothing, info) = false
249+
function _check_callback(cb, info)
250+
result = cb(info)
251+
result isa Bool || throw(ArgumentError("callback must return Bool, got $(typeof(result))"))
252+
return result
253+
end
254+
245255
struct MinResults{Tr,Tc<:ConvergenceInfo,Th,Ts,To}
246256
res::Tr
247257
conv::Tc

src/optimize/projected/projectednewton.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ function _solve(
164164
options,
165165
)
166166
end
167+
if _check_callback(options.callback, (iter=iter, time=time()-t0, state=(x=x, z=z, fz=fz, ∇fz=∇fz, B=B, activeset=activeset)))
168+
break
169+
end
167170
end
168171
iter = options.maxiter
169172
return ConvergenceInfo(

src/optimize/randomsearch/particleswarm.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ function solve(
8080
X_best[1] .= x0
8181
X[1] .= x0
8282
iter = 0
83-
while iter < options.maxiter
83+
callback_stopped = false
84+
while iter < options.maxiter && !callback_stopped
8485
iter += 1
8586
limit_X!(X, lower, upper)
8687
Fs = batched_value(problem, Fs, X)
@@ -125,6 +126,7 @@ function solve(
125126
current_state, swarm_f = get_swarm_state(X, Fs, x, current_state)
126127
ω, c₁, c₂ = update_swarm_params!(c₁, c₂, ω, current_state, swarm_f)
127128
update_swarm!(X, X_best, x, n, V, ω, c₁, c₂)
129+
callback_stopped = _check_callback(options.callback, (iter=iter, time=time()-t0, state=(X=X, X_best=X_best, Fs=Fs, Fs_best=Fs_best, x=x, best_f=best_f, swarm_f=swarm_f)))
128130
end
129131
best_f, x
130132
ConvergenceInfo(

src/optimize/randomsearch/simulatedannealing.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ function solve(
5454
f0 = f_now
5555
temperature = f_best
5656
iter = 0
57+
callback_stopped = false
5758
is_converged = converged(method, f_now, options)
58-
while iter options.maxiter && !(is_converged)
59+
while iter options.maxiter && !(is_converged) && !callback_stopped
5960
iter += 1
6061
# Determine the temperature for current iteration
6162
temperature = method.temperature(iter)
@@ -87,6 +88,7 @@ function solve(
8788
end
8889
end # n_per_temperature
8990
is_converged = converged(method, f_now, options)
91+
callback_stopped = _check_callback(options.callback, (iter=iter, time=time()-t0, state=(x_best=x_best, f_best=f_best, x_now=x_now, f_now=f_now, temperature=temperature)))
9092
end
9193

9294
ConvergenceInfo(

0 commit comments

Comments
 (0)