BecksLab / EcologicalNetworksDynamics.jl

A simulator for ecological dynamics written in Julia.
GNU Affero General Public License v3.0
18 stars 1 forks source link

Pre-defined parametrization of the model #69

Open ismael-lajaaiti opened 1 year ago

ismael-lajaaiti commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 
  2. 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 commented 1 year ago

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 ;)

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 commented 1 year ago

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 commented 1 year ago

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:

.. yeah, enums are really cool :)

ismael-lajaaiti commented 1 year ago

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

iago-lito commented 6 months ago

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 commented 6 months ago

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

iago-lito commented 6 months ago

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