4. Simulating growth with FBA#
Set an objective, constrain the uptake rates, solve, and read the fluxes back. This is the loop every other analysis on this site is built from.
Functions on this page#
MATLAB |
Python |
|
|---|---|---|
set bounds and the objective |
||
solve the LP |
||
show the fluxes that carry material |
||
|
pick a parsimonious solution among the optima |
Setup#
yeast-GEM.yml from docs/data/, yeast-GEM v9.1.0, which
arrives with a growth objective and an aerobic glucose medium already set.
4.1 Solve#
model = readYAMLmodel('yeast-GEM.yml');
sol = solveLP(model);
fprintf('objective: %s\n', model.rxns{model.c == 1});
fprintf('status: %d\n', sol.stat);
fprintf('growth: %.4f /h\n', sol.f);
objective: r_2111
status: 1
growth: 0.0809 /h
The objective is model.c, a vector with one entry per reaction, so
model.c == 1 finds the reaction being maximised.
sol carries the whole answer. sol.f is the objective value with its
natural sign: RAVEN minimises internally and negates the result, so no sign
has to be undone by the caller. sol.x is the flux vector, in the order of
model.rxns, and sol.stat reports how the solve ended:
|
Meaning |
|---|---|
|
solved to optimality |
|
a feasible solution was found, but not proven optimal |
|
no feasible solution exists |
|
solved, but the flux minimisation in 4.6 failed |
Anything other than 1 makes the fluxes provisional at best, so check stat
before reading x. A plain solve (minFlux left at its default) also returns
sol.sPrice and sol.rCost, the shadow prices and reduced costs from the
LP dual.
from raven_toolbox.io import read_yaml_model
model = read_yaml_model("yeast-GEM.yml")
solution = model.optimize()
print(f"objective: {model.objective.expression}")
print(f"status: {solution.status}")
print(f"growth: {solution.objective_value:.4f} /h")
objective: 1.0*r_2111 - 1.0*r_2111_reverse_58b69
status: optimal
growth: 0.0809 /h
optimize maximises by default and returns a Solution whose fluxes is a
pandas Series indexed by reaction id.
The objective prints as a difference of two terms because cobrapy gives every
reaction a forward and a reverse variable, both non-negative, and reports the
net flux as forward minus reverse. The suffix on the reverse variable is a
hash, so it differs between models. Only the expression is split; r_2111
remains one reaction, and solution.fluxes['r_2111'] is the net value.
4.2 Change the objective#
The objective is a reaction to maximise: growth, a product exchange, an ATP demand.
model = setParam(model, 'obj', 'r_2111', 1); % growth
disp(model.rxnNames{getIndexes(model, 'r_2111', 'rxns')});
growth
setParam writes the coefficient into model.c and zeroes every other
entry, so setting an objective replaces the previous one rather than adding
to it. A negative coefficient minimises that reaction instead.
model.objective = "r_2111"
print(model.reactions.get_by_id("r_2111").name)
growth
Assigning a reaction or its id replaces the objective. model.objective also
accepts an expression, so a weighted combination of reactions is written
directly, and model.objective_direction switches between maximising and
minimising.
4.3 Constrain an uptake rate#
yeast-GEM writes its exchange reactions as metabolite <=>, so uptake is a
negative flux and the lower bound is what limits it. A model that writes
uptake as => metabolite, like smallYeast in 6. Solvers, has the
opposite sign. yeast-GEM ships with glucose capped at 1 mmol/gDW/h, which is
where the growth rate above comes from; ten times the glucose gives roughly ten
times the growth.
idx = getIndexes(model, 'r_1714', 'rxns'); % D-glucose exchange
fprintf('shipped bounds: [%g %g]\n', model.lb(idx), model.ub(idx));
model = setParam(model, 'lb', 'r_1714', -10);
sol = solveLP(model);
fprintf('growth on 10 mmol glucose: %.4f /h\n', sol.f);
model = setParam(model, 'lb', 'r_1714', -1); % back to the shipped medium
shipped bounds: [-1 1000]
growth on 10 mmol glucose: 0.8370 /h
glucose = model.reactions.get_by_id("r_1714")
print("shipped bounds:", glucose.bounds)
glucose.lower_bound = -10.0
faster = model.optimize()
print(f"growth on 10 mmol glucose: {faster.objective_value:.4f} /h")
glucose.lower_bound = -1.0 # back to the shipped medium
shipped bounds: (-1.0, 1000.0)
growth on 10 mmol glucose: 0.8370 /h
The upper bound of 1000 on the same reaction is the model's convention for
"unbounded", and it allows secretion of glucose, which nothing in the model will
choose to do. Bounds are a pair, and only the one facing the direction of
interest constrains the answer. 5. Growth media and conditions sets
all of them at once, and covers the opposite sign convention that
model.medium uses.
4.4 Try something without keeping it#
A knockout or a tighter bound is usually a question, not a decision. cobrapy's
model is a context manager: changes made inside with model: are rolled back on
the way out. MATLAB has no equivalent; copy the struct, change the copy, and let
it go out of scope.
modelKO = setParam(model, 'eq', 'r_1992', 0); % close oxygen uptake
solKO = solveLP(modelKO);
fprintf('anaerobic: %.4f /h\n', solKO.f);
% `model` itself is untouched -- MATLAB copied it on assignment
sol = solveLP(model);
fprintf('back to aerobic: %.4f /h\n', sol.f);
anaerobic: 0.0000 /h
back to aerobic: 0.0809 /h
'eq' sets both bounds at once, so a single call closes the reaction in both
directions. MATLAB structs are value types, so modelKO is a copy from the
moment it is assigned and model cannot be reached through it.
with model:
model.reactions.get_by_id("r_1992").bounds = (0, 0) # oxygen exchange
print(f"anaerobic: {model.optimize().objective_value:.4f} /h")
print(f"back to aerobic: {model.optimize().objective_value:.4f} /h")
anaerobic: -0.0000 /h
back to aerobic: 0.0809 /h
Without with, the bound change would persist and every later result on this
page would be an anaerobic one. The block undoes the edits cobrapy records:
bounds, the objective, reactions and metabolites added or removed,
stoichiometry changed with add_metabolites, gene rules and gene knockouts.
Plain attribute edits are not recorded and survive the block: names, formulas,
charges, subsystem, the model id and anything written into annotation or
notes. Make those on model.copy().
Two things in that output need care. The anaerobic growth rate is zero,
not a smaller positive number: closing the oxygen exchange is not enough to
make yeast-GEM grow fermentatively; it also needs sterol and fatty-acid
uptake and a different biomass composition, which is what a condition does.
See Growth media and conditions. And the minus sign on that zero
is solver noise, not a negative growth rate; compare against a tolerance
rather than to 0.
4.5 Look at the fluxes#
An FBA solution has thousands of numbers, most of them zero and most of the rest uninteresting. Both toolboxes offer a filtered view.
exchanges = {'r_1714', 'r_1992', 'r_1672', 'r_1761'};
labels = {'glucose', 'oxygen', 'CO2', 'ethanol'};
for i = 1:numel(exchanges)
idx = getIndexes(model, exchanges{i}, 'rxns');
fprintf('%8s: %9.4f\n', labels{i}, sol.x(idx));
end
glucose: -1.0000
oxygen: -2.3592
CO2: 2.6597
ethanol: 0.0000
printFluxes(model, sol.x) does the same filtering without a list of ids:
by default it prints every exchange reaction carrying more than 1e-8. Its
cutOffFlux raises that floor, onlyExchange set to false includes
internal reactions, metaboliteList restricts the print-out to reactions
touching named metabolites, and outputString controls the columns, with
%eqn, %lower and %upper available alongside %flux.
solution = model.optimize()
exchanges = {
"r_1714": "glucose",
"r_1992": "oxygen",
"r_1672": "CO2",
"r_1761": "ethanol",
}
for rxn_id, label in exchanges.items():
print(f"{label:>8}: {solution.fluxes[rxn_id]:9.4f}")
glucose: -1.0000
oxygen: -2.3592
CO2: 2.6597
ethanol: 0.0000
model.summary() prints the same picture (uptake, secretion and the
objective) as a table, and solution.fluxes is a pandas Series, so ordinary
filtering works: solution.fluxes[solution.fluxes.abs() > 1e-6].
In yeast-GEM's orientation the signs say which way material moves: negative through an exchange is uptake, positive is secretion. Glucose and oxygen enter, CO2 leaves, and ethanol is zero because the model has enough oxygen to respire everything it takes up.
4.6 Pick a parsimonious solution#
An FBA optimum is rarely unique: many flux distributions reach the same growth rate, and a plain solve returns an arbitrary one, often with internal loops that carry flux without contributing to the objective. Parsimonious FBA keeps the objective at its optimum and then minimises the total flux, on the reasoning that a cell does not run reactions it gains nothing from.
solPars = solveLP(model, 'minFlux', 1); % minimise sum(abs(fluxes))
fprintf('growth: %.4f /h\n', solPars.x(getIndexes(model, 'r_2111', 'rxns')));
fprintf('total flux: %.1f\n', sum(abs(solPars.x)));
growth: 0.0809 /h
total flux: 100.6
minFlux selects the second optimisation. 1 minimises the sum of absolute
fluxes, which requires one further LP. 3
minimises the number of active reactions instead, which is a
mixed-integer problem: the result is easier to read as a pathway, and the
solve is far slower. Leaving minFlux at 0 skips the second solve
altogether and is the only setting that reports shadow prices.
from cobra.flux_analysis import pfba
parsimonious = pfba(model)
print(f"growth: {parsimonious.fluxes['r_2111']:.4f} /h")
print(f"total flux: {parsimonious.fluxes.abs().sum():.1f}")
growth: 0.0809 /h
total flux: 100.6
pfba is the equivalent of minFlux set to 1. It takes
fraction_of_optimum, which relaxes the objective before minimising flux, so
0.95 asks for the leanest distribution that still reaches 95% of the
optimum.
The growth rate is unchanged and the total flux is now the smallest that achieves it. The number of active reactions still varies between solves; pFBA pins the total flux, not which reactions carry it, so a test on that count will be flaky.
Warning
What can go wrong
The status is
infeasible. Something is over-constrained, most often an uptake that is closed, or a bound set to the wrong sign. See Growth media and conditions.Growth is zero but the solve succeeded. The model is feasible and the optimum really is zero: a nutrient is missing, or a gap blocks the biomass pseudoreaction.
The same model gives different flux distributions. Expected: alternative optima. Use pFBA, or compare ranges with FVA, rather than one solution.
Reading
sol.fas a negated objective. RAVEN minimises-c'xinternally and negates the result before returning, sosol.fis the growth rate, not its negative.A model whose coefficients span many orders of magnitude. Ill-scaled stoichiometry makes the solver report an optimum that shifts between solvers.
solveLPtakesparams.maxRatio, which splits any reaction whose coefficients span more than that ratio through auxiliary metabolites before solving, leaving the feasible region unchanged.
See also#
Getting started, loading and inspecting a model.
Growth media and conditions, what the exchange bounds mean and how to define a medium.
MATLAB vs Python, the full function mapping.