Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updating parameter/variable values does not update values of dependant defaults #2733

Closed
TorkelE opened this issue May 23, 2024 · 62 comments
Closed
Assignees
Labels
bug Something isn't working

Comments

@TorkelE
Copy link
Member

TorkelE commented May 23, 2024

I thought this one was reported, but didn't actually find it anywhere. This is both a silent error (not just a crash), and something that we have Catalyst users that report errors with, so would be good to have a fix asap so that we can finally transition Catalyst to the MTK v9.

using ModelingToolkit
@variables t X(t)
@parameters p d = X
D = Differential(t)

eqs = [D(X) ~ p - d*X]
@mtkbuild osys = ODESystem(eqs, t)

u0 = [X => 1.0,]
ps = [p => 1.0]
oprob = ODEProblem(osys, u0, 1.0, ps)

oprob.ps[:d] # 1.0
oprob[:X] # 1.0
oprob[:X] = 2.0
oprob.ps[:d] # 1.0, should be 2.0
@TorkelE TorkelE added the bug Something isn't working label May 23, 2024
@ChrisRackauckas
Copy link
Member

It wasn't listed as a dependent parameter, just has a default value

@TorkelE
Copy link
Member Author

TorkelE commented May 23, 2024

I am not sure I follow? Here, d's value is set to, by default, be whatever value Xhas. If the value of X is updated so should the value ofd`. I think this used to work before, but seems to be broken in the MTK v9 transition

@SebastianM-C
Copy link
Contributor

The thing is that the default values for parameters encode that they should only initially be that and not that it's a relationship that needs to be enforced. To enforce the relationship one needs to use the parameter dependencies keyword argument as @ChrisRackauckas pointed out.

This might be a bit confusing since for variables the defaults refer to an actual constraint and remake seems to treat defaults as enforcing equations, but setp does the correct thing (IMO) and does not update parameters based on defaults.

@TorkelE
Copy link
Member Author

TorkelE commented May 24, 2024

Up until now the default value are the value of species/parameters at the beginning of the simulation (after which they can change). The the above example one would expect the initial value of d at the beginning to be the same as X, whatever X is. However, I think we are both in agreement that d should not change throughout a simulation as X changes, I agree that that is not what default are. Generally, there should be no difference in the behaviours of remake and setp/setu.

Parameter dependencies is, if I understand it right, a different thing. However, there doesn't seem to be any documentation of it?

@hersle
Copy link
Contributor

hersle commented May 24, 2024

I would expect

prob = remake(oprob; u0 = [X => 2.0], use_defaults = true)
@assert prob.ps[d] == 2.0

to do what the original example tries to do. I'm not on my computer to check if it works, though. IIRC, the behavior of remake here could depend on whether p is passed to it.

@AayushSabharwal
Copy link
Member

AayushSabharwal commented May 27, 2024

A clarification on terminology: parameter dependencies and dependent defaults are two different things.

Dependent defaults refers to the default value of a variable depending on another variable (this can be an unknown or a parameter, or even an expression involving a mixture of the two). Note that giving X a default value of p, and updating p won't update X in the ODEProblem.

Parameter dependencies can be thought of as observed for parameters. The relationships defined here are maintained throughout the simulation. For example, if [p1 => 2p2] is a parameter dependency and p2 is modified in a callback, p1 will also be updated accordingly. Right now, dependent parameters (p1) are stored in the MTKParameters object. However, there is a future plan to treat these the same way we do observed: specify p1 ~ 2p2 as an equation, and compute p1 on the fly as necessary.

However, I can see the inconsistency here, where defaults for unknowns are considered when creating an initialization system for the ODEProblem, but ones for parameters aren't. This ties directly in to #2665, which requires allowing parameters in the ODESystem to be unknowns in the initialization system. Note that while the above issue is something I'm working on, this will mean that oprob[X] = 2.0 won't update oprob.ps[d] directly, this update would instead be performed during initialization of the integrator (init(oprob, Tsit5())).

@AayushSabharwal
Copy link
Member

to do what the original example tries to do. I'm not on my computer to check if it works, though. IIRC, the behavior of remake here could depend on whether p is passed to it.

Correct, you need to pass p = Dict() here to update the parameters.

@TorkelE
Copy link
Member Author

TorkelE commented May 27, 2024

I still think there is an issue here that can and should be solved before the creation of the integrator and simulation system initialization. I.e.

@variables t X(t)
@parameters p d = X

...

oprob.ps[:d] # 1.0
oprob[:X] # 1.0
oprob[:X] = 2.0
oprob.ps[:d] # This should yield `2.0`

Everything else is going to be greatly confusing to users, and seems like asking for more problems down the line (and also cause problems for other packages, like DynamicalSystems, which have some functionality to use ODEProblems). I.e. whatever happens to set the values when ODEProblem is called should probably happen when values are updated. Then it might be useful to have some kind of flag/metadata in ODEProblem to inform setp that there are some default dependencies going on.

@AayushSabharwal
Copy link
Member

The thing is in general oprob can't represent the true initialization. It already doesn't do it for unknowns, where the variables that are solved for in the initialization problem are just set to 0 in the ODEProblem. Parameters would probably proceed similarly. d = X is a simple default initialization, but in general this may not be invertible. What if the user changes d? The initialization problem would solve for X, but this wouldn't be reflected at the ODEProblem level.

@TorkelE
Copy link
Member Author

TorkelE commented May 27, 2024

If the user changes d then that is its new value (since it now has a value, the default is no longer used). Initialization problems never really were a thing until the latest update, so I am not really sure why their introduction should pose a problem to default values?

@AayushSabharwal
Copy link
Member

Consider an example where f(x, y) = 0 is an algebraic equation and both x and y are unknowns. If a user specifies a guess for x, and passes y => 42.0 to the ODEProblem constructor, x will be initialized to 0 in the ODEProblem. This does not mean the initial value of x is 0. It just means that when the integrator is initialized, it will solve a nonlinear system to calculate x, using the algebraic equation. Modifying x in the ODEProblem won't be reflected in the integrator.

If I now specify a default for x ~ 2y + z, this is now an additional equation in the nonlinear problem which must be satisfied. In short, defaults affect initialization.

#2665 raises a valid concern where parameters can also be unknowns in the initialization problem. Thus, they will effectively get the same treatment. If @parameters d = f(X) is a default value, it will be an equation in the initialization system, and will always be satisfied. Thus while updating X will change d (and this may be trivial) but updating d will also update X when the integrator is initialized (and this is not trivial to do at the ODEProblem level).

@TorkelE
Copy link
Member Author

TorkelE commented May 27, 2024

Right, so I agree that defaults will have effects down the line. However, this was a thing before we started working with initialization and focus on DAEs, I don't see what the introduction of this means that we should throw away old features that people have been utilising.

Generally, it is not that defaults permits you to suddenly do something entirely different, e.g.

using ModelingToolkit
@variables t X(t)
@parameters p d = X
D = Differential(t)

eqs = [D(X) ~ p - d*X]
@mtkbuild osys = ODESystem(eqs, t)

u0 = [X => 1.0,]
ps = [p => 1.0]
oprob = ODEProblem(osys, u0, 1.0, ps)
oprob[:X] = 2.0

should just be the same as setting X => 2.0 in the first place:

using ModelingToolkit
@variables t X(t)
@parameters p d = X
D = Differential(t)

eqs = [D(X) ~ p - d*X]
@mtkbuild osys = ODESystem(eqs, t)

u0 = [X => 2.0,]
ps = [p => 1.0]
oprob = ODEProblem(osys, u0, 1.0, ps)

which is hardly controversial.

@AayushSabharwal
Copy link
Member

I don't see what the introduction of this means that we should throw away old features that people have been utilising.

Did this work before? Updating X and d automatically changing?

@TorkelE
Copy link
Member Author

TorkelE commented May 27, 2024

Well, it was the intended behaviour, there were bugs going around before v9 as well. In this case, we only had the bug being reported this spring.

@isaacsas
Copy link
Member

isaacsas commented Jun 4, 2024

@ChrisRackauckas can we get a decision on whether this should be supported? This is currently a blocker on making a Catalyst 14 release with MTK9 support and there seems to be a lot of back and forth in this thread.

Right now, and in MTK8 and earlier, it was supported to declare a parameter as a function of unknowns/variables via defaults and have this implicitly mean the parameter was defined in terms of the initial values of the unknowns. It is true that it appears remake never worked with this, silently being bugged and not updating such parameters in the past, but otherwise this feature worked and was used by Catalyst. Currently, such parameter definitions are still supported and work except in remake type uses as described above. But it is inconsistent to support defining parameters in such a way except for updating. If this is not meant to be allowed then it should really explicitly error during system construction when such a parameter is detected.

If defining, and consistently updating, parameters that are given by functions of the initial values of unknowns is no longer supported we need to deprecate conservation law elimination in Catalyst 14 and let Catalyst users know it will no longer be supported. Otherwise we are stuck on MTK8 until this is resolved.

@isaacsas
Copy link
Member

isaacsas commented Jun 4, 2024

Just to add, if this is something that is supposed to work with parameter_dependencies we are happy to switch to using that to define conservation constants instead of using defaults, but it isn't clear to me if this is considered supported in that context either from the preceding discussion.

@isaacsas
Copy link
Member

isaacsas commented Jun 4, 2024

And modifying @TorkelE's example to use parameter_dependencies seems to error suggesting there is no support for using initial values of unknowns in defining a dependent parameter. Or is there some other way this should be done now?

@mtkbuild osys = ODESystem(eqs, t; parameter_dependencies=Dict(d => 2*X))
oprob = ODEProblem(osys, u0, 1.0, ps)

gives

ERROR: UndefVarError: `X` not defined in `ModelingToolkit`
Suggestion: check for spelling errors or missing imports.
Stacktrace:
  [1] macro expansion
    @ ~/.julia/packages/SymbolicUtils/qyMYa/src/code.jl:418 [inlined]
  [2] macro expansion
    @ ~/.julia/packages/Symbolics/Eas9m/src/build_function.jl:546 [inlined]
  [3] macro expansion
    @ ~/.julia/packages/SymbolicUtils/qyMYa/src/code.jl:375 [inlined]
  [4] macro expansion
    @ ~/.julia/packages/RuntimeGeneratedFunctions/M9ZX8/src/RuntimeGeneratedFunctions.jl:163 [inlined]
  [5] macro expansion
    @ ./none:0 [inlined]
  [6] generated_callfunc
    @ ./none:0 [inlined]
  [7] (::RuntimeGeneratedFunctions.RuntimeGeneratedFunction{…})(::RecursiveArrayTools.ArrayPartition{…}, ::Vector{…}, ::Vector{…})
    @ RuntimeGeneratedFunctions ~/.julia/packages/RuntimeGeneratedFunctions/M9ZX8/src/RuntimeGeneratedFunctions.jl:150
  [8] ModelingToolkit.MTKParameters(sys::ODESystem, p::Dict{…}, u0::Vector{…}; tofloat::Bool, use_union::Bool)
    @ ModelingToolkit ~/.julia/packages/ModelingToolkit/cAhZr/src/systems/parameter_buffer.jl:152
  [9] ModelingToolkit.MTKParameters(sys::ODESystem, p::Dict{SymbolicUtils.BasicSymbolic{…}, Float64}, u0::Vector{Pair{…}})
    @ ModelingToolkit ~/.julia/packages/ModelingToolkit/cAhZr/src/systems/parameter_buffer.jl:13
 [10] process_DEProblem(constructor::Type, sys::ODESystem, u0map::Vector{…}, parammap::Vector{…}; implicit_dae::Bool, du0map::Nothing, version::Nothing, tgrad::Bool, jac::Bool, checkbounds::Bool, sparse::Bool, simplify::Bool, linenumbers::Bool, parallel::Symbolics.SerialForm, eval_expression::Bool, use_union::Bool, tofloat::Bool, symbolic_u0::Bool, u0_constructor::typeof(identity), guesses::Dict{…}, t::Float64, warn_initialize_determined::Bool, build_initializeprob::Bool, kwargs::@Kwargs{…})
    @ ModelingToolkit ~/.julia/packages/ModelingToolkit/cAhZr/src/systems/diffeqs/abstractodesystem.jl:943
 [11] process_DEProblem
    @ ~/.julia/packages/ModelingToolkit/cAhZr/src/systems/diffeqs/abstractodesystem.jl:835 [inlined]
 [12] (ODEProblem{…})(sys::ODESystem, u0map::Vector{…}, tspan::Float64, parammap::Vector{…}; callback::Nothing, check_length::Bool, warn_initialize_determined::Bool, kwargs::@Kwargs{})
    @ ModelingToolkit ~/.julia/packages/ModelingToolkit/cAhZr/src/systems/diffeqs/abstractodesystem.jl:1086
 [13] (ODEProblem{…})(sys::ODESystem, u0map::Vector{…}, tspan::Float64, parammap::Vector{…})
    @ ModelingToolkit ~/.julia/packages/ModelingToolkit/cAhZr/src/systems/diffeqs/abstractodesystem.jl:1076
 [14] (ODEProblem{true})(::ODESystem, ::Vector{Pair{Num, Float64}}, ::Vararg{Any}; kwargs::@Kwargs{})
    @ ModelingToolkit ~/.julia/packages/ModelingToolkit/cAhZr/src/systems/diffeqs/abstractodesystem.jl:1063
 [15] (ODEProblem{true})(::ODESystem, ::Vector{Pair{Num, Float64}}, ::Vararg{Any})
    @ ModelingToolkit ~/.julia/packages/ModelingToolkit/cAhZr/src/systems/diffeqs/abstractodesystem.jl:1062
 [16] ODEProblem(::ODESystem, ::Vector{Pair{Num, Float64}}, ::Vararg{Any}; kwargs::@Kwargs{})
    @ ModelingToolkit ~/.julia/packages/ModelingToolkit/cAhZr/src/systems/diffeqs/abstractodesystem.jl:1052
 [17] ODEProblem(::ODESystem, ::Vector{Pair{Num, Float64}}, ::Vararg{Any})
    @ ModelingToolkit ~/.julia/packages/ModelingToolkit/cAhZr/src/systems/diffeqs/abstractodesystem.jl:1051
 [18] top-level scope
    @ REPL[32]:1
Some type information was truncated. Use `show(err)` to see complete types.

@ChrisRackauckas
Copy link
Member

It should be parameter_dependencies yes. But it only handles parameters right now.

@AayushSabharwal
Copy link
Member

It should be parameter_dependencies yes. But it only handles parameters right now.

No, it shouldn't be parameter dependencies

The way to think about this is, parameter_dependencies are equations that are part of the ODESystem involving only parameters (and the lhs is a single parameter). They are like observed equations. If I have @variables x(t) y(t) and have an equation y ~ 2 * x, y is not part of the state but is calculated on the fly using the observed equation. Similarly, if p1 => 2 * p2 is a parameter dependency, p1 is not part of the parameters but is calculated on the fly using observed equations.

Defaults such as @parameters d = X are equations used only during initialization (not yet merged, but implemented in a few PRs). This is identical behavior to @variables X(t) = Y(t). If an explicit value for d is provided to ODEProblem, it will keep that value. If no value is provided, d is an unknown in the initialization system, d ~ X is an equation, and d will be solved for during the initialization stage, which happens when the integrator is created (init(prob, Alg()) is called).

@TorkelE
Copy link
Member Author

TorkelE commented Jun 4, 2024

But for this case we should be good, right? Let me explain our application

Conserved quantities in chemical reaction networks

We have a reaction system with a single thing (X) which can exist in two states (X1 and X2). Basically we have these two reactions:

k1, X1 --> X2
k2, X2 --> X1

This creates an ODE with two equations

dX1/dt = -k1*X1 + k2*X2
dX2/dt = k1*X1 - k2*X2

However, we then make the observation that the quantity X1 + X2 is conserved, i.e. does not change throughout the simulation. If we then create a parameter, Γ, to represent this conserved quantity we get an equation Γ = X1 + X2. From this we can eliminate one variable (and thus ODE) from our system, creating:

dX1/dt = -k1*X1 + k2*(Γ - X1)

and representing the species X2 as an observable

X2 = Γ - X1

(this is a bit similar to what structural_simplify does, however, chemical reaction networks have specialised structures which we can utilise to do this more efficiently)

Computing conserved quantities using defaults

Now the only question is, how do we compute Γ? Well, it is a constant Γ = X1 + X2, and can hence be computed from the system's initial condition. I.e. under the hood, we declare the parameter Γ

@parameters Γ = X1 + X2

Since X1 + X2 is changed throughout the simulation, we can _compute it from the initial condition. I.e., we do not want to have this a dynamic equation throughout the system. We just want to make sure that, when the simulation is initialised, the value of Γ is computed from the initial condition.

The problem

So the problem is that, right now, the value of parameters that have initial conditions are set when a ODEProblem is declared, but never updated when setu or remake is called. That means that they have the wrong value when you create an integrator from the ODEProblem. Basically, what is needed, is that when setu or remake is called, the value of unknowns/parameters that depend on the updated thing must also be updated.

@TorkelE
Copy link
Member Author

TorkelE commented Jun 4, 2024

Basically, the problem we see here is the same as we see when we have a parametric initial condition:

using ModelingToolkit
@parameters p d X0
@variables t X(t)=X0
D = Differential(t)

eqs = [D(X) ~ p - d*X]
@mtkbuild osys = ODESystem(eqs, t)

u0 = []
ps = [p => 1.0, d=> 1.0, X0 => 2.0]
oprob = ODEProblem(osys, u0, 1.0, ps)

oprob.ps[X0] # 2.0
oprob[X] # 2.0
oprob.ps[X0] = 3.0
oprob[X] # 2.0, but should be 3.0

While the initial example I gave her might be a bit non-intuitive, I think it should be clear why the behaviour in this example is un-desired.

@hersle
Copy link
Contributor

hersle commented Jun 4, 2024

Maybe I am missing something, but is there a common need for parameter_dependencies? Is not its use case already covered entirely by defaults? A system consists of a set of variables and parameters, so there are 2 x 2 = 4 possible "types" of "defaults assignments" that could be made between them during initialization. Let us try to cover all of them, both with creating problems with ODEProblem and updating them with remake:

using Test
using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
using DifferentialEquations

@testset begin "all 2x2 combinations of variable <-> parameter defaults"

vars = @variables x1(t) x2(t)
pars = @parameters p3 p4
eqs = [D(x1) ~ 0, D(x2) ~ 0]

@named sys1 = ODESystem(eqs, t, vars, pars; defaults = [x1 => x2*1/2]) # var(var) default
@named sys2 = ODESystem(eqs, t, vars, pars; defaults = [x2 => p3*2/3]) # var(par) default
@named sys3 = ODESystem(eqs, t, vars, pars; defaults = [p3 => x2*3/2]) # par(var) default
@named sys4 = ODESystem(eqs, t, vars, pars; defaults = [p4 => p3*4/3]) # par(par) default
sys1, sys3, sys2, sys4 = complete.([sys1, sys3, sys2, sys4]) # complete all systems

tspan = (0.0, 1.0)

# first, create and initialize all problems
prob1 = ODEProblem(sys1, [           x2 => 2.0], tspan, [p3 => 3.0, p4 => 4.0]) # calculate missing x1 from defaults, works
prob2 = ODEProblem(sys2, [x1 => 1.0,          ], tspan, [p3 => 3.0, p4 => 4.0]) # calculate missing x2 from defaults, works
prob3 = ODEProblem(sys3, [x1 => 1.0, x2 => 2.0], tspan, [           p4 => 4.0]) # calculate missing p3 from defaults, works
prob4 = ODEProblem(sys4, [x1 => 1.0, x2 => 2.0], tspan, [p3 => 3.0           ]) # calculate missing p4 from defaults, works

for prob in [prob1, prob2, prob3, prob4]
    @test prob[x1] == 1.0 && prob[x2] == 2.0 && prob.ps[p3] == 3.0 && prob.ps[p4] == 4.0
end


# second, update the variables and parameters by switching their signs
prob1 = remake(prob1, u0 = [            x2 => -2.0], p = [p3 => -3.0, p4 => -4.0], use_defaults = true) # works
prob2 = remake(prob2, u0 = [x1 => -1.0,           ], p = [p3 => -3.0, p4 => -4.0], use_defaults = true) # fails
prob3 = remake(prob3, u0 = [x1 => -1.0, x2 => -2.0], p = [            p4 => -4.0], use_defaults = true) # works
prob4 = remake(prob4, u0 = [x1 => -1.0, x2 => -2.0], p = [p3 => -3.0            ], use_defaults = true) # works

for prob in [prob1, prob2, prob3, prob4]
    @test prob[x1] == -1.0 && prob[x2] == -2.0 && prob.ps[p3] == -3.0 && prob.ps[p4] == -4.0
end

end

This all works for me on master, except the line with remake(prob2, ...), as reported in #2715. The use of parameter_dependencies sounds fully equivalent to defaults in case 4 to me, and I only find it confusing to introduce a special term for this.

EDIT: @TorkelE's example just above is also equivalent to the case remake(prob2, ...). Sorry, I happened to post simultaneously and did not mean to "bury" your comment 😅

@hersle
Copy link
Contributor

hersle commented Jun 4, 2024

Regarding @TorkelE's example with

oprob.ps[X0] = 3.0 # with default X = X0, should now oprob[X] == 3.0?

vs.

oprob = remake(oprob; p = [X0 => 3.0], u0 = Dict(), use_defaults = true) # with default X = X0, now oprob[X] == 3.0

As a user, I would not expect oprob[X] == 3.0 after the first method. What if the system is huge and has many defaults? For performance reasons, I think it is somewhat unfair to expect the library to reinitialize the system and recalculate all dependent defaults for every single line in

oprob.ps[X0] = 3.0
oprob.ps[Y0] = 4.0
oprob.ps[Z0] = 5.0
# perhaps many more in a bigger system ...

Instead, I think the second method with one call to remake communicates very clearly that it will reinitialize the problem once in a performant way. In other words, I would argue that you are "on your own" when modifying oprob.ps or oprob.u0 of a problem with dependent defaults.

@TorkelE
Copy link
Member Author

TorkelE commented Jun 4, 2024

If there are performance involved, I think @hersle makes a good point. To keep setu and setp performant that might be required.

Generally, in Catalyst, I think we recommend that remake is used for all modifications to systems, and then describe setp and setu to the user as something for situations where performance is important.

@AayushSabharwal
Copy link
Member

AayushSabharwal commented Jun 5, 2024

@TorkelE

The problem
So the problem is that, right now, the value of parameters that have initial conditions are set when a ODEProblem is declared, but never updated when setu or remake is called. That means that they have the wrong value when you create an integrator from the ODEProblem. Basically, what is needed, is that when setu or remake is called, the value of unknowns/parameters that depend on the updated thing must also be updated.

So there is no problem? If you have a default for Gamma = X1 + X2, it will be used as an equation during initialization. If the user specifies X1 and X2 to the ODEProblem during creation, Gamma will default to X1 + X2 and be updated to X1 + X2 during initialization, in case either of X1 or X2 change. If Gamma changes, I'm not entirely sure what will happen.

While the initial example I gave her might be a bit non-intuitive, I think it should be clear why the behaviour in this example is un-desired.

I can see why this is undesirable, but it's undesirable specifically because of the semantics Catalyst wants to enforce. Your concern is that the values in the ODEProblem don't reflect the constraints enforced, but the thing is they're not meant to and never have. These algebraic constraints are enforced during initialization. The ODEProblem just contains what the user knows about the initial state, and how the initialization should update said state to be consistent with the aforementioned algebraic constraints. Updating when a value changes based on defaults is

  1. slow
  • Imagine a large system, with many such dependent defaults. Every single time you modify any part of the ODEProblem, all the dependent defaults would have to be recalculated. This is simply too slow.
  1. Not technically correct
  • What if I change Gamma in the example above? What if there are more complicated constraints that can't be consistently enforced by simple substitution? What if changing a variable violates algebraic constraints?

As long as you have the default Gamma = X1 + X2, it will be enforced (to the best of the solver's ability, in case of underdetermined or overdetermined initialization) when the problem is initialized.

@AayushSabharwal
Copy link
Member

@hersle

but is there a common need for parameter_dependencies?

Yes. Parameters can be changed during callbacks. parameter_dependencies updates the dependent parameters when this happens. It's also less prone to errors, since defaults don't actually enforce a dependency, they just assign a default value during initialization which can be overriden. With a default of p2 => 2p1 both p1 and p2 are true parameters, and can be changed independently. If I modify p2, it may not equal p1. Parameter dependencies specifically disallow this behavior. If p2 => 2p1 is a parameter dependency, if I run prob.ps[p1] = 2.0 then prob.ps[p2] will be 4.0.

This all works for me on master, except the line with remake(prob2, ...), as reported in #2715

That is a bug, and something I intend to fix eventually. remake should respect these defaults if asked to.

@hersle
Copy link
Contributor

hersle commented Jun 5, 2024

Thank you! I did not think of that use, and this clears up all my confusion. For reference, it sounds to me like the TL;DR of this thread is:

  • To enforce relationships between variables and/or parameters during initialization, use defaults.
  • To enforce relationships between parameters at all times (e.g. when one is modified in a callback), use parameter_dependencies.
  • To update a problem with new initial conditions or parameters that respect defaults, use remake(prob; u0 = [x1 => 1.0, ...], p = [p1 => 1.0, ...], use_defaults = true).
  • Defaults are not respected when modifying e.g. prob.ps[p1] = 1.0. Doing so would generally be too slow and not flexible enough to support more complicated constraints.
  • Currently, remake does not properly handle defaults where variables depend on parameters. This is a bug. See examples in this thread or Remake fails with mixed variable and parameter defaults #2715 .

@AayushSabharwal
Copy link
Member

Yeah. Also an additional point - the relationships during initialization won't be fully respected until #2665 is closed. Specifically, relationships that require parameters to be changed won't work.

@AayushSabharwal
Copy link
Member

Can this be closed now that parameter initialization is in?

@isaacsas
Copy link
Member

isaacsas commented Oct 24, 2024

This doesn't seem to have been fixed though? On 9.47

using Catalyst, SciMLBase
rn = @reaction_network begin
       (k1,k2), A <--> B
       end
osys = convert(ODESystem, rn; remove_conserved = true)
osys = complete(osys)

Which gives a system with

julia> equations(osys)
1-element Vector{Equation}:
 Differential(t)(A(t)) ~ -k1*A(t) + k2*(-A(t) + Γ[1])

and

Unknowns (1):
  A(t)
Parameters (3):
  k1
  k2
  Γ[1] [defaults to B(t) + A(t)]

remake still doesn't correctly update gamma though:

oprob = ODEProblem(osys, [osys.A => 1.0, osys.B => 1.0], (0.0, 1.0), [osys.k1 => 2.0, osys.k2 => 3.0])
oprob2 = remake(oprob; u0 = [osys.B => 2.0]) 
oprob3 = remake(oprob; u0 = [osys.A => 2.0]) 
oprob4 = remake(oprob; u0 = [osys.A => 2.0, osys.B => 3.0]) 

First notice that

julia> oprob.p
MTKParameters{Vector{Float64}, Tuple{}, Tuple{}, Tuple{}}([3.0, 2.0, 2.0], (), (), ())

is the same as

julia> oprob2.p
MTKParameters{Vector{Float64}, Tuple{}, Tuple{}, Tuple{}}([3.0, 2.0, 2.0], (), (), ())

is the same as

julia> oprob3.p
MTKParameters{Vector{Float64}, Tuple{}, Tuple{}, Tuple{}}([3.0, 2.0, 2.0], (), (), ())

is the same as

julia> oprob4.p
MTKParameters{Vector{Float64}, Tuple{}, Tuple{}, Tuple{}}([3.0, 2.0, 2.0], (), (), ())

i.e. Γ[1] does not get updated.

@isaacsas isaacsas reopened this Oct 24, 2024
@isaacsas
Copy link
Member

Note I tested here in a new environment with Catalyst master and MTK 9.47.0.

@AayushSabharwal
Copy link
Member

AayushSabharwal commented Oct 24, 2024

For a parameter to be solved during initialization, it must satisfy the conditions in https://docs.sciml.ai/ModelingToolkit/dev/tutorials/initialization/#Initialization-of-parameters. Such a parameter will get its final value when init is called on the ODEProblem (during solve) similar to unknowns that are solved for during initialization.

julia> oprob.p[osys.Γ[1]]

Try julia> oprob.ps[osys.Γ[1]]. You can't index MTKParameters symbolically, hence the .ps syntax.

@isaacsas
Copy link
Member

julia> defaults(osys)
Dict{Any, Any} with 1 entry:
  Γ[1] => B(t) + A(t)

So the parameter has a default specified but it isn't being used in remake?

@isaacsas
Copy link
Member

Note the problem here is not the initial problem construction, i.e. oprob, it is remake not rerunning initialization of Γ[1] when the initial conditions change.

@isaacsas
Copy link
Member

Try julia> oprob.ps[osys.Γ[1]]. You can't index MTKParameters symbolically, hence the .ps syntax.

Thanks, I forgot we need to use oprob.ps now. The indexing does work.

@AayushSabharwal
Copy link
Member

remake will reconstruct the initialization problem, so if Γ[1] had a guess it would get the appropriate value after init. remake isn't updating Γ[1] because you didn't pass the p keyword to remake. If you do p = Dict() it should update Γ[1].

@isaacsas
Copy link
Member

That feels like it shouldn't be required though? Why should a user have to know they have to pass a blank dict?

@AayushSabharwal
Copy link
Member

Since before remake supported symbolic maps, it didn't update the unknown/parameter values if you didn't pass u0/p, which is why it doesn't do so here. Passing p = Dict() is also slower, since it has to populate from defaults/existing values, do the symbolic substitution, etc. That overhead should be optional. Parameter initialization allows this kind of update in a more robust manner without having to unnecessarily re-create the exact same parameter object.

@AayushSabharwal
Copy link
Member

This remake behavior with symbolic maps has also existed for ages 😅 and it's something we test pretty well (see https://github.com/SciML/SciMLBase.jl/blob/master/test/remake_tests.jl and https://github.com/SciML/SciMLBase.jl/blob/master/test/downstream/modelingtoolkit_remake.jl)

@isaacsas
Copy link
Member

isaacsas commented Oct 24, 2024

So what's the solution for Catalyst? Is there a way we can generate an ODESystem with a parameter that is defined in terms of initial values of unknowns, which gets auto-updated when remake is called by a user and only passed new values of the initial conditions?

@isaacsas
Copy link
Member

Note that in my example above

oprob4 = remake(oprob; u0 = [osys.A => 2.0, osys.B => 3.0], p = Dict())

gives

julia> oprob4.p
MTKParameters{Vector{Float64}, Tuple{}, Tuple{}, Tuple{}}([3.0, 2.0, 2.0], (), (), ())

so it doesn't appear the p = Dict() trick is working?

@AayushSabharwal
Copy link
Member

AayushSabharwal commented Oct 24, 2024

So what's the solution for Catalyst? Is there a way we can generate an ODESystem with a parameter that is defined in terms of initial values of unknowns, which gets auto-updated when remake is called by a user and only passed new values of the initial conditions?

The solution is parameter initialization. You have a parameter whose value is dependent on the initial conditions of unknowns. Those initial conditions could potentially also be determined by other (algebraic/otherwise) constraints. So you give the parameter a default of A + B (or alternatively pass Gamma ~ A + B as an initialization equation) and give Gamma a guess. When the problem is initialized, we will solve for Gamma (more generally, given any two we will solve for the third value).

When you remake and pass u0 = [A => 2.0, B => 3.0] we will rebuild the initialization problem behind the scenes to solve for Gamma given the new values of A and B. If you don't want to remake simply to change initial values, you can add parameters A0 and B0, both without guesses, add defaults A = A0 and B = B0, and just change the values of the parameters. Initialization will solve for Gamma.

@isaacsas
Copy link
Member

OK, so if Catalyst constructs the ODESystem with guesses specified in addition to the default equation we currently pass everything should work under the hood? I'll give that a try later today. Thanks!

@AayushSabharwal
Copy link
Member

Note that in my example above

oprob4 = remake(oprob; u0 = [osys.A => 2.0, osys.B => 3.0], p = Dict())

gives

julia> oprob4.p
MTKParameters{Vector{Float64}, Tuple{}, Tuple{}, Tuple{}}([3.0, 2.0, 2.0], (), (), ())

so it doesn't appear the p = Dict() trick is working?

It seems the problem here is one of the "shortcuts" we take in remake. It gets the symbolic maps u0 = [A => 2.0, B => 3.0] and p = [Gamma => A + B]. It then notices that u0 is not dependent on p but p depends on u0, so it creates the concrete u0 array and then tries to figure out what p should be. The problem is, B is not a state so it's not in u0, so the fact that B => 3.0 is lost, and when calculating A + B it uses the new u0 array and the old p object from the old ODEProblem, which calculates B as Gamma - A. I can fix the bug.

Note, however, that parameter initialization will not suffer from this issue. Once the integrator is initialized, it will have the correct value of Gamma.

@isaacsas
Copy link
Member

I tried passing guesses = [Γ[1] => 1.0] when constructing the ODESystem, but that doesn't seem to have any impact on remake working and updating Γ[1] when just changing an initial condition.

@isaacsas
Copy link
Member

isaacsas commented Oct 24, 2024

If we defined @parameters Γ[1:1] = [A + B] instead of using defaults to define this would that propagate correctly into remake?

@AayushSabharwal
Copy link
Member

I tried passing guesses = [Γ[1] => 1.0] when constructing the ODESystem, but that doesn't seem to have any impact on remake working and updating Γ[1] when just changing an initial condition.

Initialization runs when calling init(prob) or solve(prob)

If we defined @parameters Γ[1:1] = [A + B] instead of using defaults to define this would that propagate correctly into remake?

That's identical to using defaults

@isaacsas
Copy link
Member

Got it. I would still consider this an open issue then as it appears there is no way to update this parameter value without requiring users to deviate from the standard SciML workflow for updating a problem's u0/p.

@AayushSabharwal
Copy link
Member

But this is now a standard SciML workflow. SciML exposes new functionality, and MTK codegens to it.

@ChrisRackauckas
Copy link
Member

DAEs and callback have always had an initialization phase before the solve. This is just using the initialization phase to update u0 and p. For DAEs, it's always been the case that u0 would update at the beginning. It is a bit new for this phase to also change p, but it's not new for example for a callback initialization to change p before a solve.

@AayushSabharwal
Copy link
Member

Also worth noting is that with this initialization, if you remake with any 2 of Gamma[1], A or B MTK will solve for the third. You couldn't do this with remake. The example of Gamma[1] ~ A + B is simple, but in general this is applicable for any such set of initial conditions.

@isaacsas
Copy link
Member

If I have understood, it sounds like one should no longer assume that prob.p is actually the finalized parameters for a non-DAE model. In the past we were told this was a way to get the parameters object in the final order / format that would be used in solvers, and I think this may be relied on in places in Catalyst (for example, so we can use / access generated functions that work with those parameters too, and perhaps in a number of tests).

@AayushSabharwal
Copy link
Member

In general in the past prob.p was not the final parameters, since callbacks could change it during initialization. Our "old" initialization schemes in OrdinaryDiffEq did not touch parameters, and only ensured the algebraic equations were satisfied. MTK being a symbolic modeling library can do better. It allows you to define additional constraints between variables (and now parameters) which should be respected during initialization, and solves the most optimal system it can for those constraints.

i.e. with BrownBasicInit, prob.u0 was not final because algebraic variables could change. Now, any of them can change and if you opt in to parameter initialization then so can (numeric) parameters.

@isaacsas
Copy link
Member

isaacsas commented Oct 24, 2024

Since this is considered the desired behavior I will close this again.

We will just make a new function in Catalyst, rebuild or such, that wraps remake and handles updating the conserved constant directly. We can then tell Catalyst users to always use this function with Catalyst generated problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

6 participants