Skip to content

Commit b23b925

Browse files
committed
Add tutorial
1 parent 59598b7 commit b23b925

File tree

2 files changed

+204
-0
lines changed

2 files changed

+204
-0
lines changed

docs/make.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ const _PAGES = [
138138
"tutorials/nonlinear/rosenbrock.md",
139139
"tutorials/nonlinear/mle.md",
140140
"tutorials/nonlinear/clnlbeam.md",
141+
"tutorials/nonlinear/user_defined_hessians.md",
141142
"tutorials/nonlinear/querying_hessians.md",
142143
],
143144
"Conic programs" => [
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
# Copyright (c) 2022 Oscar Dowson and contributors #src
2+
# #src
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy #src
4+
# of this software and associated documentation files (the "Software"), to deal #src
5+
# in the Software without restriction, including without limitation the rights #src
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell #src
7+
# copies of the Software, and to permit persons to whom the Software is #src
8+
# furnished to do so, subject to the following conditions: #src
9+
# #src
10+
# The above copyright notice and this permission notice shall be included in all #src
11+
# copies or substantial portions of the Software. #src
12+
# #src
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR #src
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, #src
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE #src
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER #src
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, #src
18+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE #src
19+
# SOFTWARE. #src
20+
21+
# # User-defined hessians
22+
23+
# In this tutorial, we explain how to write a user-defined function with an
24+
# explicit hessian.
25+
26+
# This tutorial uses the following packages:
27+
28+
using JuMP
29+
import Ipopt
30+
31+
# Our goal for this tutorial is to solve the bilevel optimization problem:
32+
33+
# ```math
34+
# \begin{array}{r l}
35+
# \min\limits_{x} & x_1^2 + x_2^2 + z \\
36+
# s.t. & \begin{array}{r l}
37+
# z \ge \max\limits_{y} & x_1^2 y_1 + x_2^2 * y_2 - x_1 y_1^4 - 2 x_2 y_2^4 \\
38+
# s.t. & (y_1 - 10)^2 + (y_2 - 10)^2 \le 25
39+
# \end{array} \\
40+
# & x \ge 0.
41+
# \end{array}
42+
# ```
43+
44+
# This bilevel optimization problem is composed of two nested optimization
45+
# problems. An _upper_ level, involving variables ``x``, and a _lower_ level,
46+
# involving variables ``y``. From the perspective of the lower-level, the
47+
# values of ``x`` are fixed parameters, and so the model optimizes ``y`` given
48+
# those fixed parameters. Simultaneously, the upper level is optimizing ``x``
49+
# given the response of ``yy``.
50+
51+
# There are a few ways to solve this problem, but we are going to use a
52+
# nonlinear decomposition method. The first step is to write a function to
53+
# compute:
54+
55+
# ```math
56+
# \begin{array}{r l}
57+
# V(x_1, x_z) = \max\limits_{y} & x_1^2 y_1 + x_2^2 * y_2 - x_1 y_1^4 - 2 x_2 y_2^4 \\
58+
# s.t. & (y_1 - 10)^2 + (y_2 - 10)^2 \le 25
59+
# \end{array}
60+
# ```
61+
62+
function solve_lower_level(x...)
63+
model = Model(Ipopt.Optimizer)
64+
set_silent(model)
65+
@variable(model, y[1:2])
66+
@NLobjective(
67+
model,
68+
Max,
69+
x[1]^2 * y[1] + x[2]^2 * y[2] - x[1] * y[1]^4 - 2 * x[2] * y[2]^4,
70+
)
71+
@constraint(model, (y[1] - 10)^2 + (y[2] - 10)^2 <= 25)
72+
optimize!(model)
73+
@assert termination_status(model) == LOCALLY_SOLVED
74+
return objective_value(model), value.(y)
75+
end
76+
77+
# This function takes a guess of ``x``, and returns the optimal lower-level
78+
# objective-value and the optimal response ``y``. The reason why we need both
79+
# the objective and the optimal ``y`` will be made clear shortly, but for now
80+
# let us define:
81+
82+
function V(x...)
83+
f, _ = solve_lower_level(x...)
84+
return f
85+
end
86+
87+
# We can substitute ``V`` into our full problem to create:
88+
89+
# ```math
90+
# \begin{array}{r l}
91+
# \min\limits_{x} & x_1^2 + x_2^2 + V(x_1, x_2) \\
92+
# s.t. & x \ge 0.
93+
# \end{array}
94+
# ```
95+
96+
# This looks like a nonlinear optimization problem with a user-defined function
97+
# ``V``! However, because ``V`` solves an optimization problem internally, we
98+
# can't use automatic differentiation to compute the first and second
99+
# derivatives.
100+
101+
# First up, we need to define the gradient of ``V`` with respect to ``x``. In
102+
# general, this may be difficult to compute, but because ``x`` appears only in
103+
# the objective, we can just differentiate the objective function with respect
104+
# to ``x``, giving:
105+
106+
function ∇V(g::AbstractVector, x...)
107+
_, y = solve_lower_level(x...)
108+
g[1] = 2 * x[1] * y[1] - y[1]^4
109+
g[2] = 2 * x[2] * y[2] - 2 * y[2]^4
110+
return
111+
end
112+
113+
# Second, we need to define the hessian of ``V`` with respect to ``x``. This is
114+
# a symmetric matrix, but in our example only the diagonal elements are
115+
# non-zero:
116+
117+
function ∇²V(H::AbstractMatrix, x...)
118+
_, y = solve_lower_level(x...)
119+
H[1, 1] = 2 * y[1]
120+
H[2, 2] = 2 * y[2]
121+
return
122+
end
123+
124+
# We now have enough to define our bilevel optimization problem:
125+
126+
model = Model(Ipopt.Optimizer)
127+
@variable(model, x[1:2] >= 0)
128+
register(model, :V, 2, V, ∇V, ∇²V)
129+
@NLobjective(model, Min, x[1]^2 + x[2]^2 + V(x[1], x[2]))
130+
optimize!(model)
131+
solution_summary(model)
132+
133+
# The optimal objective value is:
134+
135+
objective_value(model)
136+
137+
# and the optimal upper-level decision variables ``x`` are:
138+
139+
value.(x)
140+
141+
# To compute the optimal lower-level decision variables, we need to call
142+
# `solve_lower_level` with the optimal upper-level decision variables:
143+
144+
_, y = solve_lower_level(value.(x)...)
145+
y
146+
147+
# This solution approach worked, but it has a performance problem: every time
148+
# we needed to compute the value, gradient, or hessian of ``V``, we had to
149+
# resolve the lower-level optimization problem! This is wasteful, because we
150+
# will often call the gradient and hessian at the same point, and so solving the
151+
# problem twice with the same input repeats work unnecessarily.
152+
153+
# We can work around this by using memoization:
154+
155+
function memoized_solve_lower_level()
156+
last_x, f, y = nothing, 0.0, [NaN, NaN]
157+
function _update_if_needed(x...)
158+
if last_x != x
159+
f, y = solve_lower_level(x...)
160+
last_x = x
161+
end
162+
return
163+
end
164+
function memoized_f(x...)
165+
_update_if_needed(x...)
166+
return f
167+
end
168+
function memoized_∇f(g::AbstractVector, x...)
169+
_update_if_needed(x...)
170+
g[1] = 2 * x[1] * y[1] - y[1]^4
171+
g[2] = 2 * x[2] * y[2] - 2 * y[2]^4
172+
return
173+
end
174+
function memoized_∇²f(H::AbstractMatrix, x...)
175+
_update_if_needed(x...)
176+
H[1, 1] = 2 * y[1]
177+
H[2, 2] = 2 * y[2]
178+
return
179+
end
180+
return memoized_f, memoized_∇f, memoized_∇²f
181+
end
182+
183+
f, ∇f, ∇²f = memoized_solve_lower_level()
184+
185+
# The function above is a little confusing, but it returns three new functions
186+
# `f`, `∇f`, and `∇²f`, each of which call `_update_if_needed(x...)`. This
187+
# function only updates the cached values of `f` and `y` if the input `x` is
188+
# different to what is last saw.
189+
190+
model = Model(Ipopt.Optimizer)
191+
@variable(model, x[1:2] >= 0)
192+
register(model, :V, 2, f, ∇f, ∇²f)
193+
@NLobjective(model, Min, x[1]^2 + x[2]^2 + V(x[1], x[2]))
194+
optimize!(model)
195+
solution_summary(model)
196+
197+
# an we can check we get the same objective value:
198+
199+
objective_value(model)
200+
201+
# and upper-level decision variable ``x``:
202+
203+
value.(x)

0 commit comments

Comments
 (0)