17. Gap-filling#
A draft model always has holes: reactions that cannot carry flux because something upstream is missing, metabolites nothing produces, a biomass component the network cannot make. Gap-filling adds reactions from a template until the holes close, and the judgement is in deciding which holes are worth closing.
Functions on this page#
MATLAB |
Python |
|
|---|---|---|
find the holes first |
||
add template reactions so blocked reactions can carry flux |
||
the LP formulation (fastGapFill / swiftGapFill) |
||
the MILP formulation, when the LP is not enough |
||
connectivity, without solving an LP |
||
fill until a task passes; see 15. Metabolic tasks |
Setup#
A gap-filling example needs a draft with holes and a template to fill them from.
smallYeast.yml serves as both: open its medium, take a copy, and remove a
reaction. What gap-filling then puts back is checkable by eye: it should be the
reaction that was removed.
template = readYAMLmodel('smallYeast.yml');
template = setParam(template, 'ub', {'glcIN', 'o2IN'}, [1 1000]);
draft = removeReactions(template, {'ADH1'}); % alcohol dehydrogenase
draft.id = 'draft'; % fillGaps refuses a template sharing the model's id
fprintf('draft %d rxns, template %d rxns\n', numel(draft.rxns), numel(template.rxns));
draft 52 rxns, template 53 rxns
import cobra
from raven_toolbox.io import read_yaml_model
cobra.Configuration().processes = 1 # FVA below spawns workers otherwise
template = read_yaml_model("smallYeast.yml")
template.reactions.get_by_id("glcIN").upper_bound = 1.0
template.reactions.get_by_id("o2IN").upper_bound = 1000.0
draft = template.copy()
draft.remove_reactions([draft.reactions.get_by_id("ADH1")]) # alcohol dehydrogenase
print(f"draft {len(draft.reactions)} rxns, template {len(template.reactions)} rxns")
draft 52 rxns, template 53 rxns
Note the medium. Shipped shut, smallYeast has 51 of its 53 reactions
blocked: not because it has gaps, but because nothing can get in. Gap-fill
a model in that state and you are asking the wrong question entirely.
17.1 Find the holes before filling them#
Gap-filling without looking first is how a model acquires reactions nobody can justify. Start from what cannot carry flux.
canCarry = haveFlux(draft);
fprintf('%d of %d reactions can carry flux\n', sum(canCarry), numel(draft.rxns));
50 of 52 reactions can carry flux
from cobra.flux_analysis import find_blocked_reactions
blocked = find_blocked_reactions(draft)
print(f"{len(blocked)} of {len(draft.reactions)} reactions are blocked")
print(sorted(blocked)[:5])
2 of 52 reactions are blocked
['ethIN', 'ethOUT']
17.2 Connectivity gap-filling#
The question here is structural: which template reactions, added to the draft, would let a blocked reaction carry flux at all? No objective, no growth, just connectivity.
% fillGaps solves a MILP: GLPK warns and solves it, setRavenSolver('gurobi') is faster
[newConnected, cannotConnect, addedRxns] = fillGaps(draft, {template}, ...
'allowNetProduction', true, 'useModelConstraints', false);
fprintf('%d added: %s\n', numel(addedRxns), strjoin(addedRxns, ', '));
[Warning: Solving a MILP with GLPK. This works for small problems, but for larger problems Gurobi is much faster and more reliable; select it with setRavenSolver('gurobi'). Silence this warning with warning('off','RAVEN:glpkMILP').]
1 added: ADH1
from raven_toolbox.gapfilling import connect_blocked_reactions
result = connect_blocked_reactions(draft, template, allow_net_production=True)
print(f"{len(result.added_reactions)} added: "
f"{', '.join(sorted(result.added_reactions))}")
1 added: ADH1
The reaction that comes back is the one that was taken out, in both
toolboxes. That identity is the result to report. fillGaps also
returns counts of newly connected and still unconnectable reactions, and
those are not stable: it solves a MILP, several solutions are equally
optimal, and which one comes back can change with the machine or the thread
count. Two CI runs on identical input reported 11 and 9 newly connected.
Both are MILPs. connect_blocked_reactions runs on the GLPK that ships with
cobrapy; fillGaps runs on RAVEN's bundled GLPK too, with a warning that
recommends Gurobi, which is the solver this tab selects.
allow_net_production decides how strict the test is. A reaction can be
blocked because a substrate is unavailable or because a product has nowhere
to go; with net production allowed, only the first counts. Turning it off asks
the harder question and usually adds more reactions.
17.3 The LP formulation#
Note the argument: fillGaps takes a cell array of template models, while
gapFillFastLP and gapFillMILP take a single struct. Passing a cell to
these two fails with Dot indexing is not supported for variables of this type.
fill_gaps_fast_lp (fastGapFill, and its swift variant) solves one LP per
blocked reaction: activate this reaction using as few template reactions as
possible. It is the fast option, and the one that scales to a genome-scale
draft.
[addedRxns, outModel] = gapFillFastLP(draft, template); % LP, GLPK is enough
fprintf('%d reactions added\n', numel(addedRxns));
gapFillFastLP: 2 blocked reaction(s) found in draft model.
[Warning: The following reaction IDs in smallYeast are already present in the model and were renamed: Old: acOUT New: acOUT_smallYeast Old: biomassOUT New: biomassOUT_smallYeast Old: co2OUT New: co2OUT_smallYeast Old: ethOUT New: ethOUT_smallYeast Old: glyOUT New: glyOUT_smallYeast Old: glcIN New: glcIN_smallYeast Old: o2IN New: o2IN_smallYeast Old: ethIN New: ethIN_smallYeast Old: HXK New: HXK_smallYeast ...and 43 more]
gapFillFastLP: 1 reaction(s) cannot be rescued by the universal database.
gapFillFastLP: 1/2 blocked reaction(s) are rescuable; running fast...
gapFillFastLP: added 5 reaction(s) from universal database.
5 reactions added
from raven_toolbox.gapfilling import fill_gaps_fast_lp
result = fill_gaps_fast_lp(draft, template, verbose=False)
print(f"{len(result.added_reactions)} reactions added")
1 reactions added
The two tabs add 5 and 1 (MATLAB adds 8 with Gurobi: the LP has alternative
optima, and the solver picks one). They are solving the same idea with different
inputs: the MATLAB call merges the whole template into the draft first,
renaming every id it already has, which is what the rename warning above
lists. Its universal database therefore holds a second copy of the
draft's own reactions, and the LP is free to rescue a blocked reaction
through those duplicates. fill_gaps_fast_lp matches by id and treats
the shared reactions as already present, so only the missing one is a
candidate. On two models that genuinely differ, which is the real case,
the gap between the counts closes.
17.4 The MILP formulation#
When the LP formulation adds too much, or you want the provably smallest set, the mixed-integer formulation is the alternative. GLPK solves it at this size in both toolboxes (RAVEN warns and recommends Gurobi); on anything genome-scale it is slower by a wide margin, and Gurobi is the better choice.
[addedRxns, outModel] = gapFillMILP(draft, template);
fprintf('%d reactions added\n', numel(addedRxns));
[Warning: The following reaction IDs in smallYeast are already present in the model and were renamed: Old: acOUT New: acOUT_smallYeast Old: biomassOUT New: biomassOUT_smallYeast Old: co2OUT New: co2OUT_smallYeast Old: ethOUT New: ethOUT_smallYeast Old: glyOUT New: glyOUT_smallYeast Old: glcIN New: glcIN_smallYeast Old: o2IN New: o2IN_smallYeast Old: ethIN New: ethIN_smallYeast Old: HXK New: HXK_smallYeast ...and 43 more]
gapFillMILP: merged model has 52 mets, 105 rxns (52 draft, 52 universal).
gapFillMILP: 52 database candidates.
gapFillMILP: setting minGrowth = 0.02443 (10% of max 0.2443).
gapFillMILP: solving MILP (157 vars, 52 binary, 157 constraints)...
[Warning: Solving a MILP with GLPK. This works for small problems, but for larger problems Gurobi is much faster and more reliable; select it with setRavenSolver('gurobi'). Silence this warning with warning('off','RAVEN:glpkMILP').]
gapFillMILP: added 0 universal reaction(s).
0 reactions added
from raven_toolbox.gapfilling import fill_gaps_milp
result = fill_gaps_milp(draft, template)
print(f"{len(result.added_reactions)} reactions added")
fill_gaps_milp: merged model has 53 reactions (52 draft, 1 template).
fill_gaps_milp: setting min_growth = 0.01222 (10% of FBA max 0.1222).
fill_gaps_milp: solving MILP (1 database candidates)...
fill_gaps_milp: added 0 template reaction(s).
0 reactions added
Zero is the right answer here: without ADH1 the draft still grows at the
full 0.1222 /h, so it already meets the default floor (10 % of the merged
model's maximum) and the MILP has nothing to buy. In MATLAB that floor is twice
as high, 0.02443 against 0.01222, because gapFillMILP merges the template's
duplicates of the draft's reactions, its uptake reactions included, so the
merged model can take up twice the glucose. To see a repair, remove a reaction
growth needs, such as hexokinase:
draft2 = removeReactions(template, {'HXK'}); % hexokinase
draft2.id = 'draft2';
sol = solveLP(draft2);
fprintf('draft growth %.4f\n', sol.f);
addedRxns = gapFillMILP(draft2, template);
fprintf('added: %s\n', strjoin(addedRxns, ', '));
draft growth -0.0000
[Warning: The following reaction IDs in smallYeast are already present in the model and were renamed: Old: acOUT New: acOUT_smallYeast Old: biomassOUT New: biomassOUT_smallYeast Old: co2OUT New: co2OUT_smallYeast Old: ethOUT New: ethOUT_smallYeast Old: glyOUT New: glyOUT_smallYeast Old: glcIN New: glcIN_smallYeast Old: o2IN New: o2IN_smallYeast Old: ethIN New: ethIN_smallYeast Old: PGI New: PGI_smallYeast ...and 43 more]
gapFillMILP: merged model has 52 mets, 105 rxns (52 draft, 52 universal).
gapFillMILP: 52 database candidates.
gapFillMILP: setting minGrowth = 0.02443 (10% of max 0.2443).
gapFillMILP: solving MILP (157 vars, 52 binary, 157 constraints)...
[Warning: Solving a MILP with GLPK. This works for small problems, but for larger problems Gurobi is much faster and more reliable; select it with setRavenSolver('gurobi'). Silence this warning with warning('off','RAVEN:glpkMILP').]
gapFillMILP: added 1 universal reaction(s).
added: HXK
draft2 = template.copy()
draft2.remove_reactions([draft2.reactions.get_by_id("HXK")]) # hexokinase
print(f"draft growth {draft2.slim_optimize():.4f}")
result = fill_gaps_milp(draft2, template, verbose=False)
print(f"added: {result.added_reactions}")
print(f"filled growth {result.model.slim_optimize():.4f}")
draft growth 0.0000
added: ['HXK']
filled growth 0.1222
Hexokinase comes back. Both functions only add template reactions: the draft's own reactions keep their bounds, forced fluxes such as ATP maintenance included. A draft reaction that runs in the wrong direction is therefore not reversed to close a gap; it is a curation finding, so correct its bounds in the draft (see 8. Editing an existing model) and fill again.
17.5 Filling towards a task, not a hole#
Connectivity gap-filling asks "can this reaction carry flux?". The other question ("can the model still do this?") is answered by filling against a task list, which is the form the question usually takes: a model that grows, or that produces a particular compound.
tasks = parseTaskList('tasks.txt');
[outModel, addedRxns] = fitTasks(draft, template, [], 'taskStructure', tasks);
[Warning: Exchange metabolites should normally not be removed from the model when using checkTasks. Inputs and outputs are defined in the task file instead. Use importModel(file,false) to import a model with exchange metabolites remaining]
[GROWTH] Growth on glucose: Added 0 reaction(s), 0 reactions added in total
[Warning: "[LEAK] Biomass from nothing" is set as SHOULD FAIL. Such tasks cannot be modelled using this approach and the task is therefore ignored]
from raven_toolbox.init import fill_tasks
See 15. Metabolic tasks for the task list itself, and
14. Context-specific models for fill_tasks in its usual role,
repairing a model that ftINIT has just cut down.
fitTasks fills one task at a time rather than all of them at once, so
the order of the task list can change the result. Its rxnScores weights
the candidates, taking negative scores only and maximising their sum, so a
reaction with a score closer to zero is cheaper to add; that is the hook
ftINIT uses to prefer reactions its expression data supports.
gapFillMode picks the back-end: 'merge' combines the reference model
with the draft for every task, and 'preMerged' expects a reference that
already contains the draft, which is the form ftINIT passes.
Warning
What can go wrong
Identifiers that do not line up. Gap-filling can only add what it can match. Two models built from different databases share almost no metabolite ids, and the result is a gap-filler that adds nothing, or adds duplicates.
Filling every gap. Some blocked reactions are blocked because the organism genuinely cannot do them. Each addition is a claim about biology, and
fillGapswill make hundreds of them in one call.Net production hiding the problem.
allowNetProductionmakes more reactions look connectable by ignoring where the products go. Useful early, misleading later.MILP on a genome-scale model. Expect it to be slow, on GLPK most of all, and give it a time limit; see 6. Solvers and configuration.
A draft reaction in the wrong direction.
gapFillMILPandfill_gaps_milpdo not change the draft's own reactions, so a wrong direction shows up as a fill that fails or adds a detour around it.Comparing added-reaction counts across toolboxes. The two sides differ in what they treat as a candidate, so the counts are only comparable when the template genuinely holds reactions the draft lacks.
See also#
9. Quality control, finding the holes, and deciding which matter.
15. Metabolic tasks, the other way to say what the model must do.
3. Reading and writing models, saving the filled model with a record of what was added.