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

Pre-defined parametrization of the model #69

Open
ismael-lajaaiti opened this issue Oct 26, 2022 · 9 comments · Fixed by #132
Open

Pre-defined parametrization of the model #69

ismael-lajaaiti opened this issue Oct 26, 2022 · 9 comments · Fixed by #132

Comments

@ismael-lajaaiti
Copy link
Collaborator

Disentangle the parametrisation for the Yodzis model and the Brose model by creating two sub-type of the ModelParameters abstract type (e.g. YodzisParameters and BroseParameters), these types should be coupled with different dBdt! which can be achieved with multiple dispatch or by defining different type of ODEProblem as done here.

@iago-lito
Copy link
Collaborator

Hm, would this not only require dBdt! to be duplicated, but all methods accepting p::ModelParameters today as an argument?

What exactly would be the differences between struct YodzisParameters and struct BroseParameters?

@ismael-lajaaiti
Copy link
Collaborator Author

ismael-lajaaiti commented Nov 8, 2022

I think no duplication are needed, because there are already a duplication made (with multiple dispatch) for some functions used in dBdt! (e.g. consumption) depending on the type of the functional response (classic or bioenergetic). (EDIT: Actually I'm lying there, there is no duplication needed for the functions, still we still need to duplicate the types for both models, e.g. duplicate BioRates)
The difference would be that stored parameters won't be exactly the same, as the system parametrisation is slightly different between Yodzis model and Brose model.
I elaborate a bit more below to clarify I was thinking about:

  1. Define the ModelParameters sub-type:
struct YodzisParameters <: ModelParameters
  network <: EcologicalNetwork
  biorates <: YodzisBioRates 
  ... 
end 
struct BroseParameters <: ModelParameters
  network <: EcologicalNetwork
  biorates <: BroseBioRates 
  ... 
end 
  1. As the biological rates are not the same for Brose and Yodzis we define two types of BioRates (same should be done for the functional response, actually we would have just to rename BioenergeticResponse to BroseResponse and ClassicResponse to YodzisResponse).
struct YodzisBioRates
  a
  b
end 
struct BroseBioRates
  a
  b
  c
end 

I think the big pro of this, is that for ecologist knowing the model, it will be way clearer this way, to do the distinction between both models on the object that stores all the parameters and not only the functional response. Because currently we are mixing some parameters of both models. Typically, y is a parameter (in BioRates) which has meaning for the Brose model but not for the Yodzis model, still it will be always in BioRates even if not use when the ClassicResponse (i.e. response of Yodzis model). @iago-lito Let me know if this is clear or not, and what are your thoughts about it?

@iago-lito
Copy link
Collaborator

Okay, the nature of the need is becoming clearer. There are still quite a few options to extend ModelParameters to various "flavours" like Brose and Yodzis.. and I'm still having a few questions (but don't worry if you don't have an answer yet ;)

  • Do you expect the list of flavours to keep growing in the future? (like, Brose, Yodzis, Furnob, Rakzos, Donank, ...)

  • Do you expect that users need to define their own flavour?

  • Would it make sense to "convert" a model from one flavour to the other?

  • Would it make sense for some functions in the package to receive both flavours like function whatever(p::BroseParameters, q::YodzisParameters, ...)?

  • Would it make sense for a model to have both flavours ? (I guess.. no?)

The concern I'm having right now is not only that the amount of code changes required to distinguish the two flavours sounds big, but that the amount of code changes required to add a third flavour would be as big, and as big again for a fourth flavour etc. until the sources become a mess. Also, the solution you are thinking of duplicates fields like network, and it requires that several new types YodzisBioRates, BroseBioRates, YodzisParameters, BroseParameters, etc. be exported to the user, which makes future non-breaking refactoring harder.

Here is one other approach to achieve the same results, but without exposing too much internals. The idea is to flag the core ModelParameters value with an information what the flavour is, so that all functions in the package can tell how to interpret the parameters inside. The most naive implementation would be something like:

struct ModelParameters
    flavour::String # "Brose" or "Yodzis" or "Furnob" etc.
    network::EcologicalNetwork
    biorates::Biorates
    environment::Environment
end

struct BroseBioRates
  a::Float64
  b::Float64
  c::Float64 # not used with the "Yodzis" flavour.
end

.. this is easy to understand and it keeps all possible models flavour under one single type from the user POV. But it does not leverage Julia's type system, and there is this unsatisfactory c::Float64 field that is carried around but useless under Yodzis, and all functions in the package would start looking like:

function package_function(p::ModelParameters, ...)
    if (p.flavour == "Yodzis")
        ...
    elseif (p.flavour == "Brose")
        ...
    end
end

One interesting pattern to improve the above design revolves around the idea of tagged unions. They are not native in Julia, but you could craft them with something along:

# The official list of flavours.
@enum ModelFlavour Brose Yodzis Furnob

# The exact data needed for Brose.
struct BroseBiorates
    a::Float64
    b::Float64
end

# The exact data needed for Yodzis.
struct YodzisBiorates
    a::Float64
    b::Float64
    c::Float64
end

# The only type exported to user.
struct ModelParameters
    flavour::ModelFlavour
    network::EcologicalNetwork
    biorates::Union{BroseBiorates, YodzisBiorates} # Runtime type always corresponds to `flavour`.
    environment::Environment
end
@export ModelParameters

This way, you can write package functions with type-based dispatching like:

function package_function(br::BroseBiorates) ... end
function package_function(br::YodzisBiorates) ... end

function simulate(p::ModelParameters, ...)  # <- no change needed
    package_function(p.biorates)  # <- correctly dispatched
   # ...
end

Pros: code changes do not propagate to functions needing ModelParameters, but only to functions needing Biorates. The changes are completely transparent to user, who uses ModelParameters(foodweb, flavour=Brose) and ModelParameter(foodweb, flavour=Yodzis) without worrying about various sub-types. Julia's type system and multiple dispatching is still leveraged.

Cons: developers need to guarantee that p.flavour and typeof(p.biorates) will always be consistent.. and maybe others that I have not thought of yet?

@ismael-lajaaiti
Copy link
Collaborator Author

First, here are the answers to your question.

* Do you expect the list of flavours to keep growing in the future? (like, `Brose`, `Yodzis`, `Furnob`, `Rakzos`, `Donank`, ...)

No, this seems very improbable to me.

* Do you expect that users need to define their own flavour?

No, or at least no in the framework I'm thinking about. But may be @alaindanet could argue against that? Don't know.

* Would it make sense to "convert" a model from one flavour to the other?

For this I have to dive back into the models, because I don't remember if there is a 1 to 1 mapping between these two models (I think so but no sure). If so, it could totally make sense to convert one to another, otherwise it would ambiguous.

* Would it make sense for some functions in the package to receive both flavours like `function whatever(p::BroseParameters, q::YodzisParameters, ...)`?

No.

* Would it make sense for a model to have both flavours ? (I guess.. no?)

No.

Secondly, I really like your suggestion and think it's better than what I had in mind. Just a small technical question I have, I don't understand why you need this line / what this line is doing

@enum ModelFlavour Brose Yodzis Furnob

@iago-lito
Copy link
Collaborator

iago-lito commented Nov 8, 2022

Great, well let's go for tagged unions then :) This line:

@enum ModelFlavour Brose Yodzis

Creates an enum type. It's new type named ModelFlavour (actually an Int32 under the hood) which is special in that there are only two possible values of this type, namely Brose (actually a 0) and Yodzis (actually a 1). You have:

julia> typeof(ModelFlavour)  #  This is actually a type, like `ModelParameters`.
DataType

julia> ModelFlavour #  Summary of the enum type.
Enum ModelFlavour:
Brose = 0
Yodzis = 1

julia> Brose #  First possible value. Under the hood it's just a `0` integer, but it's *named*.

Brose::ModelFlavour = 0

julia> typeof(Brose) == ModelFlavour
true

julia> Yodzis # Second possible value.
Yodzis::ModelFlavour = 1

julia> ModelFlavour(0) === Brose #  same value.
true

julia> ModelFlavour(1) === Yodzis #  same value.
true

julia> ModelFlavour(2) # Error: This enum has only 2 possible variants.
ERROR: ArgumentError: invalid value for Enum ModelFlavour: 2

To compare them, instead of:

if p.flavour == "Yodnis"  # wops, mistake

you'd just

if p.flavour == Yodzis # mistake is impossible

Same, instead of creating Yodzis models with:

p = ModelParameters(foodweb, flavour = "Yodziz") # wops!

the user would

p = ModelParameters(foodweb, flavour = Yodzis)

So:

  • The flavour of the model is stored as a simple integer (0 or 1), instead of a whole string ("Brose" or "Yodzis"), which is more efficient.
  • Instead of having to remember which one is 0 and which is 1, you have names Brose and Yodzis to help you.
  • You control input to arguments of type ModelFlavour : only the 2 values you define are possible. The user cannot mistake with Blose or pick the flavour = 3.
  • Julia protects you from unintentionally doing arithmetics with underlying integers, because it would be meaningless. 0 + 1 == 1 but Brose + Yodzis yields an error.

.. yeah, enums are really cool :)

@ismael-lajaaiti
Copy link
Collaborator Author

I didn't know about enums, it is really nice! Thanks for the tip!

@iago-lito
Copy link
Collaborator

I suppose there is a slight shift in the issue now that #131 is about to land @ismael-lajaaiti? Does it become something like "Add Yodzis as an alternate set of defaults" instead?

@ismael-lajaaiti
Copy link
Collaborator Author

Mmmh, I am not how to transpose this issue to the new API, I have to admit...

@iago-lito
Copy link
Collaborator

iago-lito commented Mar 15, 2024

Okay. I'll consider this closed by #132, then. Feel free to reopen if Yodzis comes back one day ;)

@iago-lito iago-lito linked a pull request Mar 15, 2024 that will close this issue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants