Skip to content

Commit e8ff564

Browse files
Merge pull request #4017 from SebastianM-C/smc/dynopt
Add tune_parameters for dynamic optimization
2 parents ed73524 + 7d99618 commit e8ff564

File tree

7 files changed

+434
-81
lines changed

7 files changed

+434
-81
lines changed

Project.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ FunctionWrappers = "1.1"
123123
FunctionWrappersWrappers = "0.1"
124124
Graphs = "1.5.2"
125125
ImplicitDiscreteSolve = "0.1.2, 1"
126-
InfiniteOpt = "0.5"
126+
InfiniteOpt = "0.6"
127127
InteractiveUtils = "1"
128128
JuliaFormatter = "1.0.47, 2"
129129
JumpProcesses = "9.19"
@@ -152,7 +152,7 @@ RecursiveArrayTools = "3.26"
152152
Reexport = "0.2, 1"
153153
RuntimeGeneratedFunctions = "0.5.9"
154154
SCCNonlinearSolve = "1.4.0"
155-
SciMLBase = "2.115.0"
155+
SciMLBase = "2.125.0"
156156
SciMLPublic = "1.0.0"
157157
SciMLStructures = "1.7"
158158
Serialization = "1"

docs/src/tutorials/dynamic_optimization.md

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ The `tf` mapping in the parameter map is treated as an initial guess.
101101
Please note that, at the moment, free final time problems cannot support constraints defined at definite time values, like `x(3) ~ 2`.
102102

103103
!!! warning
104-
104+
105105
The Pyomo collocation methods (LagrangeRadau, LagrangeLegendre) currently are bugged for free final time problems. Strongly suggest using BackwardEuler() for such problems when using Pyomo as the backend.
106106

107107
When declaring the problem in this case we need to provide the number of steps, since dt can't be known in advanced. Let's solve plot our final solution and the controller for the block, using InfiniteOpt as the backend:
@@ -126,3 +126,90 @@ axislegend(ax1)
126126
axislegend(ax2)
127127
fig
128128
```
129+
130+
### Parameter estimation
131+
132+
The dynamic optimization framework can also be used for parameter estimation. In this approach, we treat unknown parameters as tunable variables and minimize the difference between model predictions and observed data.
133+
134+
Let's demonstrate this with the Lotka-Volterra equations. First, we'll generate some synthetic data by solving the system with known parameter values:
135+
136+
```@example dynamic_opt
137+
@parameters α = 1.5 β = 1.0 [tunable=false] γ = 3.0 δ = 1.0
138+
@variables x_pe(t) y_pe(t)
139+
140+
eqs_pe = [D(x_pe) ~ α * x_pe - β * x_pe * y_pe,
141+
D(y_pe) ~ -γ * y_pe + δ * x_pe * y_pe]
142+
143+
@mtkcompile sys0_pe = System(eqs_pe, t)
144+
tspan_pe = (0.0, 1.0)
145+
u0map_pe = [x_pe => 4.0, y_pe => 2.0]
146+
147+
# True parameter values (these are what we'll try to recover)
148+
parammap_pe = [α => 2.5, δ => 1.8]
149+
150+
oprob_pe = ODEProblem(sys0_pe, [u0map_pe; parammap_pe], tspan_pe)
151+
osol_pe = solve(oprob_pe, Tsit5())
152+
153+
# Generate synthetic data at 51 time points
154+
ts_pe = range(tspan_pe..., length=51)
155+
data_pe = osol_pe(ts_pe, idxs=x_pe).u
156+
```
157+
158+
Now we'll set up the parameter estimation problem. We use `EvalAt` to evaluate the state at specific time points and construct a least-squares cost function:
159+
160+
```@example dynamic_opt
161+
costs_pe = [abs2(EvalAt(ti)(x_pe) - data_pe[i]) for (i, ti) in enumerate(ts_pe)]
162+
163+
@mtkcompile sys_pe = System(eqs_pe, t; costs = costs_pe)
164+
```
165+
166+
By default the cost values are sumed up, if a different behaviour is desired, the `consolidate` keyword can be set in the `System` definition.
167+
168+
Next, we select which parameters to tune using `subset_tunables`. Here we'll estimate `α` and `δ` while keeping `β` and `γ` fixed:
169+
170+
```@example dynamic_opt
171+
sys_pe′ = subset_tunables(sys_pe, [α, δ])
172+
```
173+
174+
Now we can solve the parameter estimation problem. Note the `tune_parameters=true` flag:
175+
176+
```@example dynamic_opt
177+
iprob_pe = InfiniteOptDynamicOptProblem(sys_pe′, u0map_pe, tspan_pe; dt=1/50, tune_parameters=true)
178+
isol_pe = solve(iprob_pe, InfiniteOptCollocation(Ipopt.Optimizer, InfiniteOpt.OrthogonalCollocation(3)))
179+
180+
println("Estimated α = ", isol_pe.sol.ps[α], " (true value: 2.5)")
181+
println("Estimated δ = ", isol_pe.sol.ps[δ], " (true value: 1.8)")
182+
```
183+
184+
Let's visualize the fit:
185+
186+
```@example dynamic_opt
187+
fig = Figure(resolution = (800, 400))
188+
ax = Axis(fig[1, 1], title = "Parameter Estimation Results", xlabel = "Time", ylabel = "Prey Population")
189+
scatter!(ax, ts_pe, data_pe, label = "Data", markersize = 8)
190+
lines!(ax, isol_pe.sol.t, isol_pe.sol[x_pe], label = "Fitted Model", linewidth = 2)
191+
axislegend(ax)
192+
fig
193+
```
194+
195+
!!! note "Time Alignment for Cost Evaluation"
196+
When using `EvalAt` for parameter estimation, different backends handle the case when evaluation times don't align with collocation points differently:
197+
198+
- **JuMP**: Will throw an error asking you to adjust `dt` if evaluation times don't match collocation points exactly.
199+
- **CasADi**: Uses linear interpolation between collocation points for cost evaluations at intermediate times.
200+
- **InfiniteOpt**: Automatically adds support points for the evaluation times, handling mismatched grids gracefully.
201+
202+
For example, InfiniteOpt can use a different `dt` than what the data spacing requires:
203+
204+
```@example dynamic_opt
205+
# With InfiniteOpt, dt doesn't need to match the data points:
206+
iprob_pe2 = InfiniteOptDynamicOptProblem(sys_pe′, u0map_pe, tspan_pe,
207+
dt = 1/120, tune_parameters=true)
208+
isol_pe2 = solve(iprob_pe2, InfiniteOptCollocation(Ipopt.Optimizer,
209+
InfiniteOpt.OrthogonalCollocation(3)))
210+
211+
println("With dt=1/120: Estimated α = ", isol_pe2.sol.ps[α], " (true value: 2.5)")
212+
println("With dt=1/120: Estimated δ = ", isol_pe2.sol.ps[δ], " (true value: 1.8)")
213+
```
214+
215+
This flexibility makes InfiniteOpt particularly convenient for parameter estimation when your data points don't naturally align with a uniform collocation grid.

ext/MTKCasADiDynamicOptExt.jl

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,23 @@ function Base.getindex(m::MXLinearInterpolation, i...)
2121
length(i) == length(size(m.u)) ? m.u[i...] : m.u[i..., :]
2222
end
2323

24-
mutable struct CasADiModel
24+
mutable struct CasADiModel{T}
2525
model::Opti
2626
U::MXLinearInterpolation
2727
V::MXLinearInterpolation
28+
P::T
2829
tₛ::MX
2930
is_free_final::Bool
31+
tsteps::LinRange{Float64, Int}
3032
solver_opti::Union{Nothing, Opti}
3133

32-
function CasADiModel(opti, U, V, tₛ, is_free_final, solver_opti = nothing)
33-
new(opti, U, V, tₛ, is_free_final, solver_opti)
34+
function CasADiModel(opti, U, V, P, tₛ, is_free_final, tsteps, solver_opti = nothing)
35+
new{typeof(P)}(opti, U, V, P, tₛ, is_free_final, tsteps, solver_opti)
3436
end
3537
end
3638

3739
struct CasADiDynamicOptProblem{uType, tType, isinplace, P, F, K} <:
38-
AbstractDynamicOptProblem{uType, tType, isinplace}
40+
SciMLBase.AbstractDynamicOptProblem{uType, tType, isinplace}
3941
f::F
4042
u0::uType
4143
tspan::tType
@@ -66,10 +68,11 @@ end
6668
function MTK.CasADiDynamicOptProblem(sys::System, op, tspan;
6769
dt = nothing,
6870
steps = nothing,
71+
tune_parameters = false,
6972
guesses = Dict(), kwargs...)
7073
prob,
7174
_ = MTK.process_DynamicOptProblem(
72-
CasADiDynamicOptProblem, CasADiModel, sys, op, tspan; dt, steps, guesses, kwargs...)
75+
CasADiDynamicOptProblem, CasADiModel, sys, op, tspan; dt, steps, tune_parameters, guesses, kwargs...)
7376
prob
7477
end
7578

@@ -90,6 +93,14 @@ function MTK.generate_input_variable!(model::Opti, c0, nc, tsteps)
9093
MXLinearInterpolation(V, tsteps, tsteps[2] - tsteps[1])
9194
end
9295

96+
function MTK.generate_tunable_params!(model::Opti, p0, np)
97+
P = CasADi.variable!(model, np)
98+
for i in 1:np
99+
set_initial!(model, P[i], p0[i])
100+
end
101+
P
102+
end
103+
93104
function MTK.generate_timescale!(model::Opti, guess, is_free_t)
94105
if is_free_t
95106
tₛ = variable!(model)
@@ -140,14 +151,20 @@ function MTK.lowered_integral(model::CasADiModel, expr, lo, hi)
140151
model.tₛ * total
141152
end
142153

154+
MTK.needs_individual_tunables(::Opti) = true
155+
MTK.get_param_for_pmap(::Opti, P, i) = P[i]
156+
143157
function add_solve_constraints!(prob::CasADiDynamicOptProblem, tableau)
144158
@unpack A, α, c = tableau
145159
@unpack wrapped_model, f, p = prob
146-
@unpack model, U, V, tₛ = wrapped_model
160+
@unpack model, U, V, P, tₛ = wrapped_model
147161
solver_opti = copy(model)
148162

149163
tsteps = U.t
150-
dt = tsteps[2] - tsteps[1]
164+
dt = (tsteps[end] - tsteps[1]) / (length(tsteps) - 1)
165+
166+
# CasADi uses linear interpolation for cost evaluations that are not on the collocation points
167+
@assert tsteps == wrapped_model.tsteps "Collocation points mismatch."
151168

152169
nᵤ = size(U.u, 1)
153170
nᵥ = size(V.u, 1)
@@ -160,7 +177,7 @@ function add_solve_constraints!(prob::CasADiDynamicOptProblem, tableau)
160177
ΔU = sum([A[i, j] * K[j] for j in 1:(i - 1)], init = MX(zeros(nᵤ)))
161178
Uₙ = U.u[:, k] + ΔU * dt
162179
Vₙ = V.u[:, k]
163-
Kₙ = tₛ * f(Uₙ, Vₙ, p, τ + h * dt) # scale the time
180+
Kₙ = tₛ * MTK.f_wrapper(f, Uₙ, Vₙ, p, P, τ + h * dt) # scale the time
164181
push!(K, Kₙ)
165182
end
166183
ΔU = dt * sum([α[i] * K[i] for i in 1:length(α)])
@@ -176,7 +193,7 @@ function add_solve_constraints!(prob::CasADiDynamicOptProblem, tableau)
176193
ΔU = ΔUs[i, :]'
177194
Uₙ = U.u[:, k] + ΔU * dt
178195
Vₙ = V.u[:, k]
179-
subject_to!(solver_opti, Kᵢ[:, i] == tₛ * f(Uₙ, Vₙ, p, τ + h * dt))
196+
subject_to!(solver_opti, Kᵢ[:, i] == tₛ * MTK.f_wrapper(f, Uₙ, Vₙ, p, P, τ + h * dt))
180197
end
181198
ΔU_tot = dt * (Kᵢ * α)
182199
subject_to!(solver_opti, U.u[:, k] + ΔU_tot == U.u[:, k + 1])
@@ -228,6 +245,11 @@ function MTK.get_V_values(model::CasADiModel)
228245
end
229246
end
230247

248+
function MTK.get_P_values(model::CasADiModel)
249+
value_getter = MTK.successful_solve(model) ? CasADi.debug_value : CasADi.value
250+
[value_getter(model.solver_opti, model.P[i]) for i in eachindex(model.P)]
251+
end
252+
231253
function MTK.get_t_values(model::CasADiModel)
232254
value_getter = MTK.successful_solve(model) ? CasADi.debug_value : CasADi.value
233255
ts = value_getter(model.solver_opti, model.tₛ) .* model.U.t

ext/MTKInfiniteOptExt.jl

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ module MTKInfiniteOptExt
22
using ModelingToolkit
33
using InfiniteOpt
44
using DiffEqBase
5+
using SciMLStructures
56
using LinearAlgebra
67
using StaticArrays
78
using UnPack
@@ -13,12 +14,14 @@ struct InfiniteOptModel
1314
model::InfiniteModel
1415
U::Vector{<:AbstractVariableRef}
1516
V::Vector{<:AbstractVariableRef}
17+
P::Vector{<:AbstractVariableRef}
1618
tₛ::AbstractVariableRef
1719
is_free_final::Bool
20+
tsteps::LinRange{Float64, Int}
1821
end
1922

2023
struct JuMPDynamicOptProblem{uType, tType, isinplace, P, F, K} <:
21-
AbstractDynamicOptProblem{uType, tType, isinplace}
24+
SciMLBase.AbstractDynamicOptProblem{uType, tType, isinplace}
2225
f::F
2326
u0::uType
2427
tspan::tType
@@ -33,7 +36,7 @@ struct JuMPDynamicOptProblem{uType, tType, isinplace, P, F, K} <:
3336
end
3437

3538
struct InfiniteOptDynamicOptProblem{uType, tType, isinplace, P, F, K} <:
36-
AbstractDynamicOptProblem{uType, tType, isinplace}
39+
SciMLBase.AbstractDynamicOptProblem{uType, tType, isinplace}
3740
f::F
3841
u0::uType
3942
tspan::tType
@@ -57,6 +60,9 @@ end
5760
function MTK.generate_input_variable!(m::InfiniteModel, c0, nc, ts)
5861
@variable(m, V[i = 1:nc], Infinite(m[:t]), start=c0[i])
5962
end
63+
function MTK.generate_tunable_params!(m::InfiniteModel, p0, np)
64+
@variable(m, P[i=1:np], start=p0[i])
65+
end
6066

6167
function MTK.generate_timescale!(m::InfiniteModel, guess, is_free_t)
6268
@variable(m, tₛ 0, start = guess)
@@ -81,20 +87,22 @@ MTK.set_objective!(m::InfiniteOptModel, expr) = @objective(m.model, Min, expr)
8187
function MTK.JuMPDynamicOptProblem(sys::System, op, tspan;
8288
dt = nothing,
8389
steps = nothing,
90+
tune_parameters = false,
8491
guesses = Dict(), kwargs...)
8592
prob,
8693
_ = MTK.process_DynamicOptProblem(JuMPDynamicOptProblem, InfiniteOptModel, sys,
87-
op, tspan; dt, steps, guesses, kwargs...)
94+
op, tspan; dt, steps, tune_parameters, guesses, kwargs...)
8895
prob
8996
end
9097

9198
function MTK.InfiniteOptDynamicOptProblem(sys::System, op, tspan;
9299
dt = nothing,
93100
steps = nothing,
101+
tune_parameters = false,
94102
guesses = Dict(), kwargs...)
95103
prob,
96104
pmap = MTK.process_DynamicOptProblem(InfiniteOptDynamicOptProblem, InfiniteOptModel,
97-
sys, op, tspan; dt, steps, guesses, kwargs...)
105+
sys, op, tspan; dt, steps, tune_parameters, guesses, kwargs...)
98106
MTK.add_equational_constraints!(prob.wrapped_model, sys, pmap, tspan)
99107
prob
100108
end
@@ -128,10 +136,15 @@ end
128136
function add_solve_constraints!(prob::JuMPDynamicOptProblem, tableau)
129137
@unpack A, α, c = tableau
130138
@unpack wrapped_model, f, p = prob
131-
@unpack tₛ, U, V, model = wrapped_model
139+
@unpack tₛ, U, V, P, model = wrapped_model
132140
t = model[:t]
133141
tsteps = supports(t)
134-
dt = tsteps[2] - tsteps[1]
142+
143+
# InfiniteOpt can introduce additional collocation points
144+
# Make sure that the collocation points are correct.
145+
MTK.check_collocation_time_mismatch(prob.f.sys, wrapped_model.tsteps, tsteps)
146+
147+
dt = (tsteps[end] - tsteps[1]) / (length(tsteps) - 1)
135148

136149
nᵤ = length(U)
137150
nᵥ = length(V)
@@ -142,7 +155,7 @@ function add_solve_constraints!(prob::JuMPDynamicOptProblem, tableau)
142155
ΔU = sum([A[i, j] * K[j] for j in 1:(i - 1)], init = zeros(nᵤ))
143156
Uₙ = [U[i](τ) + ΔU[i] * dt for i in 1:nᵤ]
144157
Vₙ = [V[i](τ) for i in 1:nᵥ]
145-
Kₙ = tₛ * f(Uₙ, Vₙ, p, τ + h * dt)
158+
Kₙ = tₛ * MTK.f_wrapper(f, Uₙ, Vₙ, p, P, τ + h * dt)
146159
push!(K, Kₙ)
147160
end
148161
ΔU = dt * sum([α[i] * K[i] for i in 1:length(α)])
@@ -158,12 +171,12 @@ function add_solve_constraints!(prob::JuMPDynamicOptProblem, tableau)
158171
for (i, h) in enumerate(c)
159172
ΔU = @view ΔUs[i, :]
160173
Uₙ = U + ΔU * dt
161-
@constraint(model, [j = 1:nᵤ], K[i, j]==(tₛ * f(Uₙ, V, p, τ + h * dt)[j]),
162-
DomainRestrictions(t => τ), base_name="solve_K$i()")
174+
@constraint(model, [j = 1:nᵤ], K[i, j]==(tₛ * MTK.f_wrapper(f, Uₙ, V, p, P, τ + h * dt)[j]),
175+
DomainRestriction(==(τ), t), base_name="solve_K$i()")
163176
end
164177
@constraint(model,
165178
[n = 1:nᵤ], U[n](τ) + ΔU_tot[n]==U[n](min+ dt, tsteps[end])),
166-
DomainRestrictions(t => τ), base_name="solve_U()")
179+
DomainRestriction(==(τ), t), base_name="solve_U()")
167180
end
168181
end
169182
end
@@ -233,6 +246,7 @@ function MTK.get_U_values(m::InfiniteModel)
233246
U_vals = value.(m[:U])
234247
U_vals = [[U_vals[i][j] for i in 1:length(U_vals)] for j in 1:nt]
235248
end
249+
MTK.get_P_values(m::InfiniteModel) = value(m[:P])
236250
MTK.get_t_values(m::InfiniteModel) = value(m[:tₛ]) * supports(m[:t])
237251
MTK.objective_value(m::InfiniteModel) = InfiniteOpt.objective_value(m)
238252

@@ -257,11 +271,11 @@ end
257271

258272
# JuMP variables and Symbolics variables never compare equal. When tracing through dynamics, a function argument can be either a JuMP variable or A Symbolics variable, it can never be both.
259273
function Base.isequal(::SymbolicUtils.Symbolic,
260-
::Union{JuMP.GenericAffExpr, JuMP.GenericQuadExpr, InfiniteOpt.AbstractInfOptExpr})
274+
::Union{JuMP.GenericAffExpr, JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr})
261275
false
262276
end
263277
function Base.isequal(
264-
::Union{JuMP.GenericAffExpr, JuMP.GenericQuadExpr, InfiniteOpt.AbstractInfOptExpr},
278+
::Union{JuMP.GenericAffExpr, JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr},
265279
::SymbolicUtils.Symbolic)
266280
false
267281
end

0 commit comments

Comments
 (0)