Growth-rate tuning#
A draft ecModel with active enzyme constraints usually predicts a growth rate far below what the organism actually reaches, because a maximum constraint has not yet been imposed on the protein pool, and because some kcat values are uncertain or too low. This page diagnoses which of those is the limiting factor and tunes the model against known physiological data until it reaches a realistic growth rate. The result is a functional ecModel.
The protein pool's maximum constraint is calculated as:
where sigma is the average in vivo saturation of every enzyme in the model, f is the mass fraction occupied by metabolic enzymes in the total proteome, and \(P_{tot}\) is the total protein mass in grams per gram cell dry weight. When one or more factors are unknown, a default of 0.5 for each gives a default maximum constraint of \(0.5 \times 0.5 \times 0.5 = 0.125\).
Note
When no physiological data are available Tuning requires physiological data, particularly growth rate. Without it, for example in large-scale reconstruction across many cell lines, only draft ecModels can be obtained: a draft may not simulate realistic fluxes, because the protein pool's upper bound is undefined, but it can still compare cellular parameters such as protein costs. Even with no experimental data, a functional ecModel is reachable by assuming an arbitrary protein pool value and increasing low kcat values until simulations become reasonable.
Functions on this page#
MATLAB |
Python |
|
|---|---|---|
|
run FBA and predict a growth rate |
|
iteratively raise the kcats that most limit growth |
||
apply a curated kcat value to specific reactions |
||
save the functional ecModel |
Simulate growth rate#
Examine whether the ecModel reaches a realistic growth rate once enzyme constraints are applied. Microorganisms reach their maximum growth rate when nutrient supply is not limiting, so allow unconstrained uptake of the carbon source first:
ecModel = setParam(ecModel, 'lb', params.c_source, -1000);
Or referring directly to the glucose exchange reaction in the S. cerevisiae ecModel:
ecModel = setParam(ecModel, 'lb', 'r_1714', -1000);
geckopy/cobrapy has no setParam wrapper: bounds are attributes on the
cobra.Reaction object itself.
ec_model.reactions.get_by_id(params.c_source).lower_bound = -1000
Or directly:
ec_model.reactions.get_by_id("r_1714").lower_bound = -1000
The glucose exchange reaction, for example, is defined as glucose[e] <=>.
A positive, forward flux represents dissipation from the system, so a
negative flux signifies uptake into the cytoplasm; a negative lower bound
is therefore what allows uptake. Query the direction of a reaction with:
constructEquations(ecModel, 'r_1714');
print(ec_model.reactions.get_by_id("r_1714").reaction)
Set growth as the objective, predict the flux distribution and print the growth rate:
ecModel = setParam(ecModel, 'obj', params.bioRxn, 1);
sol = solveLP(ecModel);
bioRxnIdx = getIndexes(ecModel, params.bioRxn, 'rxns');
fprintf('Growth rate: %f /hour\n', sol.x(bioRxnIdx))
Output in the full_ecModel tutorial:
Growth rate: 0.107203 /hour
ec_model.objective = params.bio_rxn
sol = ec_model.optimize()
print(f"Growth rate: {sol.fluxes[params.bio_rxn]:.4f} /hour")
Setting .objective to a reaction id maximizes it by default; there is no
separate "set objective coefficient" call, and no reaction-index lookup,
since sol.fluxes is a pandas Series indexed by reaction id.
Note
Example output
In the full_ecModel tutorial, the growth rate right after applying enzyme
constraints is about 0.107 h^-1. Because nutrient uptake is not limiting,
this is the maximum the ecModel can reach. Being substantially below
0.41 h^-1, the experimentally observed maximum growth rate of this organism
(params.gR_exp in MATLAB, params.gr_exp in Python), indicates the model
needs tuning.
Three reasons can leave the predicted growth rate below the experimental maximum, and a systematic check distinguishes them: the metabolic network's stoichiometry is not fully correct; the protein pool exchange reaction is too tightly constrained; or kcat values assigned to reactions are too low.
Network stoichiometry limits growth rate#
Predict the growth rate in the conventional GEM, without enzyme constraints, under the same exchange flux constraints:
model = loadConventionalGEM();
model = setParam(model, 'lb', params.c_source, -1000);
model = setParam(model, 'obj', params.bioRxn, 1);
sol = solveLP(model);
bioRxnIdx = getIndexes(model, params.bioRxn, 'rxns');
fprintf('Growth rate: %f /hour\n', sol.x(bioRxnIdx))
Output in the full_ecModel tutorial:
Growth rate: 19.718033 /hour
from geckopy import load_conventional_gem
model = load_conventional_gem(adapter)
model.reactions.get_by_id(params.c_source).lower_bound = -1000
model.objective = params.bio_rxn
sol = model.optimize()
print(f"Growth rate: {sol.fluxes[params.bio_rxn]:.4f} /hour")
If the conventional GEM also cannot reach the maximum growth rate, the starting GEM's stoichiometry or constraints contain errors that should be resolved before reconstructing an ecModel from it.
Note
Example output
Without a constraint on carbon uptake, the conventional GEM in the
full_ecModel tutorial reaches a growth rate of about 19.7 h^-1. This is
not biologically realistic on its own, given the unconstrained carbon
uptake, but it confirms that the conventional GEM's stoichiometry can reach
0.41 h^-1, so the shortfall above comes from the enzyme constraints, not
the network.
Too tight protein pool constraint#
Confirm that the protein pool constraint from Applying kcats is realistic: inspect the sigma, \(P_{tot}\) and f parameters in the model adapter, and adjust them if discrepancies show up. Regardless of whether those parameters are realistic, the simplest way to test whether the protein pool is what limits growth is to relax the pool usage reaction's constraint entirely, releasing every enzyme constraint at once:
ecModel = setParam(ecModel, 'ub', 'prot_pool_exchange', 1000);
ec_model.reactions.prot_pool_exchange.upper_bound = 1000
set_prot_pool_size narrows prot_pool_exchange's upper bound to a
realistic budget; relaxing it back to unconstrained means raising that
upper bound back to geckopy's wide-open default (1000).
Tip
GECKO 4: direction of prot_pool_exchange
In GECKO 4 and geckopy, prot_pool_exchange is a forward reaction that
produces prot_pool, with a lower bound of 0 and an upper bound of 1,000
by default (see
Building an empty ecModel).
Relaxing the protein pool therefore raises its upper bound, and minimizing
its usage is an ordinary minimization. The GECKO 3.0 protocol used the
opposite (negative-flux) direction, where relaxing lowered the lower bound
to -1,000 and minimizing usage required maximizing with coefficient 1.
With neither a protein pool nor a nutrient constraint, predict the lowest protein pool usage that still supports the experimental maximum growth rate:
ecModel = setParam(ecModel, 'lb', 'r_4041', 0.41);
ecModel = setParam(ecModel, 'obj', 'prot_pool_exchange', -1);
sol = solveLP(ecModel);
protPoolIdx = strcmp(ecModel.rxns, 'prot_pool_exchange');
fprintf('Protein pool usage is: %.0f mg/gDCW\n', sol.x(protPoolIdx))
Objective coefficient sign. solveLP maximizes the objective, so the
coefficient -1 minimizes the flux through prot_pool_exchange.
Output in the full_ecModel tutorial:
Protein pool usage is: 446 mg/gDCW
cobrapy separates the objective reaction from the optimization direction,
so minimizing prot_pool_exchange usage is a plain positive objective with
objective_direction set to "min":
ec_model.reactions.get_by_id("r_4041").lower_bound = 0.41
ec_model.objective = "prot_pool_exchange"
ec_model.objective_direction = "min"
sol = ec_model.optimize()
pool_usage = sol.fluxes["prot_pool_exchange"]
print(f"Protein pool usage is: {pool_usage:.0f} mg/gDCW")
Set the predicted protein pool usage as the constraint on the protein pool exchange reaction, then revert the objective function and growth rate constraint used to find it:
ecModel = setParam(ecModel, 'ub', protPoolIdx, sol.x(protPoolIdx));
ecModel = setParam(ecModel, 'lb', 'r_4041', 0);
ecModel = setParam(ecModel, 'obj', 'r_4041', 1);
ec_model.reactions.prot_pool_exchange.upper_bound = pool_usage
ec_model.reactions.get_by_id("r_4041").lower_bound = 0
ec_model.objective = "r_4041"
ec_model.objective_direction = "max"
This always yields an ecModel that reaches the intended growth rate, assuming the conventional GEM can too, but it often sets the protein pool usage to an unrealistic protein content. Increasing kcat values for some reactions instead is the more biologically meaningful option, covered next.
Note
Example output
Setting the upper bound of prot_pool_exchange to 1,000 mg/gDCW implies
the whole cell is 100% protein. While unrealistic, this often avoids
overconstraining. In the full_ecModel tutorial, the predicted protein
pool usage is 446 mg/gDCW. With f = 0.5, this implies the cell would
consist of 89.2% protein.
Assuming full enzyme saturation, a more realistic (lower) sigma would push
the simulated total protein content above 100%, which is why simply
releasing the protein pool constraint is not the recommended fix.
Sensitivity tuning of kcat values#
Iteratively increasing the kcat values of enzymes that limit growth, until the desired growth rate is reached, is the recommended approach over relaxing the pool. At this stage the enzyme constraints are dominated by the overarching protein pool constraint, so each deterministic iteration identifies the most-limiting enzyme as the one demanding the largest fraction of the protein pool, and raises its kcat; the raised value is the one contributing most to overconstraining the model, not necessarily the one furthest from its true in situ value.
First revert the protein pool constraint to a realistic value (see Applying kcats), then tune and report the changes:
ecModel = setProtPoolSize(ecModel);
[ecModel, tunedKcats] = sensitivityTuning(ecModel);
struct2table(tunedKcats)
sensitivityTuning returns the updated ecModel and the tunedKcats
structure, and with verbose at its default (true) prints the growth
rate of each iteration. It also handles light ecModels. Output for the
full_ecModel tutorial:
Iteration 1: Growth: 0.11421
Iteration 2: Growth: 0.18016
Iteration 3: Growth: 0.23412
Iteration 4: Growth: 0.27495
Iteration 5: Growth: 0.30635
Iteration 6: Growth: 0.33961
Iteration 7: Growth: 0.3718
Iteration 8: Growth: 0.39141
Iteration 9: Growth: 0.41193
from geckopy import sensitivity_tuning, set_prot_pool_size
set_prot_pool_size(ec_model)
tuning_result = sensitivity_tuning(ec_model)
sensitivity_tuning mutates ec_model in place, with no separate returned
model, and returns a TunedKcatsResult with a .rxns field (and the
previous/tuned values); with no explicit growth-rate argument it targets
params.gr_exp from the adapter. It is not available for light ecModels
(raises NotImplementedError if ec_model.ec.gecko_light is True). With
verbose=True (the default) it logs the growth rate of each iteration at
INFO level through the logging module.
Both toolboxes also provide a CMA-ES-based kcat tuning module
(cmaesKcatTuning in MATLAB, cmaes_kcat_tuning in Python), described in
Tuning against experimental data.
The tunedKcats/tuning_result output documents which kcat values were
changed, with their previous value and the catalyzed reaction. Inspect it
to check whether any changed value was initially gathered incorrectly from
a kcat source.
Note
Many kcat values are changed during tuning Some kcat values changing is expected, typically about 20 to 40. If a much larger fraction changes, check the following before assuming the tuning itself is at fault:
No constraint is set on any nutrient uptake; only the protein pool should be limiting.
The maximum growth rate specified as
gR_exp/gr_expin the model adapter is realistic.The starting GEM can simulate the intended growth rate on its own, to rule out the network stoichiometry as the limiting factor (see Network stoichiometry limits growth rate above).
Note
Example output
In the full_ecModel tutorial, iterative tuning raises the growth rate
from 0.114 h^-1 in the first iteration to 0.412 h^-1 in the ninth, above
the target of 0.41 h^-1.
The selected ec.kcat entries appear in the tuned kcat output (see
Example tuned kcat output below).
ec.source still names the original source, so transferring the changes
into data/customKcats.tsv (see
Applying kcats) keeps
them documented.
Example tuned kcat output#
rxns |
rxnNames |
enzymes |
oldKcat |
newKcat |
source |
|---|---|---|---|---|---|
|
5'-Phosphoribosylformyl glycinamidine synthetase |
P38972 |
0.05 |
5 |
BRENDA |
|
Acetyl-CoA carboxylase, reaction |
P48445; Q00955 |
1.23 |
12.3 |
BRENDA |
|
Fructose-bisphosphate aldolase |
P14540 |
32.5 |
325 |
Custom |
|
Glyceraldehyde-3-phosphate dehydrogenase |
P00360 |
29 |
290 |
Custom |
|
Lanosterol synthase |
P38604 |
0.001937 |
0.1937 |
BRENDA |
|
Glucose transport |
P38695 |
19.26 |
192.6 |
DLKcat |
Evaluate the tuned kcat values#
Tuning changes values automatically, based only on which enzyme
overconstrains the ecModel, without regard to whether the original value
was itself unrealistic. Good evidence can exist that the tuned value
better reflects reality than the source it replaced, but not every tuned
value can be supported by literature data. The worked example below covers
one value from the table above, 5'-phosphoribosylformyl glycinamidine
synthetase (reaction r_0079), which tuning raised from 0.05 s^-1 to 5
s^-1, sourced originally from BRENDA; repeat this kind of check for each
value of interest.
Get the match details behind the original BRENDA value:
rxnIdx = find(strcmp(kcatList_merged.rxns, 'r_0079'));
kcatList_merged.wildcardLvl(rxnIdx)
kcatList_merged.eccodes(rxnIdx)
kcatList_merged.origin(rxnIdx)
kcat lists are pandas.DataFrames in geckopy, one row per reaction, rather
than a struct of parallel arrays, so this is a row lookup:
row = kcat_list_merged.loc[kcat_list_merged["rxn_id"] == "r_0079"].iloc[0]
print(row["wildcard_level"], row["eccode"], row["origin"])
This yields a wildcard level of 0, EC number 6.3.5.3 and origin 4 (any organism, any substrate, specific-activity kcat).
Looking at the specific BRENDA entry, the reported kcat of 0.05 comes from E. coli with NH3 as substrate. The usual substrate is glutamine, so this value may not be a fair estimate for the reaction in the ecModel. The original paper's abstract states that NH3 can replace glutamine as a nitrogen donor, with a Km of 1 M and a turnover of 3 min^-1 (2% of the glutamine turnover); the same paper also reports a specific activity with glutamine as substrate, a more reasonable value to use here. Calculate the corresponding activity: the specific activity was reported as 2.15 micromol/min/mg protein, which equals mmol/min/g protein, so converting to mol/s/g protein and applying the enzyme's molecular weight gives:
convKcat = 2.15;
convKcat = convKcat / 1000;
convKcat = convKcat / 60;
enzMW = ecModel.ec.mw(strcmp(ecModel.ec.enzymes, 'P38972'));
convKcat = convKcat * enzMW
enz_idx = ec_model.ec.enzymes.index("P38972")
enz_mw = ec_model.ec.mw[enz_idx]
sa = 2.15 # umol/min/mg protein
conv_kcat = sa / 1000 / 60 * enz_mw
print(conv_kcat)
The converted kcat is 5.34, close to the value of 5 reached by
the tuning. Replace the value in the ecModel with the new
literature value, either documenting it in customKcats.tsv (see
Applying kcats) or applying
it directly:
ecModel = setKcatForReactions(ecModel, 'r_0079', 5.34);
ecModel = applyKcatConstraints(ecModel);
from geckopy import set_kcat_for_reactions
set_kcat_for_reactions(ec_model, ["r_0079"], 5.34)
set_kcat_for_reactions takes a list of reaction ids, even for a single
reaction, and one kcat value applied to all of them. With its default
apply=True, it also applies the new kcat to the S-matrix immediately, so
no separate call to apply_kcat_constraints is needed here (pass
apply=False to defer that, matching MATLAB's two-step
setKcatForReactions + applyKcatConstraints).
After repeating this for each tuned value, consider the results overall. When convincing evidence shows the original value is more realistic than the tuned one, key reactions or whole pathways may be missing from the starting GEM's network stoichiometry; such issues, as in Network stoichiometry limits growth rate above, are best resolved in the conventional GEM before it is used to reconstruct an ecModel.
Save the functional ecModel#
The tuned ecModel can now simulate the physiological data it was tuned against. Save it before moving on to proteomics integration or simulation and analysis:
saveEcModel(ecModel);
from geckopy import save_ec_model
save_ec_model(ec_model, "ecYeastGEM.yml", adapter=adapter)
Box 2: Selection of RAVEN toolbox functions#
These operations work on both conventional GEMs and ecModels, in both languages (RAVEN toolbox functions in MATLAB, cobrapy idioms in Python).
Set the upper bound of a reaction to ten:
ecModel = setParam(ecModel, 'ub', 'r_0003', 10);
ec_model.reactions.get_by_id("r_0003").upper_bound = 10
Set the lower bound of a reaction to zero:
ecModel = setParam(ecModel, 'lb', 'r_0003', 0);
ec_model.reactions.get_by_id("r_0003").lower_bound = 0
Set the objective to maximize flux through the biomass reaction:
ecModel = setParam(ecModel, 'obj', params.bioRxn, 1);
ec_model.objective = params.bio_rxn
Perform FBA, optimizing the objective:
sol = solveLP(ecModel);
sol = ec_model.optimize()
Perform parsimonious FBA, optimizing the objective and minimizing the total sum of flux:
sol = solveLP(ecModel, 1);
from cobra.flux_analysis import pfba
sol = pfba(ec_model)
Tip
GECKO 4: enzyme-aware pFBA
Both languages also offer a variant that minimizes total enzyme usage
instead of total flux, the L1 norm of usage_prot_* fluxes rather than all
reaction fluxes. This is not part of the original Nature Protocols
procedure, and useful for the
enzyme-usage inspection done during
simulation and analysis. It is not available for light ecModels, which
have no usage_prot_* reactions to minimize.
sol = getPfbaEnzymes(ecModel);
from geckopy import pfba_enzymes
sol = pfba_enzymes(ec_model)
Inspect the nonzero fluxes:
Through exchange reactions (this includes usage reactions for enzymes constrained by their concentration, but not enzymes that draw from the protein pool, since pool usage is itself the exchange reaction in that case):
printFluxes(ecModel, sol.x)
Through all reactions:
printFluxes(ecModel, sol.x, false)
sol.fluxes is a pandas Series indexed by reaction id, so this is
ordinary pandas filtering. Through all reactions:
print(sol.fluxes[sol.fluxes != 0])
Restricted to exchange reactions:
exchange_ids = [r.id for r in ec_model.exchanges]
print(sol.fluxes[exchange_ids][sol.fluxes[exchange_ids] != 0])
Export the results to a spreadsheet (this file does not carry content from
the ec structure, but is convenient for quickly finding reaction
identifiers):
exportToExcelFormat(ecModel, 'filename.xlsx');
geckopy has no dedicated spreadsheet exporter; write the solution directly with pandas:
sol.fluxes.to_excel("filename.xlsx")
Export the ecModel to an SBML file for use in other constraint-based modeling software:
exportModel(ecModel, 'filename.xml');
import cobra
cobra.io.write_sbml_model(ec_model, "filename.xml")
As on Building an empty ecModel,
this plain-cobrapy SBML export does not retain the ec_model.ec fields,
the same caveat as MATLAB's exportModel.
See also#
Applying kcats, where the enzyme constraints tuned on this page come from.
Tuning against experimental data, a GECKO 4 / geckopy method that fits kcats to measured data directly, rather than iteratively relaxing the protein pool.
API reference, every function in both toolboxes.