
MxlBricks¶
mxlbricks
is a library built on top of MxlPy to enable quick building of mechanistic learning models.
Motivation¶
It is desirable to be able to build larger-scale metabolic models from smaller components, which can be tested and analysed in detail.
However, in the last decades we have not seen widespread use of such successful model composition.
We want to argue that this is due to the fact that most attempts to compose larger models use either entire existing models or metabolic pathways as the unit of composition.
However, the idealisation of a metabolic pathway as an isolated entity is not a realistic assumption, as it ignores the fact that the pathway is embedded in a larger network of reactions.
This is especially problematic regarding choices of energy and redox equivalents.
These are frequently described as either parameters, conserved quantities or free variables in different models, depending on the context.
This leads to incompatibilities between the models.
We thus believe that the unit of composition should be the individual reactions and those reactions need to be described in a way that allows for the description of other model components to vary depending on the use case.
Practical problems¶
Other problems are of a more practical nature, such as differences in naming conventions.
For this we propose a shared set of names which we store in the names
module.
To enable localisation of the reactions, we also provide the option to inject compartment information.
def atp_synthase(compartment: str = "") -> str: ...
from mxlbricks import names as n
# Default case
print(n.atp_synthase())
# Localised to cytosol
print(n.atp_synthase("_cyt"))
atp_synthase atp_synthase_cyt
Reaction definition¶
All reactions in mxl-bricks
are defined in a similar way.
We will first show an entire example and then step through it, explaining the rationale behind the choices made.
from mxlpy import Model, fns
from mxlbricks.utils import static
def add_dummy_reaction(
model: Model,
compartment: str,
e0: str | None = None,
kcat: str | None = None,
km: str | None = None,
) -> Model:
"""Dummy enzyme-catalysed, irreversible reaction.
1 substrate => 1 product
"""
rxn_name = n.dummy(compartment)
# Default parameter description if none is given
e0 = static(model, n.e0(rxn_name), value=1.0) if e0 is None else e0
kcat = static(model, n.kcat(rxn_name), value=1.0) if kcat is None else kcat
km = static(model, n.km(rxn_name), value=0.1) if km is None else km
# Derive vmax from e0 and kcat
model.add_derived(vmax := n.vmax(rxn_name), fn=fns.proportional, args=[kcat, e0])
# Add the reaction
model.add_reaction(
name=rxn_name,
fn=fns.michaelis_menten_1s,
args=[
n.a0(compartment),
vmax,
km,
],
stoichiometry={
n.a0(compartment): -1,
n.a1(compartment): 1,
},
)
return model
The reaction takes a Model
object, any localisation information (e.g. compartment
) and optionally parameters as names.
def add_dummy_reaction(
model: Model,
compartment: str,
e0: str | None = None,
kcat: str | None = None,
km: str | None = None,
)
Injecting parameters¶
The names are given as strings, so that they can be easily replaced by the user.
In this way the reaction does neither impose a name nor other features.
Note: this technique is called dependency injection.
This is especially useful for different descriptions of the same reaction.
For example, the concentration of the enzyme could be assumed to be constant, in which case a normal parameter would be appropriate.
def constant(model: Model, name: str, value: float) -> str:
model.add_parameter(name, value)
return name
However, the reaction might also be regulated by a thioredoxin system.
In that case we would not be interested in the total amount of the enzyme, but rather it's active fraction.
def thioredixon_regulated(model: Model, name: str, value: float) -> str:
model.add_parameter(name, value)
derived_name = f"{name}_active"
model.add_derived(derived_name, fns.proportional, args=[name, n.e_active()])
return derived_name
Using this approach, we can easily switch between the two descriptions by simply injecting the appropriate function.
# here the function depends on `E0_dummy`
add_dummy_reaction(..., e0=static(model, n.e0(n.dummy()), value=1.0))
# here the function depends on `E0_dummy_active`
add_dummy_reaction(..., e0=thioredixon_regulated(model, n.e0(n.dummy()), value=1.0))
Default parameters¶
Inside the function, the parameters are then initialised to default values if they are not given
e0 = static(model, n.e0(rxn_name), value=1.0) if e0 is None else e0
This is done to avoid overly redundant code in case the parameter description is always the same.
For example, it is a lot easier to see the difference between these two descriptions
add_dummy_reaction(
model,
compartment="_chl",
)
add_dummy_reaction(
model,
compartment="_chl",
e0=thioredixon_regulated(model, name=n.e0(rxn_name), value=1.0),
)
rather than these two
add_dummy_reaction(
model,
compartment="_chl",
e0=static(model, name=n.e0(rxn_name), value=1.0),
kcat=static(model, name=n.kcat(rxn_name), value=1.0),
km=static(model, name=n.km(rxn_name), value=0.1),
)
add_dummy_reaction(
model,
compartment="_chl",
e0=thioredixon_regulated(model, name=n.e0(rxn_name), value=1.0),
kcat=static(model, name=n.kcat(rxn_name), value=1.0),
km=static(model, name=n.km(rxn_name), value=0.1),
)
Derived vmax values¶
mxl-bricks
does not make any assumption about the rate laws used in the model.
However, in the case of any Michaelis-Menten-type rate law, it is recommended to derive the vmax
from the kcat
and the concentration of the enzyme.
This makes it a lot easier to compare the actual kcat
values chosen against databases like BRENDA.
# Derive vmax from e0 and kcat
model.add_derived(vmax := n.vmax(rxn_name), fn=fns.proportional, args=[kcat, e0])
As MxlPy supports derived values from both parameters and variables, this code does not need to be changed between the two descriptions.
Filtered stoichiometries¶
By default, we will define the stoichiometries of a reaction like the following:
stoichiometry={
n.a0(compartment): -1,
n.a1(compartment): 1,
}
However, especially with different descriptions of energy and redox equivalents, not all of the stoichiometries might actually be variables.
In that case, we can use the filter_stoichiometry
function to filter out the stoichiometries that are not actually variables.
from mxlbricks.utils import filter_stoichiometry
model.add_reaction(
...
stoichiometry=filter_stoichiometry(
model,
stoichiometry={
n.a0(compartment): -1,
n.a1(compartment): 1,
},
),
)
Model building¶
With this, models can be composed from
- a set of variables
- a set of parameters
- (potentially) a set of derived quantities
- a set of reactions
def get_model() -> Model:
model = Model()
model.add_variables({n.atp(): 1.0, ...})
model.add_parameters({n.ph(): 7.0, ...})
# Add reactions to the model
add_reaction1(model)
add_reaction2(model)
add_reaction3(model)
...
return model
Design recommendations¶
We recommend to include all parameters of a reaction in the description of the reaction, such that it is a self-contained unit.
However, sometimes parameters might be shared between reactions.
In that case, we recommend to still define the parameter in each reaction, but then pass the default value to each of the reactions.
def get_model() -> Model:
model = Model()
model.add_parameters({n.ph(): 7.0, ...})
# Inject the parameters into the reactions
add_reaction1(model, ph=n.ph())
add_reaction2(model, ph=n.ph())
...
return model
Discarded design decisions¶
Model inheritance¶
We initially considered using inheritance to build models from smaller components.
In the context of models, this could look something like this:
def build_model_v1() -> Model: ...
def build_model_v2(model_v1: Model) -> Model: ...
However, this makes changes to models difficult, as other models may depend on the model you are trying to change.
Thus, the entire chain of models becomes rigid and difficult to change.
So while a composition approach requires more boilerplate code and repetition, it is much more flexible.