StrainDesign Developer’s Guide
This guide explains how StrainDesign works internally — the mechanics, the mathematics, and the rationale behind each stage of a strain-design / Minimal Cut Set (MCS) computation. It is written for developers and contributors who want to understand, extend, debug, or optimize the package, rather than just use it. For usage, see the tutorial notebooks and the API reference.
For every processing stage it answers what the code does, how it works (including the underlying linear-algebra and optimization theory), and why it is built that way.
Audience. A scientific programmer comfortable with linear and mixed-integer programming and
constraint-based metabolic modeling, but new to this codebase. The chapters are largely self-contained,
though the notation is established in Chapter 1 and the LP/duality groundwork in Chapters 2 and 6. Code
is cited as file.py:line; line numbers are anchors that drift with edits, so treat them as pointers,
not addresses.
How to read this guide
New to strain design / this package: Chapter 1 (problem + notation) → Chapter 2 (LP foundation) → then follow the pipeline order, Chapters 3–9.
Optimizing performance: Chapter 11 (bottleneck profile + levers) first, then Chapter 3 (compression), Chapter 5 (the preprocessing FVA), Chapter 7 (MILP conditioning), Chapter 8 (enumeration).
Debugging correctness: Chapter 10 (failure modes), then the relevant mechanism chapter (Chapter 4 GPR, Chapter 3 compression, Chapter 9 solution semantics).
For the mathematics: Chapter 2 (polytope/LP) → Chapter 6 (duality, Farkas, strong-duality reuse) → Chapter 7 (big-M vs indicator linearization) → Chapter 8 (integer cuts).
Chapters at a glance
Orientation & the strain-design problem — the MCS problem, SUPPRESS/PROTECT/bilevel semantics, interventions & cost, the binary
zvector, invocation, and the master notation table.The constraint-based foundation —
Sv=0, the flux polytope/cone, FBA & FVA as LPs, the internal standard form, and the convex geometry needed for duality.Network compression — why compress; the exact integer/rational nullspace (fraction-free RREF, big-int path); parallel, coupled (kernel-proportionality + bound intersection), conservation-relation, and blocked/zero-flux reductions; the alternating fixpoint; GPR AND/OR propagation; the compression map; and the legacy efmtool Java backend.
GPR integration — why gene KOs are encoded as flux structure;
extend_model_gprpseudo-metabolite construction (AND/OR), the flux-space-invariance argument, reversible split &reac_map;reduce_gpr; the two-pass boundary and the regulatory-gene exemption.FVA in preprocessing — the three FVA uses and their rationale;
bound_blocked_or_irrevers_fvabound relaxation and its MILP effect; size-1 MCS extraction; thespeedy_fvaacceleration algorithm.Dualization (the mathematical core) — LP duality & complementary slackness; Farkas’ lemma and the SUPPRESS infeasibility certificate (why the dual ray is unbounded); strong-duality encoding of bilevel problems and why the one
LP_dualizeoperation is reusable across OptKnock/RobustKnock/OptCouple/DoubleOpt.MILP construction & the z-linking — the seed cost rows,
num_z, block-diagonal module assembly,prevent_boundary_knockouts;link_z: per-constraint big-M from a bounding LP vs native indicator constraints, the bound-driven fork, and why indicators give a tighter relaxation.Solving & enumeration — ANY/BEST/POPULATE objective setups; the iterative loop and superset-excluding integer cuts; solver parameters; the CPLEX-vs-Gurobi gap.
Decompression & solution semantics — reverse-map expansion of compressed interventions; size-1 MCS re-injection;
filter_sd_maxcost; the KI value-0/(nan,nan)&strip_non_kiencoding; gene↔reaction translation.Known issues, gotchas & failure modes — neutral-gene-KO paths and superset artifacts with mechanism; the in-place dict-mutation footgun; name truncation; numeric-status robustness.
Performance, benchmarking & roadmap — the bottleneck profile; the lever groups; benchmarking discipline (multi-seed, known-answer gates, MCS2/gMCSpy).
Model surgery & constraint parsing — the utility layer:
remove_ext_mets, regulatory-intervention encoding,gene_kos_to_constraints, module/cost remapping through compression, andparse_constr(strings → matrix rows).The object model & result API —
SDModule(types, validation),SDSolutions(result access, KO/KI encoding, lazy expansion, save/load), thesd_setupbundle, and the preprocessed-dump workflow.The solver-interface layer —
MILP_LPand the four backends: how indicators/big-M/populate/status/params map onto CPLEX, Gurobi, SCIP, GLPK.Analysis & exploration API — the standalone tools (not part of the compute pipeline):
fba/fva,yoptyield optimization,plot_flux_space(production envelopes, yield spaces), and the compressed-analysis tools.
Repository structure
straindesign/
├── __init__.py # Package exports & avail_solvers detection
├── names.py # All string constants (solver names, module types, etc.)
│
├── compute_strain_designs.py # Main user-facing orchestration function
├── strainDesignModule.py # SDModule: problem specification (a dict subclass)
├── strainDesignProblem.py # SDProblem: translates model+modules → MILP matrices
├── strainDesignMILP.py # SDMILP: solves the MILP, manages the solution loop
├── strainDesignSolutions.py # SDSolutions: result container, GPR translation
│
├── compression.py # Network compression (RREF, RationalMatrix, nullspace)
├── networktools.py # GPR extension, regulatory extension, compress wrappers, LP suppression
├── lptools.py # FVA, FBA, flux-space plotting, solver selection
├── speedy_fva.py # Accelerated FVA (scan-LP + push-to-bounds)
├── parse_constr.py # Constraint/expression string → matrix conversion
├── indicatorConstraints.py # IndicatorConstraints data class
├── solver_interface.py # MILP_LP factory: instantiates the right backend
├── cplex_interface.py # CPLEX backend (Cplex_MILP_LP)
├── gurobi_interface.py # Gurobi backend (Gurobi_MILP_LP)
├── scip_interface.py # SCIP backend (SCIP_MILP_LP)
├── glpk_interface.py # GLPK backend (GLPK_MILP_LP)
├── efmtool_cmp_interface.py # EFMtool JAR interface (legacy compression backend)
├── pool.py # SDPool: cross-platform multiprocessing pool
└── efmtool.jar # Bundled EFMtool binary
Which chapter covers which module: compression → Ch 3; GPR / networktools → Ch 4, Ch 12; FVA / lptools /
speedy_fva → Ch 5, Ch 15; dualization & problem build (strainDesignProblem.py) → Ch 6, Ch 7; the solve
loop (strainDesignMILP.py) → Ch 8; results (strainDesignSolutions.py) → Ch 9, Ch 13; solver
interfaces → Ch 14; parsing → Ch 12.
1. Orientation & the strain-design problem
This chapter is the entry point for the whole reference. It states the strain-design / Minimal Cut Set (MCS) problem in both plain and formal terms, fixes the notation that every later chapter reuses, defines the two atomic building blocks (SUPPRESS and PROTECT) and the bilevel variants that generalize them, explains what “an intervention” and “minimal” mean in this codebase, sketches the end-to-end pipeline (with forward references to the chapters that work out each stage), and shows exactly how the package is invoked. Nothing here is proved; the mathematics that is worked out later (LP duality, Farkas certificates, big-M, integer cuts, exact compression) is named and pointed at, not reproduced.
1.1 The metabolic model
A constraint-based metabolic model is, for our purposes, a linear description of the space of steady-state flux distributions a cell can sustain. Three objects define it.
Stoichiometric matrix
S ∈ ℝ^{m×n}. Rows are theminternal metabolites, columns are thenreactions. EntryS[i,j]is the signed stoichiometric coefficient of metaboliteiin reactionj(negative = consumed, positive = produced). Instraindesignthis is a scipy sparse matrix; it is never formed densely for genome-scale models (iML1515 hasm ≈ 1877,n ≈ 2712).Flux vector
v ∈ ℝ^n. Componentv_jis the net rate (mmol · gDW⁻¹ · h⁻¹) of reactionj. Fluxes are the decision variables of every LP in the package; a “strain” is not identified with one flux vector but with the whole set of flux vectors its network admits.Bounds
lb, ub ∈ (ℝ ∪ {±∞})^n, applied component-wise aslb ≤ v ≤ ub. A reaction is irreversible whenlb_j ≥ 0(orub_j ≤ 0), reversible whenlb_j < 0 < ub_j. Exchange/boundary reactions carry the medium definition through their bounds (e.g.EX_glc__D_ewithlb = -10fixes maximum glucose uptake).
The steady-state assumption — that internal metabolite pools neither accumulate nor deplete — is written as the homogeneous balance
S v = 0.
Together with the bounds this carves out the flux polytope
P = { v ∈ ℝ^n : S v = 0, lb ≤ v ≤ ub }. (1.1)
P is a convex polyhedron (a pointed cone truncated by the finite bounds). Its full
linear-algebraic structure — why steady state is a null-space condition, why P is a cone
when bounds are 0/±∞, how FBA maximizes a linear objective over P and how FVA sweeps each
coordinate’s range — is the subject of Ch 2. For this chapter, P is simply the set of flux
behaviors the unmodified model permits.
A flux behavior (used informally throughout) is any linearly-describable subset of P:
“growth ≥ 0.1”, “ethanol export ≥ 5 while growth is maximal”, “no net product at all”. Every
strain-design module names such a subset and declares whether it must be destroyed or
preserved.
1.2 Desired vs. undesired flux regions
Strain design starts from a partition of flux space into behaviors we want and behaviors we do
not. Write a linear flux region as a system of linear (in)equalities on v:
D = { v ∈ P : A_ineq^{(D)} v ≤ b^{(D)}, A_eq^{(D)} v = b_eq^{(D)} }.
Two roles:
an undesired region
D⁻is a behavior we want the engineered strain to be unable to exhibit — e.g. “the cell grows but makes no target product”, or simply “the cell grows at all” (for a lethal knockout set). The goal is to makeD⁻ ∩ P' = ∅in the modified networkP'.a desired region
D⁺is a behavior we want the engineered strain to retain — e.g. “growth of at least 0.1 h⁻¹ is still achievable”. The goal is to keepD⁺ ∩ P' ≠ ∅.
An intervention modifies the network — most commonly by forcing some reactions’ fluxes to
zero (a knockout) — turning P into a smaller (or, for knock-ins, larger) polytope P'. The
strain-design problem is: choose the cheapest set of interventions such that every undesired
region becomes infeasible while every desired region stays feasible. SUPPRESS and PROTECT are
exactly the machine encodings of “make D⁻ infeasible” and “keep D⁺ feasible”.
A subtle but load-bearing convention: the zero vector v = 0 lies in P for essentially every
model (all reactions off is trivially steady-state and within bounds). A SUPPRESS/PROTECT
region must therefore be defined so it excludes v = 0; otherwise “make the region
infeasible” is impossible (you cannot knock out the do-nothing state) and “keep it feasible” is
vacuous. The SDModule constructor enforces this for modules that carry an inner objective:
strainDesignModule.py runs an FBA with every reaction pinned to 0 and rejects the
module if that trivial point satisfies the constraints. This is why a lethality SUPPRESS is
written growth ≥ 0.001 and not growth ≥ 0 — the strict-ish positive threshold pushes the
target region off the origin.
1.3 SUPPRESS and PROTECT: precise semantics
These are the two atomic module types. Both take a constraints list describing a flux region;
they differ only in what the solver is asked to guarantee about that region in the engineered
strain.
SUPPRESS — make the region infeasible. Given a region
D⁻ = { v : Sv = 0, lb ≤ v ≤ ub, T v ≤ t } (T, t encode the module's constraints)
a SUPPRESS module demands that after intervention no flux vector satisfies all of these
simultaneously: D⁻ ∩ P' = ∅. “Growth without production is impossible”, “growth is
impossible” (lethality), “the yield falls below the threshold at maximum growth” are all
SUPPRESS behaviors. Mechanically this is the hard case: to certify that a linear system has no
solution you cannot just exhibit a point, you must produce an infeasibility certificate. The
package builds one via Farkas’ lemma / LP duality — the module is dualized so that a bounded
dual ray exists iff the primal region is empty, and the intervention variables z are wired
to force such a ray to exist. That dualization is the mathematical core of the package and is
worked out in full in Ch 6 (farkas_dualize, strainDesignProblem.py); z-linking of the dual
rows is Ch 7.
PROTECT — keep the region feasible. Given a region D⁺ described the same way, a PROTECT
module demands that after intervention at least one flux vector still satisfies all
constraints: D⁺ ∩ P' ≠ ∅. “Growth of ≥ 0.1 remains possible”, “the model stays feasible at
all” are PROTECT behaviors. This is the easy case: feasibility is certified by a witness flux
vector, so PROTECT contributes the region’s constraints as raw primal rows to the MILP —
the same Sv=0, lb≤v≤ub, Tv≤t block, with the z-linking arranged so that a knocked-out
reaction drops out of that primal system. No dualization is needed for a bare PROTECT.
This primal/dual asymmetry is fossilized in the internal name constants
(names.py): PROTECT was historically 'mcs_lin' (a linear/primal feasibility
block) and SUPPRESS was 'mcs_bilvl' (a bilevel/dualized block), before both were renamed to
'protect' and 'suppress'. The renaming is cosmetic; the linear-vs-dual split it encoded is
still exactly how the two module types are assembled.
The classical MCS = one SUPPRESS + PROTECT. A Minimal Cut Set in the original sense of
Klamt & Gilles is the smallest set of reaction deletions that blocks a specified undesired
behavior while (optionally) sparing desired ones. In this package that is written as exactly
one SUPPRESS module (the behavior to eliminate) together with zero or more PROTECT modules
(behaviors to preserve). If there are no PROTECT modules, an MCS just makes the SUPPRESS region
empty (classic lethality/blocking). The code recognizes this canonical shape explicitly:
compute_strain_designs.py sets is_classical_mcs true precisely when there is one
SUPPRESS and every other module is a PROTECT, and only then does it attempt the size-1 MCS
shortcut (§1.6, Ch 5). The number of SUPPRESS and PROTECT modules is otherwise unrestricted and
they can be freely combined; several SUPPRESS modules just mean several regions must all be
eliminated at once.
Optionally, either module may carry an inner_objective. Then the region is not “all v
satisfying the constraints” but “all v that are optimal for the inner objective and also
satisfy the constraints”. SUPPRESS-with-inner-objective says “flux states that are optimal for
(say) growth and also over-produce a by-product must be impossible”; this couples an
optimization inside the feasibility question and therefore uses the same dualization
machinery as the bilevel modules below (Ch 6). An inner_opt_tol < 1 relaxes “optimal” to
“within a fraction of optimal” (strainDesignModule.py,
strainDesignProblem.py).
1.4 The bilevel variants (conceptual only)
MCS reasons about whole flux regions. A second family of modules reasons about what a cell will
do if it optimizes its own objective — the biologically realistic assumption that a strain
grows as fast as its network allows. These are bilevel problems: an outer design objective
subject to an inner cellular optimization. straindesign supports four, of which at most one
may appear in a computation (compute_strain_designs.py); they may still be combined
with any number of SUPPRESS/PROTECT modules.
OptKnock — maximize an outer objective (e.g. product export) over the flux state that maximizes an inner objective (e.g. growth). Answers “what knockouts give the highest possible product synthesis at the growth-optimal flux state?” It bounds the production potential, not guaranteed production.
RobustKnock — max–min: maximize the worst-case outer objective over all growth-optimal flux states. Guards against the alternative-optima loophole of OptKnock (the cell could pick a growth-optimal state that makes nothing); it maximizes the guaranteed production.
OptCouple — maximize the growth-coupling potential: the gap between max growth without production and max growth overall. Drives designs where growth forces production.
inner-objective SUPPRESS/PROTECT, and
DOUBLEOPT— as in §1.3, feasibility modules whose region is defined relative to an inner optimum.DOUBLEOPTis a distinct, fully validated module type (names.py), so the complete type set is six: PROTECT, SUPPRESS, OPTKNOCK, ROBUSTKNOCK, OPTCOUPLE, DOUBLEOPT (matching Ch 13’s enumeration).
Conceptually, all four reduce to the same trick: replace “v optimizes the inner LP” with the
LP’s strong-duality condition (primal feasible + dual feasible + zero duality gap), which is
a set of linear constraints the outer MILP can carry. That is why one dualization routine
(LP_dualize) serves every bilevel case. The exact primal/dual constructions, the max–min
handling, and the growth-coupling-potential formula are Ch 6. This chapter only needs the reader
to know that these modules exist, that they set the global objective of the computation (see
§1.5), and that mechanically they are “SUPPRESS/PROTECT with an optimization welded inside”.
1.5 Interventions, costs, the binary vector z, and “minimal”
Intervention kinds.
Knockout (KO) — force a reaction (or, via GPR, a gene) permanently off. In the MILP a KO is expressed by driving the reaction’s flux to 0 when its intervention variable is active.
Knock-in (KI) — add a reaction to the network; its cost is incurred by keeping it, and it is free to omit. KI is handled as an inverted KO: the same
zmachinery with the sense flipped. The reaction must already exist in the model with the bounds it would have after insertion (compute_strain_designs.py/docstring).Regulatory — impose (or remove) a linear flux constraint as an intervention, e.g. “
EX_o2_e = -1” to model a forced aeration change. Reaction-based regulatory constraints are added during preprocessing viaextend_model_regulatory; gene-based ones are deferred until after GPR integration (Ch 4). Costs live inreg_cost.
Costs. Every candidate intervention carries a positive cost; max_cost bounds the total.
Costs are supplied per-kind: ko_cost, ki_cost (reactions), gko_cost, gki_cost (genes),
reg_cost (regulatory). Defaults: with reaction interventions, every reaction is a KO
candidate at cost 1 (compute_strain_designs.py); with gene_kos=True, every gene is a
KO candidate at cost 1 (:253-257). Supplying a partial dict restricts candidacy to the
listed items — anything not listed is simply not knockable. Essential reactions/genes (those
whose removal would break a PROTECT or desired region) have their cost entries dropped during
preprocessing so they are never proposed (:381, :494; Ch 5).
The binary vector z. After preprocessing, the model has been compressed and GPR-extended;
SDProblem.__init__ allocates one binary variable per (compressed) reaction: num_z = numr
(strainDesignProblem.py), z ∈ {0,1}^{num_z}. z_j = 1 means “reaction j is
intervened” (knocked out, or — for a KI reaction, whose sense is inverted — kept in). The cost
data is compiled (strainDesignProblem.py) into three aligned per-reaction arrays:
cost[j]— the intervention cost of reactionj(0 ifjis not targetable);z_inverted[j]— true iffjis a KI (aki_costentry present), meaningz_j’s sense is flipped so cost is paid for presence;z_non_targetable[j]— true iffjhas neither a KO nor KI cost, soz_jis fixed to 0 (ub[j] = 1 − z_non_targetable[j],strainDesignProblem.py).
KIs override KOs when both are given (:143 blanks the KO cost wherever a KI cost exists). The
resulting cost vector feeds the two budget rows placed at the very top of the MILP
(strainDesignProblem.py): a row Σ cost_j z_j ≤ max_cost (the idx_row_mincost
row, b_ineq[1] = max_cost) and a companion −Σ cost_j z_j ≤ 0 row (idx_row_maxcost), plus a
reserved objective row. The exact meaning of these two rows and their interaction with KI
inversion is Ch 7; here they matter only as the place where max_cost enters.
What “minimal” means. For an MCS-only computation (all modules SUPPRESS/PROTECT), the
global objective is to minimize total intervention cost Σ cost_j z_j
(strainDesignProblem.py sets c ← cost and flags is_mcs_computation). “Minimal” has
two precisions the reader must keep distinct, and they map onto the solution_approach kwarg
(§1.7, Ch 8):
irreducible (the
'any'approach): the intervention set contains no proper subset that is itself a valid design — you cannot drop any single intervention and still block every SUPPRESS region. This is what “Minimal Cut Set” strictly means.cardinality/cost-minimal (
'best'/'populate'): among all valid designs, one of globally least total cost. Every cost-minimal design is irreducible, but not vice versa.
If a bilevel module is present, the global objective is not cost minimization — it is the
module’s own objective (OptKnock/RobustKnock outer objective, OptCouple’s growth-coupling
potential), and max_cost merely bounds how many interventions the design may spend
(strainDesignProblem.py installs the module objective into the objective row instead).
The max_cost bound is the same in both regimes: no design may exceed it, and it is the primary
lever that keeps the enumeration tractable (the canonical benchmarks all cap it at 2–6).
1.6 The end-to-end pipeline at a glance
compute_strain_designs(model, **kwargs) (compute_strain_designs.py) is the orchestrator.
Its stages, in order, with the chapter that details each:
Parse & validate (
:178-304) — resolvesd_setupvs. explicit kwargs, select the solver, seed the RNG, normalize cost dicts, reject overlapping gene/reaction candidates, rename genes whose IDs start with a digit, and re-validate each module’s constraints against the chosen solver. (This chapter, §1.7.)Preprocess — the bulk of wall-time (measured ~117 s of blocked/irreversible FVA on the iML1515 gene-MCS benchmark). It interleaves several transformations:
remove_ext_metsand reaction-based regulatory constraints (:310-330).Compression pass #1 (
compress_model(..., propagate_gpr=True),:357): lossless, exact integer/rational network compression on the metabolic model before gene pseudo-reactions exist — Ch 3.FVA #1 (
:373-381): flux-variability analysis on each desired/PROTECT module to find reactions essential to those behaviors, and drop them from the knockable set — Ch 5.GPR integration (
:383-422, only ifgene_kos):reduce_gprprunes irrelevant genes, thenextend_model_gprencodes the Boolean gene–protein–reaction rules as flux structure (gene pseudo-metabolites / pseudo-reactions) so that a gene knockout becomes an ordinary reaction-level constraint in the same MILP; module references are remapped throughreac_map— Ch 4.Compression pass #2 (
compress_model(...),propagate_gprdefault,:434): compress the now GPR-extended network — Ch 3/4.FVA #2 (
bound_blocked_or_irrevers_fva,:450): relax non-binding bounds to ±∞ and pin blocked/irreversible reactions to 0, which tightens the downstream big-M/indicator linearization — Ch 5.FVA #3 (knockable-scoped,
:454-494): find reactions essential to SUPPRESS vs. PROTECT and, for a classical MCS problem, extract size-1 MCS (single reactions whose removal alone blocks the SUPPRESS region) so they need not be re-discovered by the MILP — Ch 5.
Build the MILP (
SDMILP(cmp_model, sd_modules, **kwargs_milp),:518; Ch 7). Each module is appended byaddModuleas a block: SUPPRESS → dualized Farkas infeasibility rows, PROTECT → raw primal feasibility rows, bilevel → strong-duality rows (Ch 6). Thenlink_zwires the binaryzto those continuous rows, as native indicator constraints or big-M depending on bound structure (Ch 7).Solve / enumerate (Ch 8):
compute(ANY),compute_optimal(BEST), orenumerate(POPULATE). Found designs are excluded by iterative integer cuts so the next solve returns a genuinely new design.Decompress (
_decompress_solutions,:589; Ch 9):expand_sdreverses the two compression maps to recover interventions on original reactions, re-injects the size-1 MCS, filters bymax_cost, and translates reaction designs to gene designs via the cobra GPR AST.
Chapters 2–5 cover preprocessing, 6–7 the MILP construction, 8 the solve loop, 9 decompression, 10 known gotchas, and 11 performance and roadmap.
1.7 How the package is invoked
The whole computation is one function call. The canonical e_coli_core gene-MCS benchmark
(tests/test_09_performance.py) is:
import straindesign as sd
from straindesign.names import SUPPRESS, POPULATE
sol = sd.compute_strain_designs(
model, # a cobra.Model
sd_modules=[sd.SDModule(model, SUPPRESS,
constraints="BIOMASS_Ecoli_core_w_GAM >= 0.001")],
solution_approach=POPULATE, # enumerate all cost-minimal designs
max_cost=3, # ≤ 3 interventions
gene_kos=True, # knock out genes, not reactions
solver=solver, # 'cplex' | 'gurobi' | 'glpk' | 'scip'
)
# sol.reaction_sd -> list of reaction-level designs (455 for this problem)
# sol.gene_sd -> the corresponding gene-level designs
This one SUPPRESS module says “flux states with biomass ≥ 0.001 must become impossible” — i.e.
find gene knockout sets that are lethal — and POPULATE asks for all minimal such sets
(455 of them; CPLEX ≈ 1.2 s). No PROTECT module is present, so nothing is preserved beyond
model feasibility.
Constructing an SDModule (strainDesignModule.py). Signature:
SDModule(model, module_type, *args, **kwargs). module_type is one of 'suppress',
'protect', 'optknock', 'robustknock', 'optcouple'. The constructor:
parses
constraintsinto canonical[{reac: coeff, …}, op, rhs]triples viaparse_constraints(:290-291); the string"BIOMASS_Ecoli_core_w_GAM >= 0.001"and the list forms["-EX_o2_e <= 5", "ATPM = 20"]and[[{'EX_o2_e':-1},'<=',5], …]are all accepted (:144-152);parses
inner_objective/outer_objective/prod_idfrom string or dict into{reac: coeff}maps (:296-308);validates that the module type has the arguments it needs (OptKnock/RobustKnock require inner and outer objectives,
:248-257; OptCouple requires an inner objective andprod_id,:258-268), and that senses/tolerances are legal (:277-282);unless
skip_checks=True, runs an FBA to confirm the region is feasible in the original model and (for inner-objective modules) thatv = 0is excluded (:311-320).
A dummy object with just an id may stand in for the model if skip_checks=True and
reac_ids=[…] are supplied (:239-242, 284-285).
Key compute_strain_designs kwargs (docstring :70-166, handling :174-534):
kwarg |
meaning |
default |
|---|---|---|
|
list of |
required |
|
|
|
|
|
model default |
|
upper bound on total intervention cost |
|
|
cap on MILP solutions generated (designs returned may exceed this after decompression) |
|
|
knock out genes (triggers GPR integration) instead of reactions |
|
|
per-reaction KO / KI cost dicts (partial dict restricts candidacy) |
all-KO@1 / none |
|
per-gene KO / KI cost dicts |
all-gene@1 if |
|
regulatory-intervention constraints → cost |
none |
|
run the iterative network compressor |
|
|
if set (nonzero), use big-M instead of indicator constraints; GLPK forces |
|
|
MILP seed (feeds solver branch-and-bound) |
random ( |
|
MILP solver time limit (s) |
|
M deserves a note because it silently changes the MILP encoding. With the default M = None,
SDProblem.__init__ sets self.M = np.inf (strainDesignProblem.py), and link_z
attaches each z to its continuous rows as a native indicator constraint — except GLPK,
which cannot express indicators and is forced to M = 1000 (:120-124). Because SUPPRESS’s
dualized rows are unbounded (the Farkas ray) while PROTECT’s primal rows are finite-flux, the
emergent behavior under M = inf is that SUPPRESS rows become indicators and PROTECT rows
become big-M — but this is a consequence of bound structure inside link_z, not a hard-coded
per-module switch (Ch 7). No MIP optimality gap is set anywhere, so both CPLEX and Gurobi run at
their default 1e-4 relative gap (Ch 8, Ch 11).
The call returns an SDSolutions object exposing reaction_sd (reaction-level designs) and,
when gene_kos, gene_sd (gene-level designs) plus a status — the translation between the
two is Ch 9.
1.8 Notation reference
The symbols below are used consistently in later chapters; where a symbol names a concrete attribute in the code the file/field is given.
symbol |
meaning |
code |
|---|---|---|
|
number of (internal) metabolites = rows of |
|
|
number of reactions = columns of |
|
|
stoichiometric matrix |
sparse |
|
flux vector (LP decision variables) |
— |
|
lower / upper flux bounds |
|
|
flux polytope |
— |
|
undesired (SUPPRESS) / desired (PROTECT) flux region |
module |
|
binary intervention vector, one per compressed reaction |
|
|
per-reaction intervention cost |
|
|
KI mask (cost paid for presence) |
|
|
non-knockable mask ( |
|
|
budget: |
|
|
MILP inequality block (top rows: budget + objective) |
|
|
MILP equality block |
|
|
big-M constant (∞ ⇒ indicator constraints) |
|
|
a module’s linear region constraints (schematic) |
|
|
MILP objective coefficients (cost vector for MCS; module objective for bilevel) |
|
|
maps linking |
|
Two matrix conventions recur. First, “primal” always refers to a flux-space LP over v
(build_primal_from_cbm), and “dual” to its LP dual over multipliers (LP_dualize,
farkas_dualize); a SUPPRESS block lives in dual space, a PROTECT block in primal space (§1.3,
Ch 6). Second, equality constraints (Sv = 0) contribute free dual variables while
inequality constraints contribute sign-constrained ones — a distinction the dualization code
handles carefully and which Ch 6 proves out. Keep the z-vs-v split in mind: z is binary
and indexes interventions; v (and the dual multipliers) are continuous and index flux
behavior. The entire MILP is the coupling of these two through the z_map_* matrices.
2. The constraint-based foundation
Everything straindesign does — compression, FVA-based preprocessing, dualization, the MILP itself — is built on one linear-algebraic object: the set of steady-state flux distributions of a metabolic network, carved out of ℝⁿ by a homogeneous equation \(S \cdot v = 0\) and a box of bounds \(lb \le v \le ub\). This chapter derives that object from first principles, establishes the polyhedral geometry the later chapters lean on (faces, vertices, rays, the recession cone — the machinery that makes a Farkas certificate exist and a dual go unbounded), and shows precisely how FBA and FVA are posed as linear programs in the code. It closes with the standard form (A_ineq, b_ineq, A_eq, b_eq, lb, ub, c) that is the lingua franca of the whole package, and how a cobra model is poured into it by build_primal_from_cbm (strainDesignProblem.py).
Notation follows Ch 1: \(S \in \mathbb{R}^{m \times n}\) is the stoichiometric matrix, \(v \in \mathbb{R}^n\) the flux vector, m metabolites, n reactions.
2.1 Mass balance and the steady-state assumption
2.1.1 From dynamic mass balance to S·v = 0
Consider a well-mixed cell (or compartment) of constant volume containing m internal metabolites with concentration vector \(x(t) \in \mathbb{R}^m\) (units: mmol·gDW⁻¹, per gram dry weight), and n reactions with flux (rate) vector \(v(t) \in \mathbb{R}^n\) (units: mmol·gDW⁻¹·h⁻¹). The stoichiometric matrix \(S \in \mathbb{R}^{m \times n}\) has entry S_{ij} = the signed molar stoichiometric coefficient of metabolite i in reaction j: negative if i is consumed, positive if produced, zero if uninvolved. Column j of S is the net reaction vector of reaction j; row i lists every reaction touching metabolite i.
The instantaneous mass balance for each internal metabolite is a bookkeeping identity — rate of change = production − consumption, summed over all reactions weighted by their stoichiometry:
dx_i/dt = Σ_{j=1..n} S_{ij} · v_j (i = 1..m)
or in matrix form
dx/dt = S · v(t).
A dilution/growth term is folded into the biomass reaction and exchange fluxes in genome-scale models, so the bare S·v form is the working equation. The steady-state assumption is that internal metabolite pools do not accumulate or deplete on the timescale of interest:
The biological justification is timescale separation: intracellular metabolite turnover is on the order of seconds to sub-second, while the phenotypes of interest (growth rate, product secretion) play out over hours. Over the slow timescale the fast internal pools are effectively at quasi-steady-state, so their net rate of change is negligible relative to the through-fluxes. Crucially, (SS) says nothing about the fluxes being small — it says they are balanced: whatever is made is immediately consumed. Metabolites we deliberately allow to accumulate or leave the system (biomass, secreted products, medium components) are handled not by relaxing (SS) but by giving them dedicated exchange/boundary reactions (Sec 2.3) that act as sources/sinks, so those degrees of freedom re-enter through v, never as a nonzero right-hand side.
2.1.2 Dimensions, rank, and what the solution set is
(SS) is a homogeneous linear system: m equations, n unknowns. In genome-scale models n > m (typically by a factor of ~1.3–2; e.g. iML1515 has n ≈ 2712 reactions, m ≈ 1877 metabolites), so the system is underdetermined and has a nontrivial solution space. That solution space is exactly the null space (kernel) of S:
𝒩(S) = { v ∈ ℝⁿ : S·v = 0 }.
Let r = rank(S) ≤ min(m, n). By the rank–nullity theorem,
dim 𝒩(S) = n − r.
Two facts about r matter downstream:
ris usually strictly less thanm. Rows ofSare linearly dependent whenever there is a conservation relation — a left null vectorγ ∈ ℝ^mwithγᵀ S = 0, meaning the poolγᵀxis conserved by every reaction (e.g. total carbon in a closed sub-network, or a moiety like CoA/ACP that is never net-produced). Each independent conservation relation dropsrank(S)belowmby one, i.e. makes one metabolite balance row redundant given the others. Ch 3’sremove_conservation_relations(compression.py) exploits exactly this: redundant rows can be deleted fromSwithout changing𝒩(S), shrinking the equality block of the LP.n − r, the degrees of freedom, is the dimension of the flux cone before bounds. These are the independent “flux modes” you may set freely; the rest are pinned by balance. Compression (Ch 3) works in𝒩(S)using an exact integer/rational nullspace basis (never floating point — see the engagement notes and Ch 3’s RREF construction), because the geometry of the cone must be preserved bit-for-bit.
Without any bounds, 𝒩(S) is a linear subspace: closed under addition and under multiplication by any real scalar (positive, negative, or zero). If v balances, so does −v (run every reaction backward) and so does α·v for any α. A pure subspace is not yet a useful model of a cell — it permits negative fluxes through irreversible reactions and unbounded fluxes through everything. Thermodynamics and capacity limits enter as bounds, and that is what turns the subspace into a cone/polytope.
2.2 Bounds, reversibility, and the flux polytope
2.2.1 Bounds encode direction and capacity
Each reaction j carries a lower and upper flux bound, assembled into vectors lb, ub ∈ (ℝ ∪ {±∞})ⁿ:
lb_j ≤ v_j ≤ ub_j (j = 1..n).
The sign convention is fixed by how the reaction is written: column j of S is oriented so that positive v_j means “forward” (left-to-right as written). Reversibility is then purely a statement about lb_j:
Reaction type |
Typical bounds |
Meaning |
|---|---|---|
Irreversible (forward) |
|
may only run forward; |
Reversible |
|
may run either direction |
Irreversible (reverse-only) |
|
only the reverse net direction is thermodynamically allowed |
Fixed / measured |
|
flux pinned to a measured value |
There is nothing special about “reversible” beyond lb_j < 0: direction is entirely an artifact of the sign of the bounds, not a separate attribute the LP sees. This is why compression and GPR integration (Ch 3, Ch 4) freely split a reversible reaction into a forward part (0 ≤ v_j⁺) and a reverse part (0 ≤ v_j⁻) with v_j = v_j⁺ − v_j⁻: it is a lossless re-encoding of the same bound interval into two irreversible columns, needed because a knockout / gene rule must act on a nonnegative flux magnitude.
A subtle but load-bearing point the code relies on (recorded in the engagement notes and re-derived in Ch 7’s prevent_boundary_knockouts): an LP variable bound lb_j ≤ v_j is not the same object as a constraint row. A constraint row −v_j ≤ −lb_j can be selectively switched off by a binary z (that is how a knockout is simulated: multiply the effective bound by z), whereas a hard variable bound cannot be overridden by any row you add — the variable box always wins. prevent_boundary_knockouts (strainDesignProblem.py) therefore migrates the knockable side of a bound (a positive lower bound or a negative upper bound on a knockable reaction) out of the box and into A_ineq so that z can later clamp it; non-knockable bounds stay in the box. The mechanics belong to Ch 7, but the reason lives here: direction and capacity are encoded in bounds, and only bounds that have become rows can be knocked out.
2.2.2 The steady-state flux set is a polyhedron (cone / polytope)
Intersect the kernel with the box:
P is the intersection of a linear subspace (𝒩(S), cut out by the equalities S·v=0) with a box (finitely many inequalities v_j ≤ ub_j, −v_j ≤ −lb_j). A finite intersection of closed half-spaces and hyperplanes is by definition a convex polyhedron. Two special shapes matter:
If all bounds are homogeneous — every finite bound is
0, the rest±∞— thenPis closed under nonnegative scaling:v ∈ P, α ≥ 0 ⟹ α v ∈ P. This is a polyhedral cone, the flux coneC = { v : S v = 0, v_j ≥ 0 for irreversible j }. It is the natural home of Elementary Flux Modes and, dually, of Minimal Cut Sets: an MCS is a minimal set of constraints whose removal empties a target sub-cone.With finite bounds present (
ub_j < ∞, a fixed uptakelb = ub, etc.),Pis a bounded (or partially bounded) polytope — the object FBA optimizes over.
Convexity is not a nicety; it is the enabling property. Because P is convex, a linear objective attains its optimum at an extreme point (a vertex), FVA’s per-reaction min/max are well-defined and attained, and — most importantly for Ch 6 — infeasibility of a target region has a certificate (Farkas), and the dual of an LP over P behaves predictably (Sec 2.5).
A 3-reaction toy
Take metabolites A, B and reactions v1: → A, v2: A → B, v3: B → (an uptake, a conversion, a secretion). With metabolites {A, B} as rows and reactions {v1, v2, v3} as columns,
v1 v2 v3
A [ 1 -1 0 ]
B [ 0 1 -1 ]
S =
S·v = 0 gives v1 = v2 (A balance) and v2 = v3 (B balance), so v1 = v2 = v3. The kernel is one-dimensional: 𝒩(S) = span{(1,1,1)}, consistent with n − r = 3 − 2 = 1. Add irreversibility v ≥ 0 and a capacity v1 ≤ 10: P = { (t,t,t) : 0 ≤ t ≤ 10 } — a line segment, a 1-D polytope with two vertices (0,0,0) and (10,10,10). Drop the upper bound and P becomes the ray { t(1,1,1) : t ≥ 0 } — a 1-D cone with a single extreme ray. This tiny example already exhibits everything Sec 2.5 formalizes: a vertex, a recession ray, and (if we asked for v1 ≥ 11 on the capped model) an infeasible region whose infeasibility is provable.
2.3 Exchange / boundary reactions
Internal metabolites must balance, but a cell is an open system: it takes up substrate and secretes product/biomass. These flows are modeled by exchange (boundary) reactions — columns of S with a single nonzero entry (they touch exactly one metabolite), representing a source or sink across the system boundary. An uptake reaction EX_glc: glc_e ↔ has stoichiometry −1 on external glucose; by convention the exchange flux is written so that negative = uptake, positive = secretion. The medium is then defined purely by bounds on exchanges: lb(EX_glc) = −10 allows up to 10 mmol·gDW⁻¹·h⁻¹ glucose uptake, lb(EX_o2) = 0 makes the environment anaerobic, lb = ub = 0 deletes a metabolite from the medium.
Because exchanges are genuine columns with genuine bounds, (SS) still holds with a strict zero right-hand side: the “accumulation” of secreted product is carried by the exchange flux, not by a nonzero dx/dt. This is the mechanism promised in Sec 2.1: everything that would otherwise break steady state is re-expressed as a reaction. Practically, exchanges are the reactions the SUPPRESS/PROTECT modules point at (e.g. “product exchange must stay ≥ y” for PROTECT, “biomass with zero product exchange must be impossible” for SUPPRESS), and they are usually excluded from the knockable set — you knock out internal enzymatic steps, not the definition of the medium. Ch 5’s essential-reaction FVA and Ch 7’s prevent_boundary_knockouts both special-case them.
2.4 FBA and FVA as linear programs — and how the code builds them
2.4.1 FBA: one LP
Flux Balance Analysis picks, among all steady-state flux distributions, one that maximizes a linear objective \(c^\top v\) (classically c = the biomass reaction indicator, so \(c^\top v\) = growth rate):
This is a linear program over the polytope P. Its optimum is attained at a vertex of P (Sec 2.5). The optimal value is unique; the optimal v need not be (the objective face can be higher-dimensional — this degeneracy is exactly why pFBA and FVA exist).
straindesign implements (FBA) in fba (lptools.py). The construction is worth tracing because it fixes the sign/standard-form conventions used everywhere:
Objective.
ccomes frommodel.reactions[i].objective_coefficient, or from a userobjdict parsed bylinexprdict2mat(lptools.py).Sense flip to a minimizer. The internal solver interface
MILP_LPalways minimizes. So a maximization is turned into a minimization by negatingc(lptools.py):obj_sense = 'maximize' c = [-i for i in c] # min (−cᵀv) ≡ max (cᵀv)
and the reported objective is negated back at the end (
Solution(objective_value=-opt_cx, ...),lptools.py). Every LP in the package is a minimization internally; keep this in mind when reading the dual (Ch 6).Equality block = stoichiometry (+ user equalities).
A_eqstarts asSvia cobra’screate_stoichiometric_matrix(model)(lptools.py), withb_eq = 0(one zero per metabolite). Any user equality constraints (parsed to matrix form bylineqlist2mat) are stacked underneath:A_eq = sparse.vstack((A_eq_base, A_eq)); b_eq = b_eq_base + b_eq
So
A_eq·v = b_eqis literally[S; V_eq]·v = [0; v_eq].Inequality block = user inequalities only. If the user supplied none,
A_ineqis an empty0×nmatrix (lptools.py). Reaction directionality and capacity are not put intoA_ineqhere; they ride in the variable box:Box.
lb, ubare read straight off the reactions (lptools.py).Solve.
MILP_LP(c, A_ineq, b_ineq, A_eq, b_eq, lb, ub, solver).solve.
Two robustness wrinkles are handled explicitly and are worth flagging because they reflect the polyhedral theory of Sec 2.5:
Unbounded objective (
status == UNBOUNDED,lptools.py): the objective face is a recession ray —cᵀv → ∞along a ray in the recession cone. The code then re-solves for a finite representative point by fixingcᵀvto a computed value (add_eq_constraints), so the caller still gets a usable flux vector rather than “∞”.pFBA (
pfba≥ 1,lptools.py): after the primary optimumopt_cxis found, a secondary LP minimizes total fluxΣ|v_j|(mode 1) or the number of active reactions (mode 2) subject tocᵀv = opt_cx. MinimizingΣ|v_j|is linearized by the classic reversible splitv_j = v_j⁺ − v_j⁻,v_j⁺, v_j⁻ ≥ 0, minimizingΣ(v_j⁺ + v_j⁻)(lptools.py) — the same split motivated in Sec 2.2.1. Mode 2 uses indicator constraints and binaries (lptools.py), a mini-MILP, foreshadowing the main event.
2.4.2 FVA: 2n LPs
Flux Variability Analysis asks, for each reaction i, the full range of v_i consistent with steady state (optionally after fixing the objective, or under extra constraints):
That is 2n LPs sharing one feasible polytope P; only the objective vector e_i (the i-th unit vector) changes between them. FVA is the workhorse of preprocessing: it detects blocked reactions (v_i^min = v_i^max = 0 — the reaction can carry no steady-state flux at all, so it is deleted), essential reactions in a PROTECT/desired module (bounds forcing |v_i| > 0, hence not knockable — dropped from the knockable set), and reactions whose model bound never binds (relaxed to ±∞ by bound_blocked_or_irrevers_fva). The three distinct uses and their rationale are Ch 5’s subject; here we fix only the LP form and the code’s entry point.
In straindesign the public fva (lptools.py) is a thin wrapper that delegates to speedy_fva (lptools.py):
def fva(model, **kwargs):
from straindesign.speedy_fva import speedy_fva
return speedy_fva(model, **kwargs)
speedy_fva does not solve 2n independent LPs blindly; it uses a two-phase accelerated scheme (global scan LPs with dual-simplex warm-starts resolve ~half the bounds cheaply, then individual LPs for the rest, with optional coupled-reaction compression for large models). The mathematics of that acceleration is Ch 5’s. The reference brute-force implementation — literally 2n LPs — survives as fva_legacy (lptools.py) and makes the standard form explicit:
Build
A_eq = [S; V_eq],b_eq = [0…0, v_eq],A_ineq/b_ineqfrom user constraints,lb/ubfrom the reactions (lptools.py) — identical assembly to FBA.Instantiate one
MILP_LPand iterate overi ∈ {0, …, 2n−1}. The helperidx2c(i, prev)(lptools.py) maps indexito an objective:col = floor(i/2)is the reaction,sig = sign(mod(i,2) − 0.5)is−1for eveni(maximize, since the solver minimizes−v_col) and+1for oddi(minimizev_col). The warm-start trick isprev: consecutive LPs differ in one objective coefficient, so the simplex basis is reused.Results are unpacked with the sign undone:
maximum = −x[even],minimum = x[odd](lptools.py), and anything with|value| < 1e-11is snapped to0to kill solver noise (lptools.py).
The takeaway for a developer: FVA is 2n LPs over the same polytope, distinguished only by the objective ±e_i, and the internal minimize-only convention means “maximize v_i” is submitted as “minimize −v_i” and negated on return. Every accelerated variant is an optimization of how many of those 2n LPs you actually solve, not of what they compute.
2.5 Enough polyhedral theory for Ch 6
The dualization chapter (Ch 6) needs three geometric facts about P (and about the target regions the modules define). We state them here with just enough proof-sketch to make the later “why does a Farkas certificate exist” and “why is this dual unbounded” self-contained.
2.5.1 Faces, vertices, rays, recession cone
Let P = { v : A v ≤ b } be any polyhedron (fold the equalities S v = 0 into two inequalities S v ≤ 0, −S v ≤ 0, and the box into rows, to view P uniformly as A v ≤ b with A ∈ ℝ^{p×n}).
A face of
PisP ∩ { v : wᵀv = δ }for a valid inequalitywᵀv ≤ δ(one satisfied by all ofP). Faces of dimension 0 are vertices, dimension 1 are edges.A vertex (extreme point) is a
v ∈ Pat whichnlinearly independent constraint rows are active (tight). Equivalently,vis not the midpoint of any segment inP. A linear objective, if bounded onP, attains its optimum on a face, and ifPhas a vertex, at a vertex — this is why FBA/FVA optima are attained and why they occur at biologically “cornered” flux states.The recession cone (characteristic cone) of
Pisrec(P) = { d ∈ ℝⁿ : A d ≤ 0 } = { d : S d = 0, d_j ≥ 0 (j irrev), d_j ≤ 0 where ub binds, … }.A direction
d ∈ rec(P)is a ray: for anyv ∈ Pandt ≥ 0,v + t d ∈ P. Rays are the unbounded directions. Minkowski–Weyl / decomposition theorem: every polyhedron decomposes asP = conv(vertices) + cone(extreme rays)— a bounded convex hull plus a recession cone. When all bounds are finite,rec(P) = {0}andPis a polytope (bounded). When some bound is±∞(or the flux cone case, all homogeneous),rec(P)is nontrivial and carries the unbounded modes — precisely the extreme ray(1,1,1)we saw in the Sec 2.2.2 toy.
2.5.2 Why an objective goes unbounded → the dual is infeasible
(FBA)’s objective cᵀv is unbounded above on P iff there is a recession ray d ∈ rec(P) with cᵀd > 0 (walk along d forever, gaining cᵀd per unit). By LP duality (stated and proved in Ch 6), primal unboundedness is equivalent to dual infeasibility: no dual y satisfies the dual constraints. This is the geometric root of the status == UNBOUNDED branch in fba (Sec 2.4.1) — and, more importantly, of the bilevel dualization in Ch 6, where forcing primal–dual objective equality (strong duality) is used to encode inner-problem optimality. A recession ray of the inner polytope with positive inner objective would make that encoding vacuous, which is exactly the pathology the module builders guard against.
2.5.3 Why an infeasible target region yields a Farkas certificate
The SUPPRESS module (Ch 1) demands that a target flux region become empty after knockouts. The target region is itself a polyhedron:
T = { v : S v = 0, lb ≤ v ≤ ub, T_ineq v ≤ t_ineq, T_eq v = t_eq }
(the module’s inequalities, e.g. “biomass ≥ 0.001 and product ≤ 0”). Write T uniformly as { v : A v ≤ b }. Farkas’ lemma (affine form) states the dichotomy:
Exactly one of the following holds: (a)
∃ v : A v ≤ b(the region is nonempty), or (b)∃ y ≥ 0 : Aᵀ y = 0 and bᵀ y < 0(a certificate of emptiness).
The vector y in (b) is a Farkas certificate: a nonnegative combination of the constraint rows that derives the contradiction 0 = (Aᵀy)ᵀv ≤ bᵀ y < 0. Geometrically, y is a recession ray of the dual polyhedron — an unbounded dual direction — which is why Ch 6’s farkas_dualize deliberately builds a dual whose ray (not vertex) encodes infeasibility, and adds a normalization row (e.g. bᵀy = −1) to pin down the otherwise scale-free ray. The existence of y is guaranteed by Farkas’ lemma precisely because T is a polyhedron — convexity is what makes emptiness certifiable by a single linear witness. The MILP then hunts for a knockout set z that forces case (b) to hold, i.e. makes such a y exist. That is the entire logical content of “SUPPRESS → Farkas infeasibility certificate,” and it rests on the geometry of this section.
For completeness, strong duality (used for PROTECT-as-feasibility and for bilevel strong-duality encodings) and the full proofs of Farkas’ lemma and the duality theorem are Ch 6’s; here we have only established why the objects exist — P, T, their vertices, rays, and recession cones — and that emptiness/unboundedness are certifiable, which is all the later chapters assume.
2.6 The standard form and build_primal_from_cbm
Internally, straindesign never manipulates a cobra model directly during MILP assembly. Every constraint system — primal, dual, per-module block — is carried as a tuple in one standard form:
with A_ineq ∈ ℝ^{p×N}, A_eq ∈ ℝ^{q×N}, x ∈ ℝ^N, lb, ub ∈ (ℝ∪{±∞})^N, c ∈ ℝ^N. This is the signature of MILP_LP and the contract every builder honors. It is deliberately minimal and symmetric: separate equality and inequality blocks (so dualization can treat them by their type — equalities → free dual vars, inequalities → sign-restricted dual vars; Ch 6), an explicit variable box (kept separate from A_ineq so bounds and rows are distinguishable, per Sec 2.2.1), and a single objective c in minimize sense (Sec 2.4.1).
2.6.1 Mapping a cobra model into the standard form
build_primal_from_cbm (strainDesignProblem.py) is the canonical adapter from a cobra model (plus optional extra constraints V_ineq·x ≤ v_ineq, V_eq·x = v_eq) into this form. In the primal every variable is a reaction flux, so N = numr = len(model.reactions). The construction (strainDesignProblem.py):
numr = len(model.reactions)
S = sparse.csr_matrix(create_stoichiometric_matrix(model))
A_eq = sparse.vstack((S, V_eq)) # [ S ; V_eq ]
b_eq = [0]*S.shape[0] + v_eq # [ 0 ; v_eq ]
A_ineq = V_ineq.copy(); b_ineq = v_ineq.copy() # only user inequalities
lb = [float(r.lower_bound) for r in model.reactions]
ub = [float(r.upper_bound) for r in model.reactions]
So the equality block is the stoichiometry S (the steady-state constraint S·x = 0) with the module’s equalities stacked below; b_eq is zeros for the metabolite rows and v_eq for the extra rows. The inequality block starts as just the module’s inequalities — reaction directionality/capacity live in the box lb/ub, exactly as in FBA (Sec 2.4.1). The objective c defaults to the model’s objective_coefficient vector.
2.6.2 The bookkeeping matrices (z_map_*)
Beyond the LP itself, build_primal_from_cbm returns three association matrices that thread reaction identity (and therefore knockout binary z_j) through the standard form (strainDesignProblem.py):
z_map_vars— shapenumz × N, relating intervention binaries to variables. In the primal it is the identity (sparse.identity(numr)): variablex_jis reactionj, so knocking out reactionjacts on variablej. An entry+1marks “this reaction’s knockout removes this variable”;−1marks an addition (knock-in).z_map_constr_ineq— shapenumz × p, relating binaries to inequality rows. Zero at construction, because in the raw primal no inequality row is tied to a specific reaction knockout (the model’s own bounds are still in the box).z_map_constr_eq— shapenumz × q, relating binaries to equality rows. Zero for the same reason (the metabolite balances belong to no single reaction’s knockout).
These matrices are the mechanism by which dualization (Ch 6) and z-linking (Ch 7) know which rows and variables a given z_j must switch. When LP_dualize transposes the system, it also transposes/re-routes these maps so that a reaction still tracks the correct dual object — this is why LP_dualize (strainDesignProblem.py) takes and returns the z_map_* triple, not just the LP.
The last step in the adapter is the bound migration already previewed in Sec 2.2.1:
A_ineq, b_ineq, lb, ub, z_map_constr_ineq = prevent_boundary_knockouts(
A_ineq, b_ineq, lb.copy(), ub.copy(), z_map_constr_ineq, z_map_vars)
prevent_boundary_knockouts (strainDesignProblem.py) moves the knockable side of each nonzero bound (a positive lower bound / negative upper bound on a reaction that carries a nonzero z-mapping, strainDesignProblem.py) out of the box and into a new A_ineq row, updating z_map_constr_ineq so the row is tagged with the owning reaction — but leaves non-knockable bounds untouched. The why (a hard variable box cannot be relaxed by a binary, only a constraint row can) is Sec 2.2.1; the how (which bounds move, and how the resulting big-M/indicator gets attached) is Ch 7. What matters for this chapter is that after build_primal_from_cbm returns, the tuple (A_ineq, b_ineq, A_eq, b_eq, lb, ub, c, z_map_constr_ineq, z_map_constr_eq, z_map_vars) is a faithful, dualization-ready standard-form encoding of P together with the intervention bookkeeping — the single object every subsequent chapter consumes.
With S·v = 0 derived from mass balance, the flux set established as a convex polyhedron P (cone when homogeneous, polytope when bounded), FBA/FVA pinned down as LPs over P in the exact form the code builds them, and the standard-form tuple that build_primal_from_cbm produces, the linear-algebra/LP bedrock is in place. Ch 3 works inside 𝒩(S) to shrink P losslessly; Ch 5 exploits FVA over P; Ch 6 dualizes the standard form and turns the emptiness/unboundedness facts of Sec 2.5 into Farkas and strong-duality certificates; Ch 7 attaches the z binaries via the z_map_* matrices assembled here.
3. Network compression
Compression is the single most consequential preprocessing stage for strain design. It runs twice in
the pipeline (compress_model at compression.py, once before GPR integration and once after —
see the end-to-end flow in Ch 1 and the GPR boundary in Ch 4), and everything downstream — the FVA
passes, the dualization, the MILP, the enumeration loop — operates on the compressed network. This
chapter explains what compression removes, the exact integer/rational linear algebra that makes it
correct, and why the design is the way it is. The organising fact is that compression is lossless
for the flux space: it produces a smaller network whose steady-state flux cone is an exact linear
image of the original, so any minimal cut set found on the compressed network expands (Ch 9) to a
minimal cut set of the original. Nothing is approximated; that is the whole point, and it is why the
arithmetic must be exact.
3.1 Why compress at all
Strain design is solved as a mixed-integer linear program (MILP) with one binary intervention variable
z_j ∈ {0,1} per knockable reaction and a block of continuous constraint rows per module (Ch 6–7).
Two quantities dominate the difficulty of that MILP:
The number of binaries. Branch-and-bound explores a tree whose worst-case size is exponential in the count of binary variables. Each reaction that survives into the compressed model is a candidate
z. Halving the reaction count is, very loosely, squaring the amount of pruning the solver must do to close the tree — the practical effect is far larger than the raw ratio suggests.The number of constraint rows. Every module contributes a dualized or primal block whose height is proportional to the number of metabolites (dual variables, one per
S-row) or reactions (primal fluxes). Fewer metabolites and fewer reactions means smaller, denser constraint blocks and fewer big-M / indicator couplings to relax.
Compression attacks both. It removes reactions (columns of S, hence binaries) by merging sets that
are forced to carry proportional flux, and it removes metabolites (rows of S, hence dual variables)
that are linearly dependent. On iML1515 the effect across passes is large and worth stating concretely:
the model enters compression with 2712 reactions; the first compression pass (before GPR
integration) drives it down to roughly 1237 reactions; after the GPR-extended network is built
(which re-introduces gene pseudoreactions) and the second compression pass runs, the working reaction
count settles around 2152. The metabolite count falls in step through conservation-relation
removal. These are the numbers the MILP actually sees; the raw 2712 never reaches the solver.
There is a second, subtler payoff that the codebase treats as a working hypothesis. A stoichiometric
matrix S ∈ ℝ^{m×n} of a genome-scale model is far from full column rank: it has a large right
nullspace (the space of steady-state flux vectors, dim = n − rank(S)), and it has conservation
relations — left-null vectors y with yᵀS = 0 — that make its rows linearly dependent. Coupled-
reaction merging collapses columns that are nullspace-proportional; conservation removal deletes
dependent rows. A well-compressed network has had both its column redundancy and its row redundancy
squeezed out, so it sits much closer to full rank than the original. The hypothesis noted in the code
is that as the compressed S approaches full rank, the strain-design MILP starts to resemble the
tighter, better-conditioned formulation used by MCS2 (Ch 11) — fewer degenerate LP relaxations, fewer
alternative optima to enumerate. This is a rationale for compressing aggressively and exactly, not
merely enough.
The correctness knife-edge that runs through the entire chapter: compression decisions are made from
the kernel (nullspace) of S, and those decisions are combinatorial — “is this kernel row exactly
zero?”, “are these two kernel rows exactly proportional?”. A floating-point kernel answers those
questions with a tolerance, and a tolerance is a guess. Guess wrong in either direction and you either
merge reactions that are not truly coupled (corrupting the flux space, producing wrong cut sets that
pass silently) or miss couplings that exist (leaving the MILP larger than necessary). The engine is
therefore built on exact integer/rational arithmetic end to end. §3.2 is that engine.
3.2 The exact integer/rational nullspace engine
3.2.1 Why never float
Every compression primitive reduces to one linear-algebra question about S: what is its kernel, and
which rows/columns are dependent? Concretely:
A reaction carries no steady-state flux ⇔ its row in the kernel
Kis identically zero (a structural blocked reaction).Two reactions are flux-coupled ⇔ their kernel rows are scalar multiples of one another with a constant ratio across every kernel column.
A metabolite is redundant ⇔ its row of
Sis a linear combination of other rows (a conservation relation).
Each is an exact predicate: zero-vs-nonzero, equal-ratio-vs-not, dependent-vs-independent. Under
floating point every one of these becomes a threshold test |x| < ε. Choosing ε is choosing which
errors to make. Set it loose and two nearly-parallel-but-distinct reactions get merged — the merged
column is not a true consequence of Sv=0, so the compressed flux cone is wrong, and because the
MILP is built on the wrong cone, it can return “minimal cut sets” that do not cut anything in the real
model, with no error raised. Set ε tight and genuine couplings born from large stoichiometric
coefficients (see the 263-bit yeast-GEM case below) are missed. There is no safe ε, because the
coefficients that arise mid-elimination span many orders of magnitude. The project constraint is
therefore absolute: the nullspace and rank computations are done in exact arithmetic — Python
arbitrary-precision integers and fractions.Fraction — and never in float. stoichmat_coeff2rational
(compression.py) converts every stoichiometric coefficient to an exact Fraction/sympy.Rational
before any compression math runs, and float_to_rational (compression.py) is the one controlled
place where a stray float coefficient is turned into a bounded-denominator rational (it first tries
Fraction(val).limit_denominator(100) and accepts it only if it round-trips to max_precision
decimals, else falls back to round(val·10^p)/10^p). Once inside the engine, no float ever appears.
3.2.2 RationalMatrix and exact storage
The exact matrix type is RationalMatrix (compression.py). It stores a sparse rational matrix as
two parallel integer sparse matrices — a numerator CSR and a denominator CSR — so that entry
(i,j) is num[i,j] / den[i,j]. Keeping numerators and denominators as separate scipy int64 CSR
matrices lets the common operations (column iteration, row/column deletion, submatrix extraction) stay
in fast compiled sparse code, while every value remains an exact rational. Construction paths:
from_cobra_model (:175) reads a model’s coefficients straight into num/den arrays, preserving
Fraction/sympy-Rational exactly and only calling float_to_rational for genuine floats;
identity (:144), from_numpy (:155), and _from_sparse (:130) cover the rest.
Two features of RationalMatrix matter later:
add_scaled_column(:313) performscol[dst] += (num/den)·col[src]in exact rational arithmetic with per-entry GCD reduction — this is the primitive that merges a coupled slave column into its master (§3.4).Batch edit mode (
begin_batch_edit/end_batch_edit,:270/:276) switches the backing store to LIL for a burst of column mutations and back to CSR afterward, so a whole coupled-group merge does not pay repeated format-conversion costs.
3.2.3 Fraction-free integer RREF: _rref_integer_sparse
The core is _rref_integer_sparse (compression.py), which computes a reduced row-echelon
form over the integers without ever introducing a denominator. The key idea is that for the purposes
we need (rank, pivot columns, and reading off a kernel), rows may be scaled by any nonzero integer:
scaling a row of S does not change its null vectors. So instead of dividing (which creates
fractions), the algorithm cross-multiplies and then removes the common integer factor.
Setup — clear denominators once. Each input row r has its rational entries num/den cleared to
integers by multiplying the whole row by the LCM of its denominators (:527–:539). After this every
working row is a pure integer row; there are no denominators to track for the rest of the routine —
this is the sense in which it is “fraction-free.”
Fraction-free elimination. To eliminate pivot column c (pivot value pv in the pivot row) from a
target row with entry ev in column c, the update is
new_row[k] = ev_scaled · pivot[k] − pv_scaled · target[k] (conceptually)
where the code (_eliminate, :564) first divides pv, ev by g = gcd(pv, ev) to get
pv_scaled = pv/g, ev_scaled = ev/g, then computes, for the sparse pivot row prd,
new_row = {c: v·pv_scaled} over the target row and subtracts ev_scaled·prd[c] on the shared
columns (:583–:589). This is the classical fraction-free (Bareiss-style) update: it keeps
everything integer and makes column c vanish in the target, because
ev_scaled·pv − pv_scaled·ev = 0 after the GCD split.
Content reduction (GCD) — why coefficients stay polynomial. Cross-multiplying integer rows makes
entries grow. Without control, the bit-length of coefficients grows exponentially down the
elimination. The defence is to divide each freshly-computed row by the GCD of all its entries — its
“content” — right after forming it (:592–:595): row_gcd = gcd(*new_row.values) then
row[c] //= row_gcd. This is exactly the mechanism (Bareiss / fraction-free Gaussian elimination) that
bounds intermediate integers to the size of subdeterminants of the original matrix, i.e. keeps the
bit-length polynomial rather than exponential. A final content reduction of the pivot rows runs at
:680–:686 as insurance.
Markowitz pivoting — keep it sparse. On a genome-scale S the elimination is dominated not by
arithmetic but by fill-in and pivot search. Two heuristics keep both small:
Columns are pre-sorted by ascending nnz (
col_order,:510–:514) so that sparse columns — the likely pivots — are visited first; rows are pre-sorted by ascending nnz (:544–:546). Results are translated back to the original column order at the end (:688–:691).At each step the pivot is chosen by the Markowitz criterion among the rows that actually contain the current pivot column: sparsest row first, ties broken by smallest absolute pivot value (
:628–:637). A livecol_rowsindex (:554–:562) maps each column to the set of active rows containing it, so pivot search visits only the handful of rows that hold the column instead of scanning all active rows (on iML1515 that scan was ~99.9% misses; the index removes it).
Two-phase echelon, not full Gauss–Jordan. Phase 1 (:613–:650) does forward elimination only —
each pivot is cleared from rows below it, leaving already-processed pivot rows sparse. Phase 2
(:652–:679) does back-substitution, processing pivots last-to-first and clearing each pivot column
from the pivot rows above it. Doing it in this order means that when a pivot row is applied during
back-substitution, its own later-pivot columns are already cleared, so back-substitution only ever
introduces free-column fill and only ever removes pivot-column entries — enabling the
pivcol_holders index (:664–:668) to be maintained with discards only. The commit comments record
the payoff on iML1515: ~0.8M back-substitution ops versus ~9.4M for naive Gauss–Jordan, because full
Gauss–Jordan re-reduces every filled row against every later pivot (~99% of the total work).
The routine returns (rref_data, rank, pivot_columns) where rref_data[i] is pivot row i as a
{orig_col: integer_value} dict, all in the original column space.
3.2.4 Reading off the kernel: _nullspace_sparse
_nullspace_sparse (compression.py) turns the RREF into an explicit kernel basis. With rank
pivots and cols columns, the free columns are free_cols = {0..cols−1} \ pivots and the nullity is
|free_cols|. For each free column f the basis vector k_f is built by the standard RREF rule:
entry
+1at rowf(the free variable is set to 1),:726–:731;at each pivot row
iwith pivot columnp_i, entry−rref[i,f] / rref[i,p_i], reduced by GCD to a clean rational and given a positive denominator (:734–:749).
So k_f has value 1 in its own free coordinate and −(free entry)/(pivot value) in each pivot
coordinate. By construction S·k_f = 0 exactly. The set {k_f} is a sparse rational basis of the
right nullspace — one column per free variable — assembled by _build_from_sparse_data (:206). This
sparsity is exactly what makes coupling detection cheap in §3.3–§3.4: a coupled reaction shows up as a
kernel row with a distinctive zero pattern, and sparse kernel rows make that pattern comparison a
dictionary lookup.
nullspace (:759) is the public wrapper; basic_columns (:774) returns just the pivot columns
(used by conservation removal, §3.5); sparse_nullspace (:785) is the general-purpose exact-kernel
helper that accepts scipy/numpy/RationalMatrix input.
3.2.5 The big-integer path — when subdeterminants exceed int64
Fraction-free RREF keeps coefficients polynomial, but “polynomial” is not “small”. The exact kernel
entries are ratios of subdeterminants of S, and on dense, large models those subdeterminants can
exceed the 64-bit integers that scipy sparse matrices can hold. The verified extreme is yeast-GEM,
whose exact nullspace needs coefficients up to ~263 bits — far beyond int64.
The engine handles this transparently. _INT64_MAX (:93) and _fits_int64 (:96) test whether all
numerators and denominators fit in signed int64. _build_from_sparse_data (:206) checks this: if
everything fits, it builds the fast dual-int64-CSR representation (:214–:217); if not, it falls
back to a dict-of-Fractions store, _dict_frac : {row: {col: Fraction}} (:218–:225), which
uses Python arbitrary-precision integers and bypasses scipy entirely. is_bigint (:407) reports
which mode a matrix is in. The RREF itself never overflows — it works in Python int throughout; only
the storage of the finished kernel needs the fallback.
Because scipy sparse cannot hold >int64 values, the export helpers are mode-aware. to_sparse_csr
(:382) raises OverflowError in big-integer mode (with a message pointing at the exact exports).
to_coo_exact (:412) is the big-integer-safe export used in both modes: it returns an ExactCOO
namedtuple (rows, cols, data, shape, denom) (defined :103) in which entry (rows[k], cols[k])
equals data[k]/denom exactly, with data arbitrary-precision Python ints scaled to a common
denominator. to_sparse_pattern (:435) returns a pure-structure int8 CSR (1s where nonzero) plus a
{row: {col: Fraction}} value map — this is the form coupling detection consumes, and it works
identically in int64 and big-integer mode, so the whole compression pipeline runs unchanged on
yeast-GEM. sparse_nullspace (:785) returns a scipy CSR in the common case and an ExactCOO when
K.is_bigint (:820–:823).
3.3 The compression working state and the single-kernel pass
The nullspace-driven compressor is StoichMatrixCompressor (compression.py), driven through a
mutable _WorkRecord (:930). The _WorkRecord carries three exact matrices that together record the
entire transformation and satisfy the invariant recorded on CompressionRecord (:896):
pre @ stoich @ post == cmp
with the flux-space consequence v_original = post @ v_compressed. Concretely pre is a
RationalMatrix starting as identity(m) (metabolite transformation, tracks row/metabolite
operations), post starts as identity(n) (reaction transformation, tracks column/reaction merges),
and cmp starts as a clone of stoich and is mutated in place as compression proceeds (:930–:947).
Every reaction merge is applied identically to cmp and to post so the invariant is preserved and
post can later expand a compressed flux vector back to the original reaction space (Ch 9).
The compress driver StoichMatrixCompressor.compress (:1095) runs a loop (:1121–:1128): remove
all-zero metabolite rows, then call _nullspace_compress, and re-iterate only while the previous pass
reported a contradicting removal (which changes the flux space and can expose new couplings). Note the
important design choice: one nullspace computation drives both zero-flux detection and coupled-group
merging in the same pass. _nullspace_compress (:1133) builds the active submatrix, computes
kernel = nullspace(active) once (:1144), extracts (kernel_pattern, kernel_values) via
to_sparse_pattern (:1150), and hands both to _handle_compress (:1248).
The single kernel yields three kinds of removals in one batch (_handle_compress, :1248–:1337):
Structural zero-flux reactions — reactions whose kernel row is empty.
_find_zero_flux(:1155) reports reactionreacas zero-flux iffkernel_pattern.indptr[reac] == kernel_pattern.indptr[reac+1], i.e. the reaction appears in no null vector. Such a reaction cannot carry any steady-state flux (Sv=0forcesv_reac = 0), so it can never be part of a working pathway and is deleted. This is the structural blocked-reaction test, and because it falls out of the kernel it needs no LP/FVA (contrast the bounds-based test in §3.6).Bounds-blocked reactions — reactions with
lb = ub = 0that nonetheless have a nonzero kernel row are added to the same removal set (:1266–:1271); they are structurally capable of flux but pinned to zero by bounds, so removing them here avoids a separate FVA pass.Coupled-group slaves (and contradicting groups) — see §3.4.
Everything collected is removed in one remove_reactions_by_indices batch (:1335), which drops the
columns from cmp and post together and reindexes names/bounds (:986–:1004). _handle_compress
returns True only if a contradicting group was removed, which is the sole trigger for another
iteration.
3.4 Coupled / flux-coupled merge
3.4.2 Detection: _find_coupled_groups
_find_coupled_groups (compression.py) implements exactly that two-stage test. First it buckets
reactions by kernel-row zero pattern: pattern = tuple(kernel_pattern.indices[start:end]) per reaction,
grouped into a dict, keeping only buckets of size > 1 (:1181–:1188). Then, within each candidate
bucket, it verifies the constant ratio (:1201–:1244): pick reaction a, take the first nonzero
column first_col, compute ratio = a_val/b_val there (exact Fraction division, :1218–:1226),
and confirm a_v/b_v == ratio for every remaining nonzero column (:1230–:1235). Reactions that
pass are collected into a group with ratios[reac_b] = ratio recorded per slave. The output is
(groups, ratios): each group is [master, slave1, slave2, …] (master is the first member), and
ratios[slave] is the exact Fraction v_master / v_slave.
The protected_indices argument (:1164, applied at :1202/:1211) lets specific reactions be kept
out of any coupled group — the rest of the group still merges. This is how gene-controlled reactions
are held intact through COMPRESS #1 so that gene multiplicity survives into GPR integration
(cross-reference Ch 4); the mapping from protected names to current indices is done in
_handle_compress (:1275–:1276).
3.4.3 The merge (COLUMN reduction): _combine_coupled
Merging is a column operation. _combine_coupled (:1339) folds each slave column into the master.
Given ratios[slave] = v_master/v_slave = λ, the master flux relates to the slave’s own flux by
v_slave = v_master/λ, so the slave’s stoichiometric contribution, expressed in units of the master
flux, is col[slave] · (1/λ). The code computes the multiplier as mult = 1/λ = λ.denominator / λ.numerator (:1350) and applies cmp[:,master] += cmp[:,slave]·mult and the same update to
post[:,master] (:1353–:1356), both via the exact add_scaled_column. Applying it to post
records that the compressed master reaction expands back to a specific exact linear combination of the
original columns — the master column of cmp becomes the exact stoichiometry of the lumped pathway,
and the master column of post becomes the exact expansion recipe. The slaves are then deleted
(:1326–:1327), so the group of k reactions becomes one reaction: k−1 binaries eliminated per
group. This is a column (reaction) reduction.
Worked micro-example. Take the linear pathway r1: A→B, r2: B→C, r3: C→D(ext) with A supplied
and D drained, all irreversible. The only steady states have v1=v2=v3, so the kernel has one column
(1,1,1)ᵀ (up to scale) and all three kernel rows are identical → one coupled group [r1, r2, r3]
with λ = 1 for both slaves. _combine_coupled adds col(r2) and col(r3) into col(r1): the
intermediate metabolites B and C cancel (produced by one column, consumed by the next, in equal
units) and the merged column is the net reaction A → D. Three reactions, three binaries, collapse to
one. Now suppose instead r2: 2 B → C (two B per C). Steady state on B gives v1 = 2 v2, so
ratios[r2] = v1/v2 = 2 and mult = 1/2: col(r1) gains ½·col(r2), again cancelling B exactly —
the constant ratio, carried as an exact Fraction, is what makes the cancellation exact.
3.4.4 Bound intersection of a coupled group
Merging the columns is not the whole story: the slaves’ flux bounds must be transferred to the
master, or the compressed model would silently drop feasibility restrictions. _handle_compress
(:1289–:1327) does this. Because v_slave = v_master/λ (with λ = ratios[slave]), the slave’s
box lb_s ≤ v_slave ≤ ub_s becomes a constraint on v_master:
if
λ > 0:lb_s·λ ≤ v_master ≤ ub_s·λ(:1302–:1305);if
λ < 0: the inequality flips,ub_s·λ ≤ v_master ≤ lb_s·λ(:1306–:1309).
with ±inf propagated so that an unbounded slave contributes no restriction. The master’s new box is
the intersection of its own box with all translated slave boxes: intersected_lb = max(...),
intersected_ub = min(...) (:1311–:1315), written back to work.bounds[master] (:1315).
Contradicting groups. If the intersection is empty (intersected_lb > intersected_ub) or collapses
to a single point at zero (intersected_lb == intersected_ub == 0), the coupled group can carry no
nonzero flux in any steady state — a contradicting group. Then the master and all slaves are removed
(:1317–:1323) and contradicting_removed is set, which is the flag that triggers a re-iteration of
the whole pass (:1337 → :1126): removing a contradicting group changes the flux space and may make
previously-uncoupled reactions coupled. A consistent (nonempty) group removes only the slaves
(:1324–:1327). This bound-intersection logic replaced a Java-era behaviour that could drop
reactions incorrectly; getting the translate-and-intersect direction right (especially the λ<0 flip
and the ±inf handling) is exactly the subject of the closed issue #44 cautionary tale in Ch 10.
3.5 Conservation-relation removal (ROW-rank reduction)
remove_conservation_relations (compression.py) shrinks S by deleting metabolite rows that
are linearly dependent on the others — the conservation relations of the network. A conservation
relation is a left-null vector y with yᵀS = 0: a weighted sum of metabolite balances that is
identically zero (e.g. a moiety like total ATP+ADP, or a redundant compartment balance). If row i of
S is a linear combination of other rows, then the steady-state equation S_i · v = 0 is implied by
the others and carries no information — dropping metabolite i leaves the flux space {v : Sv=0}
exactly unchanged. It is therefore lossless for fluxes, and it strictly reduces the row count.
The mechanics use the exact RREF as a rank/independence oracle. The function builds Sᵀ (reactions ×
metabolites) directly from the cobra coefficients as a RationalMatrix — deliberately transposed so
that metabolites become columns (:1428–:1455) — and calls basic_columns (:1456), which runs
_rref_integer_sparse and returns the pivot columns. The pivot columns of Sᵀ are a maximal set of
linearly independent metabolite rows; every non-pivot metabolite is a dependent row, i.e. a
conservation relation. Those dependent metabolites are removed from the model (:1458–:1460).
Two design points. First, this is a row-rank reduction, complementary to the column reduction of
§3.4 — together they push S toward full rank (the §3.1 hypothesis). Second, the ordering matters:
conservation removal runs before the expensive coupled step in each cycle (compress_model,
:1906–:1910). Fewer metabolite rows means the nullspace RREF that drives coupling detection operates
on a smaller matrix, so removing dependent rows first makes the costliest stage cheaper. (There is a
legacy Java oracle, _remove_conservation_relations_java at :1943, selectable via the
efmtool_rref backend; the default sparse_rref path uses the pure-Python exact RREF above.)
3.6 Blocked and zero-flux removal
There are two distinct notions of “carries no flux,” removed at two points:
Bounds-blocked reactions —
remove_blocked_reactions(compression.py) deletes reactions whose bounds are exactly(0, 0)(:1701) withremove_orphans=Trueso metabolites left dangling go too. This runs once at the very start ofcompress_model(:1889), before any rational conversion, as a cheap first cut.Structural zero-flux reactions — reactions whose kernel row is empty (§3.3,
_find_zero_flux,:1155). These are reactions thatSv=0forces to zero regardless of bounds; they are found for free from the nullspace during each coupled pass and removed in the same batch. The additional check at:1266–:1271catches reactions pinned to(0,0)by bounds that still have a nonzero kernel row, folding the bounds-blocked case into the kernel pass as well.
remove_unused_metabolites (_WorkRecord, :1044) is the row-side companion: after columns are
dropped, any metabolite row that has become all-zero (detected in O(m) via CSR indptr diffs,
:1054–:1055) is removed. It runs at the top and bottom of the compress loop (:1124, :1129).
3.7 The alternating fixpoint
compress_model (compression.py) orchestrates the three reducers into an alternating
fixpoint (:1894–:1937). The order within each cycle is deliberate:
Parallel merge (
compress_model_parallel, §3.8) — cheapest: a hash of the (scale-normalized) stoichiometry row, no RREF (:1899).Conservation-relation removal (§3.5) — shrinks
S’s rows so the next step’s RREF is smaller (:1906–:1910).Coupled merge (
compress_model_coupled, §3.4) — most expensive: a full exact nullspace/RREF (:1920–:1935).
The loop runs cheap-to-expensive so that each stage feeds the next a smaller network, and the expensive kernel computation only ever runs on an already-thinned matrix.
Why it alternates and why it terminates. Each reducer can expose new opportunities for the
others: a coupled merge cancels intermediate metabolites, which can make two previously-different
columns become exactly parallel (new parallel merges); a parallel merge changes the column set, which
can change the kernel (new couplings); conservation removal changes the row set likewise. So a single
pass of each is not enough — the pipeline loops. Termination is guaranteed because every reducer
only ever removes reactions or metabolites; none ever adds one. The reaction count is a non-negative
integer that is non-increasing across the loop, so it cannot decrease forever. The explicit stop
condition (:1916–:1918) is: after at least one full cycle, if either the parallel step or the
coupled step found nothing, stop — because a step that changed nothing on the current network will
change nothing on re-run unless the other step alters the network, and the loop has just established
that it did not make progress. run counts cycles for the log. In practice on genome-scale models this
converges in a handful of cycles.
Each productive step appends a record to cmp_mapReac — {"reac_map_exp": reac_map_exp, "parallel": <bool>} (:1904, :1935) — the compression map consumed by decompression (§3.10).
3.8 Parallel merge
compress_model_parallel (compression.py) is the cheap reducer. It lumps parallel reactions:
reactions that are stoichiometric scalar multiples of one another (identical up to a rational scale
factor) and have compatible bound topology, e.g. two isozymic reactions with the same net conversion.
It never computes a kernel — it groups reactions by an exact hashable key.
Scale-invariant, exact key. The stoichiometry matrix is taken transposed (stoichmat_T, one row
per reaction) and each reaction’s key (_parallel_key, :2058) is its stoichiometry row normalized
by its first nonzero coefficient in exact rational arithmetic: f0 = float_to_rational(vals[0]), then
stoich = tuple((col, float_to_rational(v)/f0) …) (:2062–:2064). Normalizing by the first
coefficient makes the key scale-invariant: −1 A → 2 B and −3 A → 6 B both reduce to the tuple
((A,1),(B,−2)) and so share a key, but the division is exact (Fraction), so two rows that are only
nearly proportional get different keys — no reaction is ever merged on a rounding coincidence.
Bound topology is part of the key. The key also carries three bound-derived flags per reaction,
computed at :2048–:2051:
fwd/rev: whether the reaction is unbounded in the forward / reverse chemical direction (aninfbound on the appropriate side given the sign of the first coefficient);inh: set to the unique valuei+1if the reaction has any finite nonzero bound (not ((ub inf or 0) and (lb inf or 0))), else0.
Because inh is i+1 (unique per reaction), any reaction carrying a finite nonzero bound gets a key
component no other reaction can match, so it is never lumped in parallel. Parallel merging is thereby
restricted to reactions whose bounds are homogeneous (each side 0 or ±inf) and whose reversibility
matches — i.e. reactions that live in the same cone face. This is the correctness guard that keeps
parallel merging from combining reactions with incompatible feasibility. Grouping is a hash pre-filter
(key_hashes) followed by an exact full key comparison (:2073–:2085); protected_rxns are forced
into singleton groups (:2076–:2078).
COLUMN reduction and the flux-split map. Each group keeps one representative (its id is decorated
with *-joined member ids, truncated to ... past ~220 chars, :2094–:2097) and the others are
removed (:2114–:2116) — again a column reduction, k−1 binaries removed per group. The
compression map differs from the coupled case in a way that matters for cost accounting: for a parallel
group the compressed flux is the total flux through all members, and each member’s share is
proportional to its stoichiometric scale |factor[j]| (its first-coefficient magnitude). The map is
built (:2127–:2141) as normalized flux-split fractions:
rational_map[cmp_id][orig_j] = |factor[j]| / Σ_k |factor[k]| (fractions sum to 1)
So expanding a compressed flux of a parallel group distributes it across the originals in these exact
proportions, whereas expanding a coupled group scales by the post-column factors (v_orig = coeff · v_cmp). This is why cost propagation (Ch 9’s compress_ki_ko_cost) treats the two directions oppositely
— KO cost of a parallel lump is the sum over members (you must knock out all parallel routes), while
KO cost of a serial/coupled lump is the min (knocking out any one link breaks the chain).
Worked micro-example. Two isozymes r1: A→B and r2: A→B (same stoichiometry, both irreversible
with ub=inf, lb=0): identical normalized key ((A,1),(B,−1)), matching fwd/rev/inh=0 flags → one
parallel group [r1,r2]. They lump into a single reaction r1*r2: A→B carrying v = v1+v2, with
flux-split map {r1: ½, r2: ½} (equal |factor|). A knockout of the lump means both isozymes are
knocked out, so its KO cost is the sum — correctly capturing that either isozyme alone still runs the
reaction.
3.9 GPR propagation through compression
When compression runs with propagate_gpr=True (COMPRESS #1, before gene pseudoreactions exist), each
merge must carry the Boolean gene–protein–reaction (GPR) rules of its members onto the surviving
reaction, so the compressed model still knows which genes control the lumped reaction. This chapter
covers only the propagation through a merge; the semantics of encoding GPR as flux structure belongs
to Ch 4 (extend_model_gpr), cross-referenced there.
The rule follows the flux logic of each merge type:
Serial / coupled merges → AND. A coupled group is an unbranched chain that must run as a unit — every member’s genes are required for the lumped reaction to carry flux — so their GPRs are combined with AND.
_combine_gpr_and(compression.py) is invoked fromcompress_model_coupled(:2007–:2015) over the saved GPR ASTs of the contributing reactions.Parallel merges → OR. Parallel members are alternative routes for the same conversion — any of them suffices — so their GPRs are combined with OR.
_combine_gpr_or(compression.py) is invoked fromcompress_model_parallel(:2107–:2121).
Both combiners lift the cobra GPR AST to sympy Boolean expressions (_gpr_ast_to_sympy, :1754),
combine with sympy.And/sympy.Or (which auto-flatten and dedupe), and render back to a rule string
(_sympy_to_gpr_string, :1773). The subtlety is the treatment of an empty GPR (a reaction with
no gene requirement, “always active”, logically True): in an AND-combine an empty GPR is a no-op and
is skipped, and if all members are empty the result is empty (:1815–:1822); in an OR-combine a
single empty member makes the whole lump always-active, so the result is empty (:1837–:1839). Full
Boolean simplification is deferred to reduce_gpr downstream (Ch 4). Note also that the coupled Python
backend clears gene rules on the raw reactions before the merge (compress_model_coupled, :1996–
:1998) and reinstates the combined rule afterward from the saved ASTs (:1982–:1983,
:2007–:2015), so the propagation is driven off a clean snapshot rather than the mutated model.
3.10 The compression map cmp_mapReac and back-expansion
The output of compress_model is cmp_mapReac: an ordered list of step records, one per productive
merge, in the order the merges were applied. Each record is a dict
{"reac_map_exp": {compressed_id: {original_id: factor, …}, …}, "parallel": bool}
where factor is an exact Fraction — a normalized flux-split share for a parallel step, or a
post-column scaling coefficient for a coupled step (§3.4/§3.8). parallel records which merge
produced the step, because cost and constraint propagation treat the two directions oppositely (§3.8).
Because compression is iterative, a compressed id in step t may itself be an original id inside step
t+1 — the maps compose. Back-expansion therefore walks the list and composes the per-step maps.
Forward composition to a single flat lookup is _build_cmp_reverse_map (networktools.py), which
threads original ids through intermediate compressed ids to a final {original_id: final_compressed_id}
table; full solution decompression walks cmp_mapReac in reverse (estimate_expansion_size reverses
it at networktools.py, and expand_sd composes the reverse maps) to turn a compressed
intervention set back into original-reaction interventions, re-injecting the flux-split/scaling factors
at each step. The complete decompression semantics — expanding a knockout of a lumped reaction into the
correct combination of original knockouts, handling parallel-vs-serial multiplicity, size-1 MCS
re-injection, and gene translation — are owned by Ch 9; this section only fixes the structure of the
map that Ch 9 consumes.
3.11 The legacy efmtool (Java) backend
Everything in §3.2–§3.10 describes the default compression engine: the pure-Python, exact
integer/rational sparse_rref backend. That engine is a reimplementation. The original backend —
and the one every pre-1.15 release actually ran — was efmtool, Marco Terzer’s Java tool for
elementary-flux-mode enumeration and network compression (the compression stage of efmtool is exactly
the coupled/zero/contradicting reduction that §3.4/§3.6 now do in Python). It is still shipped and
still reachable, selected with compression_backend='efmtool_rref', and this section documents how the
bridge works and why it has been demoted to legacy. Reading it also explains the vocabulary the
Python code inherited: the Python CompressionMethod enum (compression.py), the Python class
name StoichMatrixCompressor (compression.py), and the CoupledZero/CoupledCombine/
CoupledContradicting method names are all deliberate echoes of the efmtool Java API they replaced.
3.11.1 What efmtool is and how straindesign reaches it
efmtool is a Java library (namespace ch.javasoft.*, packaged as efmtool.jar alongside the Python
sources at straindesign/efmtool.jar). straindesign uses only its compression half — not its EFM
enumeration — through the classes loaded in efmtool_cmp_interface.py–:179:
ch.javasoft.smx.impl.DefaultBigIntegerRationalMatrix (an arbitrary-precision rational matrix),
ch.javasoft.smx.ops.Gauss (rational Gaussian elimination), ch.javasoft.metabolic.compress. StoichMatrixCompressor and CompressionMethod, and ch.javasoft.math.BigFraction /
java.math.BigInteger. The bridge is JPype: _start_jvm (efmtool_cmp_interface.py) starts an
in-process JVM, adds efmtool.jar to the classpath, and imports the Java classes via
jpype.imports so they become callable Python objects.
The routing has three layers.
Import time.
__init__.py–:53calls_start_jvmeagerly atimport straindesign. This is a no-op when jpype1 or a JVM is absent (neither is a package dependency), so a normal install never touches Java. When Java is present the JVM must be started here — before NumPy/OpenBLAS spins up worker threads — or JNI calls later crash with SIGBUS/SIGSEGV (__init__.py–:50; the code is littered with such mitigations, see §3.11.4).Backend selection.
compute_strain_designsreads the kwargcompression_backend = kwargs.get('compression_backend', 'sparse_rref')(compute_strain_designs.py) and threads it into bothcompress_modelcalls (:357–:360,:435).compress_modelsetsuse_java = (compression_backend == 'efmtool_rref')(compression.py).Dispatch inside the fixpoint. Crucially,
efmtool_rrefdoes not replace the whole compression pipeline — only two of its three reducers. Inside the alternating fixpoint (§3.7,compression.py–:1937):Parallel merge (step 1, §3.8) is always the Python hash-based
compress_model_parallel— efmtool has no equivalent and it is never routed to Java.Conservation removal (step 2, §3.5) forks on
use_java(:1907–:1910): Java goes through_remove_conservation_relations_java(:1943), Python throughremove_conservation_relations.Coupled merge (step 3, §3.4) forks inside
compress_model_coupled(:1985): Java callscompress_model_java(efmtool_cmp_interface.py), Python callscompress_cobra_model.
So
efmtool_rrefis really a hybrid: Python parallel-merge + Java conservation-removal + Java coupled-merge, iterated by the same Python fixpoint driver. The two backends differ only in the nullspace/rank algorithm used for steps 2 and 3.
3.11.2 Data marshalling: cobra model → Java → cobra model
The coupled step, compress_model_java (efmtool_cmp_interface.py), is where the interesting
marshalling lives. It mutates the cobra model in place and returns the same
{compressed_id: {orig_id: factor}} reaction map that the Python backend produces, so the rest of the
pipeline (module remapping, cost compression, decompression in Ch 9) is backend-agnostic.
Into Java.
stoichmat_coeff2rational(model)(:387) first converts every stoichiometric coefficient to an exactFraction/sympy-Rational— the same exactness discipline as §3.2.1, done before any Java call.All gene rules are cleared,
r.gene_reaction_rule = ''(:389), matching the Python coupled path (§3.9); GPR is re-attached afterward (below).A
DefaultBigIntegerRationalMatrix(num_met, num_active)is allocated (:407) and filled column by column. Reactions whose upper bound is≤ 0are flipped to the forward direction (model.reactions[mi] *= -1,:412–:415) and their index recorded inflipped; efmtool’s compressor assumes a canonical orientation. Each coefficientvis converted bysympyRat2jBigIntegerPair(:285) into a JavaBigIntegernumerator/denominator pair — usingBigInteger.valueOffor values that fit in 63 bits andBigInteger(str(...))otherwise — and set as aBigFraction(n, d)(:416–:418). This path is exact: efmtool’sDefaultBigIntegerRational Matrixis arbitrary-precision, so the Java core does not overflow.A
StoichMatrixCompressor(subset_compression)is built, wheresubset_compression = [CoupledZero, CoupledCombine, CoupledContradicting](:181–:183): remove structurally zero-flux reactions, combine coupled groups, and drop contradicting groups — the Java analogues of §3.3’s three removal kinds.smc.compress(stoich_mat, reversible, …, reacNames, None)(:423) returns acomprecwhosepostmatrix is the reaction transformation (the Java counterpart of the Pythonpostin §3.3,v_original = post · v_compressed).
Back to Python. Here is the seam that matters for correctness:
subset_matrix = jpypeArrayOfArrays2numpy_mat(comprec.post.getDoubleRows()) # :424 — DOUBLES
The structure of the compression (which original reaction maps into which compressed column, and the
zero pattern) is read back as a double-precision numpy matrix via getDoubleRows. The
per-reaction merge then:
flags a reaction zero-flux iff its
subset_matrixrow is all-zero (:432–:434);for each compressed column
j, gathers members fromsubset_matrix[:,j].nonzero(:437), scales each member’s stoichiometry by the exact factorjBigFraction2sympyRat(comprec.post. getBigFractionValueAt(ai, j))(:445–:446, exactBigFraction → sympy.Rational), and rescales its bounds by/= abs(subset_matrix[ai, j])(:447–:450, i.e. by a double);merges member reactions into the group representative, concatenating ids with
*and truncating past ~220 chars to...(:456–:467) — the same naming convention as the parallel backend (§3.8);records
subset_rxns/subset_stoichper representative (negating the stoich forflippedreactions,:452–:455) and finally assemblesrational_mapfrom them (:493–:499).
So the factors are exact rationals, but the pattern detection and the bound rescaling pass through
double precision. The suppressed_reactions argument (:367, :392) — reaction ids that must survive
because a strain-design module references them — are excluded from the active set entirely and re-added
as standalone identity entries (:480–:485), a workaround for efmtool’s CoupledContradicting step,
which will otherwise delete reactions it deems inconsistent (contrast the Python backend, which keeps
them via the exact bounds-intersection of §3.4.4). Back in compress_model_coupled the Java branch
then sweeps up any leftover (0,0) reactions (compression.py–:1994) and — identically to the
Python branch — re-attaches the AND-combined GPR from the pre-merge snapshot
(compression.py–:2015). GPR propagation is therefore the same for both backends on the
coupled step.
The conservation path. _remove_conservation_relations_java (compression.py) builds S as
a LIL matrix, densifies its transpose (stoich_mat.transpose.toarray, :1947), and hands it
to basic_columns_rat_java (efmtool_cmp_interface.py). That function wraps the dense array into a
DefaultBigIntegerRationalMatrix via numpy_mat2jpypeArrayOfArrays — which builds a JDouble[rows, cols] (:267) — then runs Gauss.getRationalInstance.rowEchelon(...) (:360) and returns the
pivot columns, i.e. the independent metabolite rows; the non-pivot metabolites are dependent
(conservation relations) and removed (compression.py–:1950). This is the exact-RREF
independence oracle of §3.5, but computed in Java — and note it marshals the stoichiometry through a
dense double array, both memory-heavy on genome-scale models and lossy for large coefficients.
3.11.3 Why it is legacy
The pure-Python sparse_rref engine (§3.2) was written to replace efmtool for four concrete reasons,
each a decisive advantage on a genome-scale correctness/performance workload:
No JVM / JPype dependency. efmtool needs a JVM, the
efmtool.jar,jpype1, andsympyall present and version-compatible (_init_java,efmtool_cmp_interface.py, raisesImportErrorfor any missing piece). The Python backend needs only NumPy/SciPy, which straindesign already depends on. A default that requires a working Java toolchain is a default that fails on many installs.Native-crash fragility. The bridge is defensive to a degree that itself signals the risk: eager JVM startup ordered before OpenBLAS threads (§3.11.1);
gc.disablewrapped around every JNI block (efmtool_cmp_interface.py–:363,:404–:426) because Python’s garbage collector finalizing a JPype proxy mid-call causes Bus error / SIGSEGV; anatexitJVM-shutdown hook to dodge a JPype teardown race (:150–:158). None of this can occur in a pure-Python engine.Big-integer safety at the interface. efmtool’s Java core is arbitrary-precision (
DefaultBig IntegerRationalMatrix), so the internal arithmetic does not overflow. The hazard is at the marshalling boundary: the compression structure and bound rescaling are read back throughgetDoubleRowsandabs(subset_matrix[...])in double precision (§3.11.2), and conservation removal pushesSthrough a denseJDoublearray. On models whose exact subdeterminants are huge — the verified extreme is yeast-GEM, needing ~263-bit coefficients (§3.2.5) — a double cannot represent those magnitudes, so bound rescaling and pattern detection silently lose precision. The Python engine keeps everything in Python big integers /Fractionend to end and switches to a dict-of-Fractionstore above int64 (§3.2.5), so it is exact even on yeast-GEM. This is the single most important reason the Python path is the default.It is the default and the tested path. The measured pipeline numbers (§3.1, and the iML1515 timings in CONTEXT) are all on
sparse_rref; that is the code that receives ongoing correctness work (e.g. the bounds-intersection fix of §3.4.4 / issue #44).
The trade-off, honestly stated. efmtool is not bad code — it is a mature, well-tested Java library
whose fraction-free rational Gauss elimination is fast compiled code, and for a decade it was the
compression engine for this and related tools. If you have a JVM handy and a model whose coefficients
stay comfortably inside double range, efmtool_rref will produce a correct compression at competitive
speed. Its costs are the heavy dependency stack, the native-crash surface, and the double-precision
marshalling seam. Given a pure-Python alternative that is exact to arbitrary precision, needs no JVM,
and is the maintained default, the Java backend earns its “legacy” label: there is essentially no
production reason to select it. The realistic remaining uses are (a) cross-validation — regression-
testing the Python engine’s output against the historical efmtool result on a model both can handle —
and (b) a fallback if a bug were ever found in the Python RREF. For everyday strain design, leave
compression_backend at its default.
3.11.4 Behavioral differences to be aware of
The two backends are intended to produce the same lossless flux-space compression, but they are not byte-identical and a few divergences are worth knowing:
GPR propagation is identical on the coupled step. Both backends clear gene rules before merging and re-attach the AND-combined GPR from the saved AST snapshot in
compress_model_coupled(compression.py–:2015), and the parallel OR-combine is always the Pythoncompress_model_parallel(§3.9). So GPR handling does not diverge between backends.Protected reactions are honored only by the Python backend.
compress_modelpasses gene- controlled reactions asprotected_reactions(no_coupled_compress_reacs,compression.py–:1925) so they survive COMPRESS #1 un-merged and gene multiplicity is preserved for GPR integration (§3.4.2, Ch 4).compress_model_javaignoresprotected_reactions— it reads onlysuppressed_reactions, whichcompress_modelnever populates on this path. On the Java backend those reactions can therefore be lumped in COMPRESS #1, a genuine semantic divergence in the gene-KO pipeline.Contradicting groups are handled differently. efmtool’s
CoupledContradictingdeletes groups it finds inconsistent (the reasonsuppressed_reactionsexists as a shield). The Python backend instead computes the exact bounds intersection of the coupled group and removes only genuinely empty/zero groups (§3.4.4). This is precisely the logic whose Java-era version “could drop reactions incorrectly” — the cautionary tale of closed issue #44 (Ch 10). The two backends can thus disagree on which reactions a contradicting group costs you.Direction bookkeeping differs. The Java path physically flips
ub ≤ 0reactions (*= -1) and negates their recorded stoich (efmtool_cmp_interface.py–:415,:452–:455); the Python coupled backend carries sign inside the exactratios(§3.4.3). Same flux space, different maps — which is fine because decompression (Ch 9) consumes whichever map its backend produced.Bound rescaling precision. Java rescales merged-reaction bounds by a double (
efmtool_cmp_interface.py–:450); the Python backend intersects bounds using exact rationals (§3.4.4). On well-scaled models this is invisible; on large-coefficient models it is another place the Java path can drift.
The safe reading: efmtool_rref is preserved for provenance and cross-checking, exercises the same
fixpoint and produces the same kind of map, but the exact-arithmetic Python backend is the one whose
compression you should trust for correctness-sensitive strain design.
4. GPR integration
Strain design asks a question posed in gene space — “which genes do I delete so that the cell
can no longer do X but can still do Y?” — but the machinery that answers it, the MILP built in later
chapters, lives entirely in flux/reaction space. Its variables are fluxes v ∈ ℝⁿ constrained by
S·v = 0 and bounds, and its binary intervention variables z toggle reactions on and off (Ch 6,
Ch 7). A gene is not a reaction. A gene influences a reaction only indirectly, through a Boolean
Gene–Protein–Reaction (GPR) rule: reaction PFK might carry the rule pfkA or pfkB, meaning the
reaction can run if either isozyme’s gene is expressed. Knocking out one gene of an or does
nothing to the flux; knocking out one gene of an and (a required subunit of an enzyme complex)
kills the reaction. The relationship between “genes deleted” and “reactions disabled” is a Boolean
function, not a simple map.
This chapter explains how straindesign makes gene knockouts expressible inside the same flux MILP.
The central idea — extend_model_gpr (networktools.py) — is to compile each Boolean GPR rule
into auxiliary flux structure: extra pseudo-metabolites and pseudoreactions bolted onto the
stoichiometric matrix, arranged so that the linear steady-state constraints reproduce exactly the
Boolean logic. After extension, “gene g is knocked out” becomes the purely linear statement “fix the
flux of pseudoreaction g to zero,” and the MILP’s existing reaction-knockout machinery handles it
with no separate Boolean-logic layer. We then cover the reversible-reaction split that GPR extension
forces (extend_model_gpr + the reac_map remap in compute_strain_designs.py), the
pre-pruning pass reduce_gpr (networktools.py) that shrinks the work, the delicate ordering of
the two compression passes around extension (compute_strain_designs.py), and the sha256 name
truncation that only fires for Gurobi/GLPK.
4.1 Why encode gene logic as flux structure at all
There are two ways to let a reaction-space MILP reason about gene knockouts.
Alternative A — post-hoc gene→reaction mapping. Solve the strain-design problem in reaction space as usual, producing reaction-knockout sets; then, after the fact, translate each reaction KO back to the genes that could cause it via the GPR rules. This is what the decompression/solution-translation step does in the reverse direction for reporting (Ch 9). But using it as the only gene mechanism is wrong for optimization, for three reasons:
The cost model is gene-based, not reaction-based. A minimal gene cut set minimizes the number (or cost) of genes deleted. One gene can disable several reactions (pleiotropy); several genes may need deleting to disable one reaction (an
andof subunits). A minimal reaction cut set optimizes the wrong objective and its cardinality does not correspond to any achievable set of gene deletions.and/orstructure creates feasibility that reaction-space cannot see. To disable a reaction guarded byg1 and g2, deleting either gene suffices — so the “cost” of killing that reaction is 1 gene, and the choice of which gene is itself a decision the optimizer should make (it may reuseg1to also kill another reaction). To disable a reaction guarded byg1 or g2, you must delete both genes — cost 2, and only that exact pair works. A reaction-level KO variable cannot represent “this reaction dies iff this particular Boolean combination of shared gene variables is all-off.”Genes are shared across reactions. The same gene appears in many reactions’ rules. A correct gene-MCS must count a shared gene once and account for all of its downstream reaction effects simultaneously. Post-hoc mapping, done per reaction, double-counts or misses these couplings.
Alternative B — a separate Boolean-constraint layer. Add binary gene variables y_g and, for each
reaction, a logical constraint v_r = 0 implied by the Boolean rule over the y_g. This is correct
but forces the MILP to carry two coupled logic systems — the flux LP and a Boolean CNF/DNF layer with
its own indicator or big-M linearizations of every and/or — roughly doubling the modeling surface
and the constraint count, and requiring bespoke code to linearize arbitrary nested Boolean trees.
The chosen design — encode the Boolean rule as flux. straindesign instead embeds the Boolean
function directly into the stoichiometry S, so the LP’s own S·v = 0 mass-balance is the Boolean
logic. No gene binaries, no second logic layer: a gene knockout is literally a reaction (pseudoreaction)
knockout of the same kind the MILP already handles, so the entire dualization/link_z machinery (Ch 6,
Ch 7) applies unchanged. The price is a modest number of extra rows/columns in S (one pseudoreaction
per surviving gene, plus one pseudo-metabolite/pseudoreaction per Boolean operator), which the second
compression pass (§4.5) then partly reabsorbs. The correctness guarantee that makes this legal is that
the extension does not change the reachable flux space of the original reactions (§4.3): all the new
structure is “upstream plumbing” whose only effect, when a pseudoreaction is fixed to zero, is to force
the guarded reactions to zero exactly when the Boolean rule says the enzyme is absent.
4.2 extend_model_gpr: turning a rule into pseudo-metabolites and pseudoreactions
extend_model_gpr(model, use_names=False) (networktools.py) walks each reaction’s GPR abstract
syntax tree (cobra parses the rule string into reaction.gpr.body, an ast.BoolOp/ast.Name tree)
and materializes it as network structure. The design has one supply primitive (a gene) and two
combinator primitives (and, or), each realized by a small stoichiometric gadget. Every gadget
obeys the same invariant: it produces a pseudo-metabolite that represents “this sub-expression is
TRUE (its enzyme/gene product is available),” and the guarded reaction is finally made to consume
one unit of the top-level pseudo-metabolite, so it can carry flux only if that metabolite can be
supplied.
Throughout, the pseudo-metabolites are abstract tokens — they have no physical units and appear in no
other balance except the gadget that defines them. The bounds on the gene pseudoreactions are [0, ∞)
(a one-directional source), so a gene “product” can be supplied in unlimited quantity but never
consumed negatively.
The gene gadget (leaf / ast.Name)
create_gene_pseudoreaction(gene_id) (networktools.py) does, for gene g:
create a pseudo-metabolite
g_{gene_id}(e.g.g_b0727);create a pseudoreaction whose id is the gene id (or gene name if
use_names=True) with reaction--> g_{gene_id}andupper_bound = ∞,lower_bound = 0(the default for a product-only reaction).
So the gene pseudoreaction is an unbounded source of the gene’s token. Symbolically, if w_g ≥ 0 is
the flux of gene g’s pseudoreaction, it contributes +w_g to the balance row of metabolite g_{g}.
Knockout = fix w_g = 0. To knock gene g out, the MILP’s z variable pins w_g = 0 (via
link_z, Ch 7, exactly as for any reaction KO). With the source shut, g_{g} can no longer be
produced, and — because in steady state it must also balance to zero — nothing downstream may consume
it. That “no consumption allowed” is precisely how “TRUE becomes FALSE” propagates through the gadgets.
The and gadget (ast.And)
create_and_metabolite(child_metabolites) (networktools.py): given the child pseudo-metabolites
c₁,…,c_k of the children of an and node, create one pseudo-metabolite A = c₁_and_…_and_c_k (the id
is the sorted children joined by _and_) and one pseudoreaction
c₁ + c₂ + … + c_k --> A (upper_bound = ∞)
This reaction consumes one unit of every child to produce one unit of A. Steady-state mass
balance then forces: to make A at rate f, each child metabolite c_i must be supplied at rate
f. If any child c_i cannot be supplied (its subtree is knocked out, so its production capacity is
0), then its balance row forces f = 0, hence A cannot be produced. A is available iff all
children are available — exactly Boolean and. A single shared consuming reaction is what couples
the children conjunctively.
The or gadget (ast.Or)
create_or_metabolite(child_metabolites) (networktools.py): given children c₁,…,c_k, create
one pseudo-metabolite O = c₁_or_…_or_c_k and k separate pseudoreactions, one per child:
c₁ --> O (upper_bound = ∞)
c₂ --> O
⋮
c_k --> O
Each reaction alone can produce O from a single child. Steady state: O can be produced (at any
positive rate) as long as at least one child can be supplied. If all children are knocked out,
every producing reaction is starved and O cannot be produced. O is available iff any child is
available — exactly Boolean or. Separate parallel producing reactions is what makes the children
disjunctive.
The recursion process_ast_node (networktools.py) applies these three rules bottom-up: a
ast.Name returns its g_{id} metabolite; a ast.BoolOp recursively resolves its children to their
pseudo-metabolite ids, then calls the and- or or-combinator on them; the return value bubbles the
top-level pseudo-metabolite id up to the reaction.
Attaching the rule to the guarded reaction
After process_ast_node(r.gpr.body) returns the top-level metabolite id M, the loop at
networktools.py does:
r.add_metabolites({model.metabolites.get_by_id(final_metabolite_id): -1.0})
i.e. it inserts a −1 coefficient for M into reaction r’s column. Now r consumes one unit of
M per unit of its own flux. Since M can only balance if it is produced by its gadget, and that
gadget can produce M only when the Boolean rule is satisfiable given the surviving gene sources,
r’s flux is forced to zero exactly when the rule evaluates FALSE under the current knockouts. When
the rule is TRUE, M is available in unlimited supply (all gadget reactions are [0,∞)), so r’s
flux is unconstrained by the gadget and behaves as before.
Reactions with no GPR rule are skipped entirely (if not r.gene_reaction_rule at the split loop,
and the AST loop guards on r.gpr and r.gpr.body); they get no pseudo-metabolite and are untouched.
There is a string-parsing fallback (networktools.py) that fires only if AST processing
raises: it splits the rule on ' or ' / ' and ' textually and rebuilds the same gadgets. It exists
for malformed or non-standard rule strings cobra’s AST cannot parse; the AST path is the norm.
Worked example: (g1 and g2) or g3
Take a reaction R1: A --> B (lb=0, so irreversible; the reversible case is §4.4) with GPR rule
(g1 and g2) or g3. Extension produces:
Gene sources (three pseudoreactions, each [0,∞)):
Wg1: --> g_g1
Wg2: --> g_g2
Wg3: --> g_g3
The and node over {g_g1, g_g2} → metabolite g_g1_and_g_g2 (sorted), one reaction:
R_g_g1_and_g_g2: g_g1 + g_g2 --> g_g1_and_g_g2
The or node over {g_g1_and_g_g2, g_g3} → metabolite O = g_g1_and_g_g2_or_g_g3 (children sorted),
two reactions:
R0_O: g_g1_and_g_g2 --> O
R1_O: g_g3 --> O
Finally R1 is edited to consume O:
R1: A + O --> B
Now check the Boolean truth table by asking, for each knockout pattern, whether R1 can carry flux
> 0 (equivalently whether O can be produced):
deleted genes |
|
|
|
can make |
can make |
|
rule value |
|---|---|---|---|---|---|---|---|
none |
free |
free |
free |
yes |
yes |
yes |
TRUE |
g1 |
0 |
free |
free |
no (and starved) |
yes via |
yes |
TRUE |
g3 |
free |
free |
0 |
yes |
yes via and-branch |
yes |
TRUE |
g1,g2 |
0 |
0 |
free |
no |
yes via |
yes |
TRUE |
g1,g3 |
0 |
free |
0 |
no |
no |
no |
FALSE |
g2,g3 |
free |
0 |
0 |
no |
no |
no |
FALSE |
g1,g2,g3 |
0 |
0 |
0 |
no |
no |
no |
FALSE |
This matches (g1 and g2) or g3 exactly. Note the minimal cut sets to disable R1 are {g1,g3} and
{g2,g3} — size 2 — which the flux structure discovers without any Boolean-logic code: it is just the
set of pseudoreaction knockouts that renders metabolite O unproducible in the LP.
4.3 Why the original flux space is unchanged (the correctness invariant)
The docstring claims “the metabolic flux space does not change.” Here is why that is exactly true (when no gene is knocked out), which is the property that legitimizes the whole encoding.
Let the original model have stoichiometry S ∈ ℝ^{m×n} over metabolites M and reactions R, with a
feasible flux v satisfying S·v = 0, lb ≤ v ≤ ub. Extension adds:
new metabolite rows (all the
g_*,*_and_*,*_or_*pseudo-metabolites) — call themP;new reaction columns (gene sources
w_g,and-reactions,or-reactions) — call their fluxesu, all with bounds[0, ∞);for each GPR reaction
r, a single−1entry in rowM_r(its top pseudo-metabolite) of columnr.
Claim (no-KO case). For every feasible original flux v, there exists a choice of pseudoreaction
fluxes u ≥ 0 such that (v, u) is feasible in the extended model; and conversely every feasible
extended flux, projected onto the original reaction columns, is a feasible original flux. Hence the
projection of the extended polytope onto the original reactions equals the original polytope.
Forward direction (construct u). Take any feasible v. Each GPR reaction r now demands its top
pseudo-metabolite M_r at rate v_r (coefficient −1, so consumption = v_r; for an irreversible
GPR reaction v_r ≥ 0 after the split of §4.4, so this is a nonnegative demand). Because every gadget
is a chain of [0,∞) reactions from the gene sources up to M_r, we can route supply to meet any
nonnegative demand: set each or-branch and and-reaction flux to carry exactly the demand upward,
and set each gene source w_g to the total demand routed through it (a nonnegative sum). Concretely,
push v_r up through one satisfying branch of each or and through the (unique) and-reaction,
accumulating at the gene sources. Every gadget reaction and gene source is [0,∞), and all demands are
≥ 0, so all these u values are feasible, and every pseudo-metabolite row balances by construction
(produced = consumed = the routed rate). Thus (v,u) is feasible.
Reverse direction (project). Given feasible (v,u), the original metabolite rows S·v = 0 are a
subset of the extended balance equations (the pseudo-metabolite rows only involve u and the new
−1 entries, never the original S entries), so S·v = 0 still holds, and the original bounds on v
are unchanged. Hence v is feasible in the original model.
Because the gadget reactions are unbounded above and the pseudo-metabolites appear in no other balance,
they never add any constraint on v in the no-KO case: for any v you can always find u. The only
way the extension can bite is when a gene source is fixed to zero — then the routing argument fails
for exactly those v_r whose every satisfying branch passes through a zeroed source, i.e. exactly the
reactions whose Boolean rule is now FALSE, forcing v_r = 0. That is the intended and only effect.
Two implementation details protect this invariant. First, the pseudoreactions are created once and
memoized: created_metabolites (a set) and the ... not in model.metabolites guards (e.g.
networktools.py, 1065, 1094) ensure a gene shared by many reactions gets a single g_{id}
source and metabolite, so all its reactions draw from the same tap — this is what makes a shared gene
count once and couple all its reactions. Second, the and/or metabolite ids are built from the
sorted child ids ("_and_".join(sorted(...)), "_or_".join(sorted(...))), so identical
sub-expressions occurring in different rules collapse to the same pseudo-metabolite and are not
duplicated — canonicalization by sorted name.
4.4 Reversible-reaction split and the reac_map remap
A subtlety: the invariant argument above needed v_r ≥ 0 so that a GPR reaction’s demand for its
pseudo-metabolite is nonnegative. But a reversible reaction has v_r free (lb < 0). If a reversible
GPR reaction consumed M_r with coefficient −1 and ran backwards (v_r < 0), it would produce
M_r — turning the guarded reaction itself into a spurious source of its own gene token, breaking the
logic (the reaction could “power its own enzyme”). Worse, the pseudo-metabolite balance would let a
reverse flux exist even with all genes deleted.
The fix (networktools.py): split every GPR-associated reversible reaction into a forward
and a reverse leg, both irreversible. For a reaction r with lb < 0:
build
r_rev = r * -1(all stoichiometric coefficients negated, so the reverse direction becomes a forward-running reaction);if
ris bidirectional (ub > 0too), give the reverse leg a distinct idr.id + '_reverse_' + hex(hash(r))[8:](networktools.py), clampr_rev.lower_bound = max(0, …), and keep the forward legrwithlower_bound = max(0, lb) = 0;if
ris purely reverse (ub ≤ 0), the forward leg is dropped (del_reac), only the reverse leg survives.
Both legs are now [0, ∞)-style irreversible, so each has a nonnegative demand for its own copy of the
GPR pseudo-metabolite, and the §4.3 argument holds for each leg independently. Removed/added reactions
are committed with model.remove_reactions(del_reac) / model.add_reactions(rev_reac)
(networktools.py) before the AST loop, so both legs get their own consumption edge to the
(shared) top pseudo-metabolite M_r — i.e. deleting the gene kills both directions at once, as it
must.
The reac_map bookkeeping. The function returns reac_map, a dict recording how each original
reaction id decomposes into the new columns and with what sign. For an un-split reaction:
reac_map[r.id] = {r.id: 1.0}. For a split bidirectional reaction:
reac_map[r.id] = {r.id: 1.0, r.id + '_reverse_xxxx': -1.0}
The signs encode the change of variables: the original signed flux v_r equals (+1)·v_fwd + (−1)·v_rev, because v_rev is the magnitude of flow in the reverse direction (its column is r*-1),
so a reverse flux of magnitude f corresponds to original v_r = −f. This is the standard
v = v⁺ − v⁻, v⁺,v⁻ ≥ 0 reversible-flux splitting, restricted here to GPR-associated reactions
only (non-GPR reactions are never split — no need, since they carry no pseudo-metabolite that a reverse
flux could corrupt).
Remapping the modules through reac_map (compute_strain_designs.py). Every strain-design
module refers to reactions by id in its CONSTRAINTS, INNER_OBJECTIVE, OUTER_OBJECTIVE, and
PROD_ID fields (Ch 1). If a referenced reaction was split, those references must be rewritten in the
new variables, using the same signed decomposition. For a constraint’s coefficient dict c[0]:
for k in list(c[0].keys()):
v = c[0].pop(k)
for n, w in reac_map[k].items():
c[0][n] = v * w
i.e. a term v·(x_k) becomes Σ_n (v·w)·(x_n) over the pieces n of k, with w ∈ {+1, −1}. For a
split reversible reaction this turns v·v_r into v·v_fwd − v·v_rev, faithfully preserving the signed
flux the module intended. Objectives (INNER_OBJECTIVE, OUTER_OBJECTIVE, PROD_ID) are single dicts
and remapped the same way (compute_strain_designs.py). Because reac_map contains an entry
for every reaction ({r.id: 1.0} for the untouched ones, networktools.py, 1149), the loop
can blindly remap every key without special-casing which reactions were split.
4.5 reduce_gpr: pruning before extension
Extension cost scales with the number of surviving genes and Boolean operators: each gene adds a
pseudoreaction + metabolite, each operator a gadget. Many genes can be proven irrelevant before any
of that structure is built, which both shrinks S and removes useless binary candidates from the MILP.
reduce_gpr(model, essential_reacs, gkis, gkos) (networktools.py) does this pruning, returning a
trimmed gkos (gene-KO-cost dict); it runs just before extend_model_gpr
(compute_strain_designs.py). Its steps:
Blocked reactions lose their GPR (
networktools.py). Any reaction with bounds(0,0)is dead anyway; its rule is cleared and genes that end up in no reaction are dropped. No point encoding logic for a reaction that can never carry flux.Protect genes that touch only essential reactions (
networktools.py). A gene whose reaction set is a subset ofessential_reacs(reactions that must stay operational — from the FVA over PROTECT/desired modules, Ch 5) can never be a useful KO: knocking it out could only threaten an essential reaction. It is added toprotected_genes.Protect genes that are individually essential to an essential reaction (
networktools.py). Usingis_gene_essential_to_reaction_ast, which evaluates the reaction’s GPR AST with that one gene set toFalseand checks whether the whole rule collapses toFalse: if deleting the gene alone would kill an essential reaction, the gene must be protected. (A gene inside anorof an essential reaction is not caught here — deleting it leaves the reaction alive — so it stays knockable.)Drop protected genes from the KO-cost dict (
networktools.py):[gkos.pop(pg.id) …]— they are no longer intervention candidates.Everything the user did not list as knockable is also protected (
networktools.py): genes whose id and name are absent fromgkoscannot be knocked out, so they are protected too.Genes with knock-in costs are un-protected (
networktools.py): a gene ingkisis a target (it can be added), so it is removed from the protected set even if the above rules caught it.Simplify each GPR rule with protected genes pinned TRUE (
networktools.py).simplify_gpr_astwalks the AST setting every protected gene toTrueand applies Boolean simplification (apply_gene_protection_to_ast,networktools.py):True and X → X,True or X → True, plus absorption (A or (A and B) → A,networktools.py). If the rule collapses toTrue, the reaction is no longer knockable-by-gene and its rule is cleared (so it gets no gadget at all); otherwise the simplified, smaller rule replaces the original — fewer operators, hence fewer gadgets at extension.Remove obsolete and protected genes from the model (
networktools.py), soextend_model_gprnever sees them.
The net effect: extend_model_gpr is handed a model whose GPR rules mention only genes that are (a)
user-declared knockable or knock-in-able and (b) capable of affecting a non-essential reaction, with
the rules already Boolean-minimized. On genome-scale models this removes a large fraction of genes and
operators before the expensive structure is built.
The id-vs-name subtlety. Genes can be referenced by either their id or their (human-readable)
name, and models are inconsistent about which the user supplies in gkos/gkis. reduce_gpr therefore
checks both: the protection rule at networktools.py protects a gene only if neither
g.id in gkos nor g.name in gkos, and the KI un-protection at networktools.py collects
g.id for g in model.genes if (g.id in gkis) or (g.name in gkis). Note the asymmetry that this matching
introduces downstream: extend_model_gpr names each gene pseudoreaction by id or name depending on
the global has_gene_names flag (use_names, decided at compute_strain_designs.py and passed in),
so the id-vs-name choice must stay consistent between the cost dicts and the pseudoreaction ids or the
later cost lookup silently misses (see Ch 10 for the fragility this creates). reduce_gpr hedges by
accepting both spellings; the pseudoreaction naming commits to one.
4.6 The two-compression-pass boundary and why regulatory genes are exempt from pass #1
Network compression (Ch 3) is run twice, straddling GPR extension:
COMPRESS #1 (
compute_strain_designs.py) runs on the pre-extension metabolic model withpropagate_gpr=True.COMPRESS #2 (
compute_strain_designs.py) runs afterextend_model_gpr, withpropagate_gprleft at its defaultFalse(compression.py).
Why two passes. The first pass compresses the genuine metabolic network while it is still small and
GPR-free, so the expensive lossless reductions operate on the original reactions. But it cannot
compress the gene pseudoreactions/pseudo-metabolites, because they do not exist yet. The second pass
runs on the extended network to reabsorb structure that extension introduced — chains of gene
sources, and/or gadgets, and split legs that turn out to be flux-coupled can be merged, shrinking
the MILP. Splitting the work this way keeps each pass cheap and lets the GPR structure benefit from
compression too.
Why propagate_gpr differs. In pass #1 the metabolic reactions still carry Boolean GPR strings.
When two reactions are merged, their rules must be combined correctly — an AND-merge for flux-coupled
reactions, an OR-merge for parallel ones (compression.py, 2040, the _combine_gpr_and/or helpers,
Ch 3) — so that after extension the merged reaction’s rule still reflects both originals. Hence
propagate_gpr=True. In pass #2 the rules have already been consumed by extend_model_gpr (converted
to flux structure) and the reactions’ gene_reaction_rule strings are no longer the source of truth —
the gadgets are. Propagating GPR strings again would be meaningless and double-count, so pass #2 uses
propagate_gpr=False and merges purely on stoichiometry.
Why regulatory-gene reactions are exempted from COMPRESS #1 (compute_strain_designs.py).
A gene-based regulatory intervention (a constraint like g <= X or g >= X on a gene, as opposed to
a plain KO g = 0) is applied by extend_model_regulatory after GPR extension, because it needs the
g_gene pseudo-metabolite to exist so the bound can be hung on the gene’s pseudoreaction flux. The
problem: if COMPRESS #1 merges several reactions that a regulatory gene controls, the merged reaction is
attached to that gene with a collapsed/rescaled stoichiometry — parallel/coupled merging multiplies
reactions by rational scale factors (Ch 3) — so a later gene-regulatory bound g <= X would be applied
against a mis-scaled flux and would not mean the same thing as in the uncompressed model. The code
comment (compute_strain_designs.py) states this directly: a pre-GPR merge “hooks the gene to
the merged reaction with the wrong (collapsed) stoichiometry, so a gene-regulatory bound … is
mis-scaled.”
The remedy is to exempt exactly the reactions controlled by a deferred-regulatory gene from merging
in pass #1. The block scans each deferred regulatory constraint string for tokens matching a gene id or
name (compute_strain_designs.py), collects that gene’s reactions into
no_coupled_compress_reacs, and passes them to compress_model so they are not coupled-merged; it
also adds them to no_par_compress_reacs (:353) so they are not parallel-merged and their names stay
stable across the two passes (the pass-#1 exemption matches them by name, so a rename would break the
matching). These same reactions do merge safely in pass #2, once extend_model_gpr has created the
g_gene metabolite and extend_model_regulatory has hung the bound on the gene pseudoreaction — at that
point the regulatory constraint lives on the gene source flux, not on the metabolic reaction’s
possibly-rescaled flux, so merging the metabolic reactions no longer distorts it. The comment is explicit
that plain gene KOs (=0) and KIs (unbounded when added) are unaffected and need no exemption —
only regulatory genes, whose bound is a finite scaled quantity, are sensitive to the stoichiometric
rescaling. (This exemption logic is the fix for closed issue #44’s class of bound-scaling bugs; see
Ch 3 for the compression bound-intersection mechanics and Ch 10 for the cautionary history.)
4.7 Name truncation (sha256), Gurobi/GLPK only
Extension generates pseudo-metabolite and pseudoreaction ids by concatenating child ids with _and_
/ _or_ separators. Nested rules over long gene ids can produce names hundreds of characters long.
Gurobi and GLPK impose a 255-character limit on variable/constraint names; CPLEX and SCIP do not.
The code sets MAX_NAME_LEN = 230 (networktools.py) and, only when the active solver is in
{GUROBI, GLPK} (checked at every id-construction site, e.g. networktools.py, 1043, 1059, 1072, 1088, 1103, 1144), truncates:
def truncate(id):
h = hashlib.sha256(id.encode()).hexdigest()[:20]
return id[0:MAX_NAME_LEN - 21] + "_" + h
i.e. it keeps the first 209 characters and appends _ + a 20-hex-char sha256 digest of the full id,
yielding a ≤230-char name. The digest suffix preserves uniqueness (two long ids sharing a 209-char
prefix still differ in hash) so distinct pseudo-metabolites do not accidentally collide after
truncation. A warning_name_too_long message (networktools.py) is logged once per truncated
name, suggesting the user switch to CPLEX or simplify gene names to avoid it.
Two properties matter for a maintainer. First, truncation is solver-conditional: the same model
produces different pseudoreaction ids under Gurobi/GLPK than under CPLEX/SCIP. Any code that matches
these ids by string (cost-dict lookups, module remapping, decompression) must therefore see the same
truncated names — which is why the id is truncated at the single point of creation and reused, not
re-derived elsewhere. Second, the sha256 rewrite is a known fragility, adjacent to open issue #43:
because the truncated name is not human-meaningful and because the truncation depends on solver
identity, a mismatch between where a name is generated and where it is later looked up can silently drop
a gene knockout from the reported solution. The mechanism and the concrete failure are owned by Ch 10;
here we only flag that the {GUROBI, GLPK}-gated sha256 truncation is the code path involved.
5. FVA in preprocessing
Flux Variability Analysis (FVA) — the pair of LPs that, for every reaction j, compute
min v_j and max v_j over the steady-state polytope {v : Sv = 0, lb ≤ v ≤ ub} (see
Ch 2 for the LP formulation) — appears three times in compute_strain_designs’s
preprocessing, at three different points in the pipeline, on three different versions of the
model, each time answering a different question and feeding a different downstream consumer.
None of the three is “just diagnostics”: each one removes work from the MILP that the solver
would otherwise have to do, and one of them (the second) is the single largest slice of
genome-scale wall-time. This chapter dissects all three, then the accelerated FVA engine
(speedy_fva) that all of them call, and closes by explaining why FVA #2 costs ~117 s.
The three uses, at a glance:
# |
Call site ( |
Model state |
Scope |
Question answered |
Consumer |
|---|---|---|---|---|---|
1 |
~L373–381 |
after COMPRESS #1, pre-GPR |
whole model |
Which reactions are essential for a PROTECT/desired behaviour? |
drop from |
2 |
|
after GPR extension + COMPRESS #2 |
whole model |
Which bounds never bind? Which reactions are blocked/irreversible? |
rewrite model bounds → shrink/condition the MILP |
3 |
~L460–491 |
after COMPRESS #2 |
knockable only ( |
Which knockable reactions are essential per module? Which are size-1 cut sets? |
drop essentials + size-1 MCS from |
All three ultimately dispatch to fva in lptools.py, which is a thin wrapper that
immediately calls speedy_fva (lptools.py). The legacy brute-force implementation
fva_legacy (lptools.py) is retained only as a debugging fallback.
5.1 The essentiality test — geometry of min(abs(range)) > 1e-10 and prod(sign(range)) > 0
Both FVA #1 and FVA #3 classify a reaction as essential (for a given module’s constraint
set) using the identical predicate, at compute_strain_designs.py and again at :465:
if np.min(abs(limits)) > 1e-10 and np.prod(np.sign(limits)) > 0: # find essential
essential_reacs.add(reac_id)
Here limits is the two-element vector [v_min, v_max] returned by FVA for reaction j,
i.e. the endpoints of the attainable flux interval [v_min^j, v_max^j] under that module’s
constraints. Read the predicate geometrically:
np.prod(np.sign(limits)) > 0—sign(v_min)·sign(v_max) > 0— is true iffv_minandv_maxhave the same, nonzero sign. That is exactly the statement the interval[v_min, v_max]does not contain 0. (If either endpoint were 0 the product would be 0; if the interval straddled 0 the signs would differ and the product would be negative.)np.min(abs(limits)) > 1e-10—min(|v_min|, |v_max|) > 10⁻¹⁰— is the numerical guard that the endpoint closest to zero is a strict, non-noise distance away from it, so the “does not contain 0” conclusion is not an artifact of solver tolerance.
Together they assert: every feasible flux state that satisfies the module’s constraints
routes a strictly nonzero, sign-definite flux through reaction j. Geometrically, the flux
polytope of that module lies entirely on one side of the hyperplane v_j = 0 and does not
touch it. Consequently, the constraint v_j = 0 (which is precisely what a knockout imposes)
is inconsistent with the module: knocking out j makes the module infeasible.
Why that matters depends on the module type, and this is the whole point of running FVA #1/#3
separately per module (for m in sd_modules:):
If the module is PROTECT/desired (a behaviour that must remain possible), a reaction essential to it can never appear in a valid design — knocking it out would violate the PROTECT requirement. Such a reaction is therefore useless as a knockout candidate and is stripped from
ko_cost(removing its binaryz_jfrom the MILP entirely).If the module is SUPPRESS (a behaviour that must be made impossible), a reaction essential to it is, by itself, a valid intervention: deleting it kills the behaviour. That is the size-1 MCS observation exploited by FVA #3 (§5.4).
A tiny worked example. Two reactions, R1: A→B, R2: B→C, sink EX_C, with a PROTECT
module requiring EX_C ≥ 1. FVA over {Sv=0, v≥0, EX_C≥1} yields v_R1 ∈ [1, 1000],
v_R2 ∈ [1, 1000]: both intervals sit strictly above 0, sign(1)·sign(1000)=+1, and
min(|1|,|1000|)=1 > 10⁻¹⁰. Both are flagged essential — correctly, since either KO drops
EX_C to 0 and breaks the PROTECT.
5.2 FVA #1 — essential reactions in PROTECT/desired modules (pre-GPR)
FVA #1 runs immediately after COMPRESS #1 and before GPR integration
(compute_strain_designs.py), so it sees a purely metabolic, compressed network with
no gene pseudoreactions yet (see Ch 4 for the COMPRESS #1/GPR boundary). It iterates only over
non-SUPPRESS modules:
for m in sd_modules:
if m[MODULE_TYPE] != SUPPRESS: # essentiality only meaningful for desired / opt-/robustknock
flux_limits = fva(cmp_model, solver=..., constraints=m[CONSTRAINTS], compress=False)
for (reac_id, limits) in flux_limits.iterrows():
if np.min(abs(limits)) > 1e-10 and np.prod(np.sign(limits)) > 0:
essential_reacs.add(reac_id)
[cmp_ko_cost.pop(er) for er in essential_reacs if er in cmp_ko_cost]
Rationale (why drop from ko_cost). As argued in §5.1, a reaction essential for a
required (PROTECT/desired) behaviour can never be part of any feasible design — its knockout
would violate a PROTECT constraint that the MILP is required to keep feasible. Every candidate
design that includes it is infeasible a priori. Popping it from cmp_ko_cost
removes its binary variable z_j from the intervention set the MILP will branch over: the
solver never even considers it, and no infeasible node is generated to reject it. This is a
pure model-size reduction with zero effect on the solution set.
Second consumer: reduce_gpr. The essential_reacs set computed here is passed straight
into GPR reduction (compute_strain_designs.py):
uncmp_gko_cost = reduce_gpr(cmp_model, essential_reacs, uncmp_gki_cost, uncmp_gko_cost)
reduce_gpr (networktools.py) simplifies the Boolean gene–protein–reaction rules before
they are compiled into flux structure (Ch 4). Knowing which reactions are essential lets it
also drop the genes that only ever control essential reactions from the knockable gene set:
if a reaction can never be knocked out, a gene whose only role is to (be required to) enable
that reaction is likewise non-knockable, and pruning it shrinks both the GPR encoding and the
gene KO cost dictionary. Thus one FVA pass feeds two reductions — reaction-level and, through
reduce_gpr, gene-level.
Why compress=False here. The model is already compressed (COMPRESS #1 just ran), so
speedy_fva’s own internal coupled-compression pass is switched off to avoid re-compressing an
already-compressed, rational-bound network. FVA #1 is comparatively cheap: it runs on the small
pre-GPR metabolic network and typically for a single PROTECT module.
5.3 FVA #2 — bound_blocked_or_irrevers_fva: relaxing non-binding bounds
FVA #2 runs after GPR extension and COMPRESS #2, so that all reactions — including the
gene pseudoreactions added by extend_model_gpr — are processed
(compute_strain_designs.py):
bound_blocked_or_irrevers_fva(cmp_model, solver=kwargs[SOLVER], compress=False)
Its body (networktools.py) runs one whole-model FVA and then rewrites each
reaction’s stored bounds (r._lower_bound / r._upper_bound directly, to make the change
permanent and bypass cobra’s optlang synchronisation) according to four independent
branches. With CPLEX/Gurobi the tolerance tol is 0.0; with SCIP/GLPK it is 1e-10
(networktools.py). Let [v_min, v_max] be the FVA interval and [lb, ub] the
current bounds.
if r.lower_bound < 0.0 and limits.minimum - tol > r.lower_bound: # (A) redundant lb → −inf
r._lower_bound = -np.inf ; n_lb_to_inf += 1
if limits.minimum >= tol: # (B) min ≥ 0 → lb = 0
r._lower_bound = max([0.0, r._lower_bound]) ; n_tightened_zero += 1
if r.upper_bound > 0.0 and limits.maximum + tol < r.upper_bound: # (C) redundant ub → +inf
r._upper_bound = np.inf ; n_ub_to_inf += 1
if limits.maximum <= -tol: # (D) max ≤ 0 → ub = 0
r._upper_bound = min([0.0, r._upper_bound]) ; n_tightened_zero += 1
Decoding the four branches:
(A) redundant lower bound → −∞. The reaction can go negative (
lb < 0), yet the achievable minimum fluxv_minis strictly greater thanlb. The lower bound therefore never binds — the network’s stoichiometry constrainsv_jmore tightly than the box bound does. Relaxinglbto−∞discards a constraint that is provably slack everywhere.(B) min ≥ 0 → lb = 0. FVA proves
v_jcannot be negative under steady state. The reaction is effectively irreversible in the forward direction, so its lower bound is pinned at 0 (max(0, lb)). Note the interaction with (A): a reaction withlb = −1000butv_min = 2first haslbset to−∞by (A), then overwritten to0by (B) because the branches are evaluated in sequence on the same reaction. The net effect islb = 0(irreversible), not−∞. Detecting irreversibility this way lets the MILP omit the negative half-space entirely.(C) redundant upper bound → +∞. Symmetric to (A):
ub > 0but the achievable maximumv_maxis strictly belowub, so the upper box bound never binds and is relaxed to+∞.(D) max ≤ 0 → ub = 0. Symmetric to (B): the reaction cannot carry positive flux, so it is irreversible in the backward direction and
ubis pinned at 0.
A reaction that is fully blocked (v_min = v_max = 0) triggers (B) and (D): lb and
ub are both pinned to 0, freezing it out of every flux state.
Decoding the real log line. bound_blocked_or_irrevers_fva emits, on iML1515 after GPR
extension (networktools.py):
FVA bounds: 4 lb→inf, 1825 ub→inf, 2258 tightened to 0, 2150 stayed finite
4 lb→inf= branch (A) fired 4 times: only 4 reactions had a genuinely reversible, slack lower bound. (Almost all reactions in a curated model are already forward-irreversible, so few have a slack negative lower bound to relax.)1825 ub→inf= branch (C) fired 1825 times: for 1825 reactions the upper bound was slack and is relaxed to+∞. This is the large one — most reactions’ nominal upper bound (e.g. the default 1000) never binds; the true maximum is limited by network stoichiometry.2258 tightened to 0= the combined count of branches (B) and (D) — the same countern_tightened_zerois incremented in both (networktools.pyand:1621). It therefore aggregates “lower bound pinned to 0 (forward-irreversible)” and “upper bound pinned to 0 (backward-irreversible / blocked)”. It is not a count of distinct reactions: a single reaction that triggers both (B) and (D) — i.e. a blocked reaction — is counted twice, and a reaction that triggers (A) then (B) contributes to bothn_lb_to_infandn_tightened_zero.2150 stayed finiteis computed independently atnetworktools.pyas the number of reactions with at least one finite bound after all rewrites:sum(1 for r in model.reactions if not isinf(r.lower_bound) or not isinf(r.upper_bound)). These are the reactions that were not fully relaxed to(−∞, +∞).
Because the four counters overlap (a reaction can increment several), they do not sum to the reaction count; only “stayed finite” is a clean per-reaction tally. This subtlety is easy to misread as an inconsistency — it is intentional (each counter reports how often a branch fired), not a bug.
Why relaxing a provably non-binding bound to ±∞ shrinks and conditions the MILP
This FVA is not cosmetic — it directly determines the size and numerical quality of the MILP
built next (SDMILP, Ch 6–7). The mechanism has two prongs.
(1) Only genuinely finite (binding) bounds become knockable constraints. In the MILP, a
reaction knockout is enforced by tying its binary z_j to the reaction’s flux-bound rows so
that z_j = 1 ⇒ v_j = 0; and in the dualized SUPPRESS block every finite reaction bound
becomes a dual variable with its own row and its own coupling to z (see Ch 6 for the
Farkas dualization and Ch 7 for link_z). A bound relaxed to ±∞ is, by definition, no
constraint at all: it contributes no row to the primal, hence no dual variable to the
dualized problem, and nothing for z to switch on that side. So every lb→−∞ (branch A) and
ub→+∞ (branch C) deletes a constraint row and, in the dual, a variable. On the numbers
above that is 4 + 1825 = 1829 bound rows removed. Conversely, the 2150 reactions that
“stayed finite” are exactly the ones whose remaining binding bound does need an
indicator/big-M linkage in the MILP — the relaxation has narrowed the set of reactions that
require this machinery to the ones that genuinely constrain flux.
(2) The remaining big-Ms get tighter. Where a knockout linkage is realised as a big-M
constraint (PROTECT’s finite-flux primal rows; the big-M vs indicator fork is emergent from
bound structure, Ch 7), the constant M must be a valid over-estimate of |v_j|. link_z
derives each M from a bounding LP over the reaction’s flux range. By replacing the loose
nominal box bounds (e.g. ±1000) with (a) the tight, FVA-proved range or (b) an honest
±∞ where the bound is slack, FVA #2 feeds link_z sharper information: reactions with a
proved finite range get a smaller, tighter M (better LP relaxation, faster branch-and-bound),
and reactions whose bound is genuinely non-binding are steered toward the indicator
formulation (which has no M at all and yields a tighter relaxation) rather than a
meaningless huge M. Both outcomes improve the MILP: fewer rows, tighter continuous
relaxation, better conditioning. (See Ch 7 for the exact self.M/bounding-LP fork.)
The important invariant: because branches (A) and (C) only relax bounds that FVA has proved never bind, and (B)/(D) only pin bounds the reaction can provably never cross, the feasible flux set is unchanged. No design is added or lost; only the description of the polytope is made leaner and better-conditioned.
5.4 FVA #3 — knockable-scoped essentials and size-1 MCS extraction
FVA #3 (compute_strain_designs.py) runs on the final, fully GPR-extended and
COMPRESS #2-compressed model, but — unlike #1 and #2 — it is scoped to knockable reactions
only via speedy_fva’s reaction_list kwarg:
knockable_ids = list(set(cmp_ko_cost.keys()) | set(cmp_ki_cost.keys()))
for m in sd_modules:
flux_limits = fva(cmp_model, solver=..., constraints=m[CONSTRAINTS],
compress=False, reaction_list=knockable_ids)
...
if m[MODULE_TYPE] != SUPPRESS:
essential_reacs.update(essentials_in_module) # essential for a PROTECT/desired module
else:
suppress_essential.update(essentials_in_module) # essential for the SUPPRESS module
Essentiality of a non-knockable reaction is irrelevant here — the MILP will never toggle its
z — so restricting FVA to knockable_ids avoids computing 2n LPs and instead computes only
2·|knockable|. The same essentiality predicate from §5.1 is applied, but now the results are
split by module type into two sets: essential_reacs (essential for some PROTECT/desired
module) and suppress_essential (essential for the SUPPRESS module).
Size-1 MCS: the core observation. A Minimal Cut Set is a smallest set of knockouts that
makes the SUPPRESS behaviour infeasible while keeping PROTECT feasible (Ch 1). A reaction that
is essential for the SUPPRESS behaviour but NOT essential for any PROTECT behaviour is,
all by itself, a valid cut set of size one: deleting it makes SUPPRESS infeasible (essential ⇒
v_j = 0 breaks it, §5.1), and — because it is not PROTECT-essential — deleting it leaves
PROTECT feasible. This is computed by a set difference
(compute_strain_designs.py):
is_classical_mcs = (len([m for m in sd_modules if m[MODULE_TYPE] == SUPPRESS]) == 1 and
all(m[MODULE_TYPE] == PROTECT for m in [... non-SUPPRESS ...]))
if is_classical_mcs and suppress_essential:
size1_mcs = suppress_essential - essential_reacs # SUPPRESS-essential, not PROTECT-essential
size1_mcs_knockable = {r for r in size1_mcs if r in cmp_ko_cost}
if size1_mcs_knockable:
cmp_size1_mcs = [{r: -1} for r in size1_mcs_knockable]
both_essential = suppress_essential & essential_reacs # essential for BOTH → non-knockable
essential_reacs.update(both_essential)
for r in size1_mcs_knockable:
cmp_ko_cost.pop(r, None) # remove from KO candidates
The is_classical_mcs guard. The size-1-MCS shortcut is only valid for a classical MCS
problem: exactly one SUPPRESS module and every remaining module a PROTECT
(compute_strain_designs.py). The guard exists because the “essential-for-SUPPRESS ⇒
valid single cut” argument relies on there being a single, well-defined behaviour to suppress
and only feasibility-preservation (not optimization) requirements to respect. In bilevel
problems (OptKnock/RobustKnock/OptCouple, which carry inner/outer objectives) or multi-SUPPRESS
problems, a reaction that is SUPPRESS-essential is not guaranteed to be a self-contained
minimal intervention — the objective coupling or a second SUPPRESS can make the “singleton”
either non-minimal or insufficient — so the shortcut is disabled and those reactions flow into
the ordinary MILP.
Why pull size-1 MCS out of ko_cost. Once a reaction r is known to be a size-1 cut set,
any larger design that contains r is non-minimal — it is a superset of the already-known
minimal cut {r}. Leaving r’s binary z_r in the MILP would invite the solver to enumerate
exactly those non-minimal supersets, wasting branch-and-bound effort and (in POPULATE mode)
polluting the solution pool with dominated designs that would only be filtered out later. So
each such r is popped from cmp_ko_cost, deleting z_r from the MILP. The
size-1 cuts themselves are stashed in cmp_size1_mcs as [{r: -1}] entries (the -1 encodes
“knock this reaction out”) and are re-injected as standalone solutions at decompression
(_decompress_solutions, Ch 9), so they still appear in the final result set — they are simply
solved by inspection instead of by the MILP.
Two guard details worth noting:
The filter
size1_mcs_knockable = {r for r in size1_mcs if r in cmp_ko_cost}restricts extraction to reactions that are pure KO candidates. Reactions carrying a KI or regulatory intervention are left in place (comment at:486–489), because they may still participate in non-KO solutions that the singleton-KO shortcut does not represent.both_essential = suppress_essential & essential_reacs: a reaction essential for BOTH the SUPPRESS and a PROTECT behaviour cannot be knocked out at all (it would break PROTECT), and is therefore folded intoessential_reacsand removed fromko_costby the final sweep atcompute_strain_designs.py.
5.5 The speedy_fva acceleration engine
Every FVA above calls fva → speedy_fva (speedy_fva.py). Understanding its algorithm
is essential because it is where the wall-time is spent, and its behaviour depends sharply on
the reaction_list scoping and compress flags the three call sites pass.
The naive FVA (fva_legacy, lptools.py) solves 2n independent LPs: for each of the
n reactions it sets objective +e_j and −e_j and solves to get v_min^j and v_max^j.
speedy_fva produces the identical result but replaces most of those 2n solves with a small
number of global scan LPs whose single optimal vertex simultaneously resolves the min or max
of many reactions at once. It is a two-phase algorithm.
Bookkeeping and the “resolved” mask
speedy_fva maintains, for the n reactions, boolean masks res_max, res_min and
incumbent vectors incumbent_max, incumbent_min (speedy_fva.py). A reaction’s max
(resp. min) is “resolved” when its true v_max (resp. v_min) is known. Three cheap
pre-resolutions run before any LP:
Fixed reactions (
|ub − lb| < 10⁻¹²):v_min = lb,v_max = ubwith no LP.reaction_listscoping: every reaction not in the requested list is marked resolved withNaNincumbents. This is how FVA #3’sreaction_list=knockable_idscollapses the problem — non-knockable reactions are simply never scanned or solved, and come back asNaNin the returned DataFrame.v = 0feasibility shortcut: if0is a feasible flux vector — which holds when no lower bound is strictly positive, no upper bound strictly negative, and there are no extra constraints (not np.any(lb > tol) and not np.any(ub < -tol) and not has_constraints) — then for every reaction whoselb = 0, the minimum is provably0(it cannot go belowlb=0, and0is attainable), and symmetrically every reaction withub = 0has maximum0. These are resolved for free, no LP. This single check typically clears a large fraction of an irreversible-heavy genome-scale model’s bounds.
Phase 1 — global scan LPs
(1b) The min Σ|x| scan LP. The first real LP minimizes the total absolute flux
Σ_j |v_j| subject to Sv = 0, the extra constraints, and the bounds (_build_abssum_lp,
speedy_fva.py). Absolute values are linearized by variable splitting: reactions are
classified as forward-only (lb ≥ 0, so |v_j| = v_j, objective coeff +1), backward-only
(ub ≤ 0, so |v_j| = −v_j, coeff −1), or truly reversible (lb < 0 < ub). For each
reversible reaction the variable is split v_j = p_j − n_j with p_j, n_j ≥ 0 and an
auxiliary equality row v_j − p_j + n_j = 0, and both p_j and n_j carry objective coeff
+1 so the objective equals p_j + n_j = |v_j| at optimum (speedy_fva.py).
Infinite bounds are clamped to ±BIG (=1000) purely so the push objective is bounded; this
does not alter feasibility.
The optimal vertex of this LP is the flux state with the least total flux. Its virtue is that
it drives most reactions to zero: any reaction sitting exactly at a lb = 0 or ub = 0
bound at this vertex is resolved by the vectorized bound scan _bound_scan
(speedy_fva.py), which marks res_max/res_min wherever |x_j − ub_j| < 10⁻⁹ or
|x_j − lb_j| < 10⁻⁹. In one LP this resolves the min/max of every reaction that touches a
zero bound at the min-flux vertex. Simultaneously the vertex’s flux values update the
incumbents (np.maximum(incumbent_max, x_scan), np.minimum(incumbent_min, x_scan),
): even a reaction not proved extreme has its known range widened by this
witness — co-optimization, one LP contributing evidence about n reactions at once.
(1c) Iterative push-to-bounds with warm-started dual simplex. The remaining unresolved
maxima are attacked collectively: a single objective c puts −1 on every reaction whose
max is still unresolved (speedy_fva.py) and the LP is re-solved — pushing all of
them toward their upper bounds at once. Whatever lands on its ub is resolved by _bound_scan;
incumbents update for the rest. The symmetric objective with +1 on unresolved-min reactions
pushes toward lower bounds. This alternation repeats
(while True: ... if resolved_this_round < 5: break) until a round resolves
fewer than 5 new bounds — i.e. until the cheap global pushes stop paying off.
The critical performance ingredient is that the scan LP object is reused across all these
re-solves — only the objective vector changes (scan_lp.set_objective(...)), never the
constraint matrix — and the solver is set to dual simplex (set_lp_method(LP_METHOD_DUAL),
). Changing only the objective keeps the previous basis primal-feasible but
dual-infeasible, which is exactly the situation dual simplex resumes from cheaply: each
re-optimization is a warm-started handful of pivots rather than a cold solve. Dozens of push
LPs therefore cost a small multiple of one LP.
Phase 2 — individual LPs for the residual
Whatever Phase 1 could not resolve (n_remaining = 2n − n_done) is finished with
individual per-objective LPs, dispatched one of two ways (speedy_fva.py):
Parallel (
n_remaining ≥ 1000 and threads > 1): the unresolved objective indices (even = max, odd = min, viaidx2c) are farmed to anSDPoolof workers, each holding its own persistent LP (fva_worker_init/fva_worker_compute), with a NaN-retry loop for any solve that returns NaN.Sequential (
0 < n_remaining < 1000, orthreads == 1): a single warm-started LP is stepped through the residual objectives withset_objective_idx, periodically rebuilt every 200 solves to limit warm-start basis degeneration. Each solved vertex is also run through_bound_scanand the incumbent update, so even in Phase 2 one LP can opportunistically resolve other pending reactions — the same co-optimization trick. A correctness guard detects when a warm-started optimum is worse than the incumbent (a sign of a degenerate/stale basis) and rebuilds the LP and re-solves from scratch for that objective.
threads auto-selects to Configuration.processes only when the model has ≥ 1000
reactions, else 1. Note the asymmetry that drives §5.6: the parallel path is
gated on n_remaining ≥ 1000, i.e. on how many objectives survive Phase 1, not on the
model size.
Internal compression (compress) and result expansion
When compress is None/True and the model has ≥ 200 reactions,
speedy_fva first lumps flux-coupled reactions and removes conservation rows
(_compress_for_fva) — a single nullspace pass (no recursive fixpoint), since FVA
needs only first-order couplings — runs FVA on the smaller compressed model, then expands the
results back via _expand_fva, scaling lumped reactions by their coupling factor
(with a min/max swap when the factor is negative) and filling blocked reactions
with 0/0. All three preprocessing call sites pass compress=False, because the model is
already compressed by the pipeline’s own COMPRESS passes; this is the key fact for §5.6.
Contrast with fva_legacy
fva_legacy (lptools.py) always solves the full 2n LPs (parallel over an SDPool when
processes > 1 and numr > 300, else a serial warm-started loop), with no scan phase, no v=0
shortcut, no co-optimization, and no reaction_list scoping. On genome-scale models
speedy_fva’s Phase 1 typically resolves well over half of the 2n objectives with a handful
of scan LPs, so the residual handed to Phase 2 is a fraction of 2n. The two return identical
DataFrames (both post-process |value| < 10⁻¹¹ → 0); fva_legacy exists purely as a
debugging oracle.
5.6 Why FVA #2 is the ~117 s genome-scale bottleneck
On the canonical iML1515 gene-MCS run (SUPPRESS biomass ≥ 0.001, POPULATE, max_cost=3,
gene_kos), preprocessing’s blocked/irreversible FVA — FVA #2 — measures at ~117 s,
the single largest preprocessing slice (Ch 11). Every structural reason for this is visible in
the three call sites and in speedy_fva’s control flow:
It is whole-model — no
reaction_list. FVA #2 (bound_blocked_or_irrevers_fva,networktools.py) forwards its kwargs tofvawith noreaction_list, sospeedy_fvamust resolve all2nobjectives — every bound of every reaction — because the bound-relaxation logic in §5.3 needs the true range of every reaction, not just knockable ones. FVA #1 is also whole-model but runs on the smaller pre-GPR network; FVA #3 is scoped toknockable_idsand so solves only2·|knockable|objectives. FVA #2 is the only one paying the full2non the large model.It runs on the GPR-extended model, which is much larger. FVA #2 executes after
extend_model_gpr, which injects a gene pseudoreaction per gene and additional pseudoreactions/pseudo-metabolites to encode the Boolean AND/OR structure (Ch 4). On iML1515 this roughly doubles the reaction count relative to the metabolic-only network FVA #1 saw. The log line’s totals (1825+2258+2150+ …) reflect a network of several thousand reactions. More reactions ⇒ more objectives and larger per-LP factorizations.Internal compression is disabled (
compress=False). Because the model is already compressed by COMPRESS #2, FVA #2 passescompress=False, sospeedy_fvadoes not run its own coupled-lumping pass — it solves LPs at the full GPR-extended dimension rather than a reduced one. This is correct (re-compressing the rational-bound model would be wasteful and the caller needs bounds on the actual reactions), but it means no dimension reduction cushions the LP cost.Phase 2 likely drops below the parallel threshold.
speedy_fvaparallelizes Phase 2 only whenn_remaining ≥ 1000. Phase 1’s scan LPs are very effective at resolving the many trivially-bounded reactions of a GPR-extended model (huge numbers of forward-only reactions withlb=0, resolved by thev=0shortcut and themin Σ|x|scan), so the residual handed to Phase 2 can fall below 1000 — at which point Phase 2 runs the sequential, single-threaded path, grinding through the residual individual LPs one at a time. A residual of a few hundred genome-scale LPs solved serially, each on a several-thousand-variable model, accounts for the bulk of the 117 s. (Phase 1’s own push LPs are cheap thanks to dual-simplex warm-starting; the cost concentrates in the serial Phase 2 tail.)
This makes FVA #2 a concrete, high-value performance lever (Ch 11). Candidate mitigations
that follow directly from the analysis above: force Phase 2 onto the parallel path even for
n_remaining < 1000 (or lower the threshold) so the residual LPs use all cores; or restrict
FVA #2’s objectives to the reactions whose bounds can actually matter downstream — although,
unlike FVA #3, it genuinely needs all reactions’ ranges to relax bounds correctly, so a
reaction_list restriction is not directly applicable and any scoping must be justified against
the bound-relaxation semantics of §5.3. The safe, immediately-available win is parallelism on
the Phase 2 tail.
6. Dualization (the mathematical core)
Everything the strain-design MILP does to a behaviour — forbid it (SUPPRESS), keep it possible (PROTECT), or force an inner optimizer to reach its optimum (OptKnock, RobustKnock, OptCouple, DoubleOpt) — is expressed through one of two linear-programming duality operations applied to a standard-form linear system. This chapter derives those two operations, states the theorems they instantiate, and reads the code that builds them:
LP_dualize(strainDesignProblem.py) — the LP dual of a maximization LP, used to certify optimality of an inner problem by strong duality.farkas_dualize(strainDesignProblem.py) — the Farkas (alternative-system) dual, used to certify infeasibility of an undesired flux region.
Both are the same matrix transpose with different bookkeeping, and farkas_dualize literally
calls LP_dualize (strainDesignProblem.py). Understanding the one construction, and the
two theorems it serves, explains the entire block-assembly logic of addModule
(strainDesignProblem.py).
This chapter produces the continuous rows — dual variables, dual-feasibility constraints,
strong-duality equalities, and the primal blocks they are paired with. It does not attach the
binary intervention variables z to those rows; that is link_z (strainDesignProblem.py)
and is owned by Ch 7. Where the dual bookkeeping matrices z_map_vars, z_map_constr_ineq,
z_map_constr_eq are updated here, we explain what they now point at so Ch 7 can wire them, but
the actual big-M / indicator machinery is deferred there.
Notation follows Ch 1: the metabolic model has stoichiometry \(S \in \mathbb{R}^{m\times n}\) over n (compressed)
reactions, flux vector \(v \in \mathbb{R}^n\), steady state \(Sv = 0\), bounds \(lb \le v \le ub\). A module adds
extra linear constraints \(V_{\text{ineq}}\, v \le v_{\text{ineq}}\), \(V_{\text{eq}}\, v = v_{\text{eq}}\) describing a flux behaviour.
6.1 Why dualize at all
A strain-design constraint is a statement about the solvability of an inner LP, and such statements cannot be written directly as linear constraints on the outer variables.
SUPPRESS demands: after the knockouts encoded by
z, the undesired region \(\{v : Sv=0,\ V_{\text{ineq}} v \le v_{\text{ineq}},\ lb \le v \le ub\}\) is empty. “This polyhedron is empty” is not a linear constraint onv— indeed there is novto constrain. Farkas’ lemma converts it into “there exists a dual vectorysatisfying a feasible linear system,” which is linear and can live in the outer MILP.OptKnock / inner-objective PROTECT demands: the flux
vis optimal for the inner objective \(\max c_{\text{inner}}^\top v\) over the (knocked-out) network. “Is optimal” is a quantifier over all other feasible fluxes. LP strong duality collapses it to three linear conditions — primal feasibility, dual feasibility, and equality of the two objective values — all linear once the dual variables are introduced.
In both cases dualization is the device that turns a nested optimization / feasibility quantifier
into a flat system of linear (in)equalities that a single-level MILP can hold. The binary z then
switch individual rows of that flat system on and off (Ch 7), which is why the dual must be built so
that each z still maps cleanly onto the object (a reaction) it knocks out — the role of the
z_map_* matrices threaded through every function below.
6.2 LP duality refresher, in the exact standard form the code uses
6.2.1 Primal standard form
Every primal the code dualizes is produced by build_primal_from_cbm
(strainDesignProblem.py) and has the shape
with x ∈ ℝ^{n}. For a bare metabolic primal, A_eq = S (so b_eq = 0, Sv=0), A_ineq holds
the module’s V_ineq rows, and lb, ub are the flux bounds (strainDesignProblem.py).
The sense is maximization. The transform it implements is the dual of the maximization \(\max c^\top x\) — the LP_dualize docstring documents this convention. It is easy to get the sense wrong, so it was verified directly (see §6.2.4): dualizing the metabolic primal with the
biomass objective and solving the returned dual reproduces the FBA optimum only under the max
reading. Throughout addModule, a maximize-sense inner objective is stored negated precisely so
that the downstream strong-duality equality comes out as a clean sum-to-zero (§6.4).
Variables carry a sign class, and it is the class — not the numeric bound values — that decides
the dual constraint sense. The code computes the three classes at strainDesignProblem.py
from the original bounds, before any bound is rewritten:
x_geq0 = { j : lb_j ≥ 0 and ub_j > 0 } # sign-nonnegative
x_eR = { j : lb_j < 0 and ub_j > 0 } # free (both signs reachable)
x_leq0 = { j : lb_j < 0 and ub_j ≤ 0 } # sign-nonpositive
A reversible reaction (\(lb<0<ub\)) is free; an irreversible forward reaction (\(lb=0\)) is nonnegative; a strictly-reverse reaction is nonpositive. The finite, nonzero magnitudes of the bounds are handled separately (see §6.2.3): they are not what selects the dual sense.
6.2.2 Weak duality, strong duality, complementary slackness
For the pair (P) above and its dual (D) (constructed in §6.2.3),
Weak duality. For any primal-feasible
xand dual-feasible \(y = (\lambda, \mu)\), \(c^\top x \le b^\top y\) where \(b = (b_{\text{eq}}, b_{\text{ineq}})\). The primal max is bounded above by every dual value.Strong duality. If (P) has a finite optimum, so does (D), and the optima coincide: \(\max c^\top x = \min b^\top y\). This is the theorem the bilevel modules exploit.
Complementary slackness. At optimality, for each inequality either the primal row is tight (\(A_{\text{ineq}}[i,:]\, x = b_{\text{ineq}}[i]\)) or its multiplier vanishes (\(\mu_i = 0\)); symmetrically for a sign-constrained primal variable \(x_j\) and its dual reduced-cost row. The MILP never encodes complementary slackness explicitly — it uses the equivalent strong-duality equality \(c^\top x = b^\top y\) (§6.4), which is one linear row instead of a disjunction per constraint and needs no extra binary variables. This is the deliberate design choice over a KKT/complementarity encoding.
The value of dualization is exactly the strong-duality clause: primal feasibility ∧ dual
feasibility \(\wedge\) (\(c^\top x = b^\top y\)) is, by the theorem, equivalent to “x is optimal for (P)” — a
statement with a universal quantifier, now written as flat linear rows.
6.2.3 LP_dualize line by line
LP_dualize(A_ineq_p, b_ineq_p, A_eq_p, b_eq_p, lb_p, ub_p, c_p, z_maps…) returns the dual system
in the same standard container (A_ineq, b_ineq, A_eq, b_eq, lb, ub, c, z_maps…), so that dualized
systems can themselves be re-dualized (RobustKnock does this — §6.5.3).
Step 1 — inhomogeneous bounds become inequality rows (strainDesignProblem.py). A
finite nonzero lower/upper bound is not left on the variable; it is appended to A_ineq_p as an
explicit row so it acquires its own dual multiplier:
lb_j finite, ≠ 0: −x_j ≤ −lb_j (row in LB, line 1111)
ub_j finite, ≠ 0: x_j ≤ ub_j (row in UB, line 1112)
A_ineq_p ← [A_ineq_p ; LB ; UB] (line 1113)
b_ineq_p ← b_ineq_p + [−lb_j…] + [ub_j…] (line 1114)
Zero bounds and ±∞ bounds are skipped (an x_j ≥ 0 reaction contributes no LB row; its
nonnegativity is carried by the sign class, not a row). This is why the sign class and the bound
magnitude are decoupled: sign → dual constraint sense; finite magnitude → an extra ≥0 dual
variable.
Step 2 — variable class ⇒ dual constraint sense (strainDesignProblem.py). Writing
the stacked primal constraint columns as \(A[:,j] = (A_{\text{eq}}[:,j]\, ;\, A_{\text{ineq}}[:,j])\) and the stacked dual
vector as \(y = (\lambda\, ;\, \mu)\), the transpose is split by class:
primal variable |
class |
dual row built |
code |
|---|---|---|---|
free \(x_j \in \mathbb{R}\) |
|
\(A_{\text{eq}}[:,j]^\top \lambda + A_{\text{ineq}}[:,j]^\top \mu = c_j\) (equality) |
|
\(x_j \ge 0\) |
|
\(-(A_{\text{eq}}[:,j]^\top \lambda + A_{\text{ineq}}[:,j]^\top \mu) \le c_j\) i.e. reduced-cost row into |
|
\(x_j \le 0\) |
|
\((A_{\text{eq}}[:,j]^\top \lambda + A_{\text{ineq}}[:,j]^\top \mu) \le -c_j\) |
The free-variable rows land in the dual’s A_eq (equality — a free primal variable forces
stationarity exactly), the sign-constrained rows land in the dual’s A_ineq (a one-sided
reduced-cost / dual-feasibility condition). This is the textbook correspondence
primal variable → dual constraint
x ∈ ℝ → =
x ≥ 0 → (one-sided inequality)
x ≤ 0 → (one-sided inequality, opposite)
read off the columns of [A_eq ; A_ineq].
Step 3 — constraint class ⇒ dual variable class (strainDesignProblem.py). The dual
variables are ordered [λ (one per A_eq row) ; μ (one per A_ineq row)] with bounds
lb = [−∞]·(#A_eq rows) + [0]·(#A_ineq rows) (line 1124)
ub = [+∞]·(#A_eq rows + #A_ineq rows) (line 1125)
So an equality primal constraint → free dual variable (λ_i ∈ ℝ), an inequality primal
constraint → sign-constrained dual variable (μ_i ≥ 0). The steady-state rows Sv=0 therefore
produce free dual variables (the classical metabolite “shadow prices,” unbounded in sign), while
every bound/module inequality produces a nonnegative dual variable. This is the symmetric partner
of the table above:
primal constraint → dual variable
= → y ∈ ℝ
≤ → y ≥ 0
Step 4 — dual objective (strainDesignProblem.py). c = b_eq_p + b_ineq_p: the dual’s
objective coefficients are the primal’s right-hand sides, in the same [λ ; μ] order. Because
b_eq = 0 for the pure Sv=0 rows, only the module RHS and the finite-bound rows contribute — the
dual objective is a weighted sum of flux bounds and module thresholds.
Step 5 — the z-map transposition (strainDesignProblem.py). This is the part that
makes dualization reusable inside a knockout MILP, and it is the reason these functions carry the
three bookkeeping matrices everywhere. A knockout removes a reaction; in the primal a reaction is
a variable, but after dualization the same knockout must remove the corresponding dual object.
The maps are transposed accordingly:
# a knockable primal VARIABLE (reaction flux) becomes a knockable dual CONSTRAINT
z_map_constr_ineq ← [ z_map_vars_p[:, x_geq0] , z_map_vars_p[:, x_leq0] ] (line 1130)
z_map_constr_eq ← z_map_vars_p[:, x_eR] (line 1131)
# a knockable primal CONSTRAINT becomes a knockable dual VARIABLE
z_map_vars ← [ z_map_constr_eq_p , z_map_constr_ineq_p , 0(for the new LB/UB rows) ] (line 1132-1133)
Reading it in words: reaction j’s flux variable maps, after dualization, onto its reduced-cost
row (its dual constraint); a knockable primal constraint maps onto its dual multiplier. The
appended LB/UB bound rows get zero columns in z_map_vars — their multipliers are never knocked out
directly (their knockout is handled through the flux variable they bound). The overlap guard at
strainDesignProblem.py enforces the invariant that no single z simultaneously flags a
variable and a constraint in the same block, which would make the transpose ambiguous.
Step 6 — reassign_lb_ub_from_ineq (strainDesignProblem.py, defined at
:1207). After transposing, many dual A_ineq rows are single-entry (a reduced-cost row on a dual
variable with no metabolic coupling). This helper folds single-variable inequality rows back into
lb/ub on the dual variables, except where the row is flagged knockable (z_map_constr_ineq
nonzero), because a knockable row must remain an explicit constraint for z to switch. This keeps
the dual compact and is also where the “negative-ub / positive-lb stay as rows” subtlety lives
(shared with prevent_boundary_knockouts, Ch 7).
6.2.4 What LP_dualize does and does not guarantee
LP_dualize returns the constraints that define the dual feasible set plus the dual objective.
It does not, on its own, deliver strong duality as a solved number — and it is not meant to. Two
verified facts pin this down (e_coli_core, biomass objective):
Solving the returned dual as a standalone LP gives objective value
0, not the FBA optimum0.873922. The dual feasible set contains \(y = 0\) (withc_pon a nonnegative variable and \(b_{\text{eq}} = 0\)), so minimizing \(b^\top y\) alone certifies nothing.Strong duality appears only after the coupling row is added (§6.4). Assembling primal ⊕ dual ⊕ the single equality \(c_v^\top x + c_{\text{dual}}^\top y = 0\) and then both maximizing and minimizing biomass over the joint system returns \(\max = \min = 0.873922\) exactly — biomass is pinned to its FBA optimum.
So the correct mental model is: LP_dualize supplies dual feasibility; the caller supplies the
objective-equality row; the strong-duality theorem does the rest. The sign conventions in the
table of §6.2.3 are exactly those under which that composite is correct — this was checked
end-to-end, not merely per-row.
6.3 Farkas’ lemma and the SUPPRESS infeasibility certificate
6.3.1 The lemma
Farkas’ lemma is the theorem of the alternative for linear systems. One standard form: exactly one of the following holds —
Geometrically, (I) says b lies in the finitely-generated cone \(\{A x : x \ge 0\}\); (II) says a
hyperplane through the origin (normal y) has the cone on one side (\(A^\top y \ge 0\)) and b strictly on
the other (\(b^\top y < 0\)) — a separating hyperplane. For the mixed \(\le / =\), sign-constrained system
the code uses, the corresponding alternative is: the primal region
{ x : A_ineq x ≤ b_ineq, A_eq x = b_eq, lb ≤ x ≤ ub }
is empty if and only if there exists a dual vector y that is feasible for the homogeneous
dual (the dual constraints with objective \(c = 0\)) and additionally makes \(b^\top y < 0\). Such a y
is a Farkas certificate (a separating / infeasibility certificate).
6.3.2 farkas_dualize
farkas_dualize (strainDesignProblem.py) builds precisely system (II) for the undesired
region. Its steps:
Zero objective (
strainDesignProblem.py):c_p = [0,…,0]. The certificate is about feasibility, not optimization, so there is no objective. This also removes the entire reduced-cost RHS from the dual constraints of §6.2.3 (all right-hand sides±c_jbecome0), leaving the homogeneous dualA^T y ≥ 0 / = 0.Dualize (
strainDesignProblem.py): callLP_dualizewith that zero objective. The returned(A_ineq_d, b_ineq_d, A_eq_d, b_eq_d, lb_f, ub_f, c_d, …)is the homogeneous dual; cruciallyc_d = b_eq_p + b_ineq_pis the primal right-hand side vectorb.Normalization row (
strainDesignProblem.py): append one inequalityA_ineq_f = [ A_ineq_d ; c_d ] b_ineq_f = b_ineq_d + [ −1 ]
i.e. \(c_d^\top y \le -1\), which is \(b^\top y \le -1\). This is the \(b^\top y < 0\) clause of Farkas’ lemma, with the strict inequality replaced by a fixed slack \(\le -1\). A knockable-column of zeros is added to
z_map_constr_ineqfor this new row (strainDesignProblem.py) — the normalization row is structural and never itself knocked out.
The result is a feasibility system in y: it is solvable exactly when the undesired region is
infeasible. Making the undesired region infeasible after knockouts therefore reduces to keeping
this dual system feasible after the same knockouts — which is a set of ordinary linear rows the MILP
can hold, with z switching the rows that correspond to knocked reactions (via the transposed
z_map from §6.2.3). This is the SUPPRESS branch: addModule calls farkas_dualize at
strainDesignProblem.py and sets a zero module objective c_i at :670.
6.3.3 Why the certificate is unbounded by nature, and the normalization row
A Farkas certificate is a recession ray, not a point. If \(y^*\) satisfies \(A^\top y^* \ge 0\) and
\(b^\top y^* < 0\), then for any scalar \(\alpha > 0\), \(\alpha y^*\) satisfies \(A^\top(\alpha y^*) \ge 0\) and \(b^\top(\alpha y^*) < 0\) as
well — the homogeneous constraints and the strict sign are both scale-invariant. The certificate
lives on an open ray through the origin; the dual variables are intrinsically unbounded (the
code sets ub = +∞ and, for the Sv=0-derived duals, lb = −∞; §6.2.3).
The \(c_d^\top y \le -1\) normalization does two jobs at once:
Pins the scale. Without it, \(y = 0\) is feasible for the homogeneous system (\(A^\top\cdot 0 \ge 0\)), and
0certifies nothing. Requiring \(b^\top y \le -1\) forcesystrictly off the origin and onto the ray, turning “\(\exists y : b^\top y < 0\)” (an open condition, awkward for a solver) into the closed, numerically stable “\(\exists y : b^\top y \le -1\).” Any true certificate can be rescaled to satisfy it, so no certificate is lost.Fixes the orientation. It selects the half-line with \(b^\top y < 0\), discarding the trivial \(y = 0\) and the wrong-sign ray.
A direct performance consequence follows from the unboundedness: FVA-style bound tightening cannot
bound these dual variables. The preprocessing FVA (Ch 5) tightens variable ranges by
maximizing/minimizing each variable over the polytope; for a Farkas dual variable that range is
(−∞, +∞) by construction (the feasible set is a cone, scale-free), so FVA returns ±∞ and buys
nothing. In link_z (Ch 7) this is exactly why the SUPPRESS dual rows end up as indicator
constraints rather than big-M: the per-constraint bounding LP that would supply a finite M
returns ±∞, and the code’s self.M = inf default routes an unbounded row to a native indicator.
This is emergent from the cone geometry, not a hard-coded “SUPPRESS ⇒ indicator” switch.
6.3.4 The b^T y ≠ 0 caveat
The docstring (strainDesignProblem.py) flags an unimplemented special case. When the
undesired region is described purely by equalities \(A x = b\) with all variables free
(\(x \in \mathbb{R}^n\)) and \(b \neq 0\), the correct Farkas alternative requires \(b^\top y \neq 0\) (not \(b^\top y < 0\)):
an all-equality, all-free system \(Ax=b\) is infeasible iff there is a y in the left null space of
A (\(A^\top y = 0\)) with \(b^\top y \neq 0\), and both signs of \(b^\top y\) are valid certificates because the
equality has no orientation. Forcing \(b^\top y \le -1\) only captures the \(b^\top y < 0\) half.
The code deliberately keeps the ≤ −1 form and notes the omission is benign in practice:
The case is rare. Metabolic primals mix
Sv=0(equalities) with bound and module inequalities, so pure all-equality/all-free undesired regions essentially do not arise; andSv=0itself is homogeneous (b_eq = 0), contributing nothing tob^T y.Where it did matter, splitting each equality \(A_i x = b_i\) into \(A_i x \le b_i\) and \(-A_i x \le -b_i\) (two \(\ge 0\) dual variables) would recover a \(b^\top y < 0\) certificate. The docstring judges the split unnecessary and keeps the single-sided normalization.
For the reader modifying this path: if you ever construct a SUPPRESS module whose region is equality-only with a nonzero RHS and free variables, the \(\le -1\) normalization can miss certificates of the opposite sign — the split is the fix.
6.4 Strong-duality encoding of bilevel problems
6.4.1 The coupling row
An inner optimizer max c_inner^T v over the network is encoded by pairing the inner primal
with its dual and forcing their objectives equal. By strong duality (§6.2.2), for
primal-feasible v and dual-feasible y,
The \(\le\) direction is weak duality (always true); the \(=\) case is attained only at the common
optimum. So adding the single equality row \(c_{\text{inner}}^\top v - b^\top y = 0\) on top of primal feasibility
(v in the network) and dual feasibility (y in the LP_dualize output) is exactly the
statement “v maximizes the inner objective.”
In the code this equality appears as a sum, not a difference, because the maximize-sense inner
objective is stored negated. Concretely, in the inner-objective branch
(strainDesignProblem.py):
c_inis the inner objective, negated when the sense isMAXIMIZE(the default): \(c_{\text{in}} = -c_{\text{inner}}\) (strainDesignProblem.py).build_primal_from_cbmbuilds the region primal_vwith objectivec_v = c_in(strainDesignProblem.py).LP_dualizedualizes the unconstrained inner primal and returnsc_inner_dual = b(the primal RHS) as the dual objective (strainDesignProblem.py).The exact-optimality coupling (
strainDesignProblem.py) block-diagonalizes the region primal with the dual and appendsA_eq row: [ c_v | c_inner_dual ] · [v ; y] = 0
i.e. \(c_v^\top v + c_{\mathrm{inner\_dual}}^\top y = 0\). With \(c_v = -c_{\text{inner}}\) this reads \(c_{\text{inner}}^\top v = c_{\mathrm{inner\_dual}}^\top y = b^\top y\) — the strong-duality equality. The negation is bookkeeping that turns “objectives equal” into a clean sum-to-zero row.
Verification. Reproducing this exact assembly on e_coli_core with the biomass inner objective,
then optimizing biomass in both directions over the joint system, yields \(\max = \min = 0.873922\) —
biomass is forced onto the inner-optimal face (the FBA value), confirming the sign convention and
the whole construction end to end. (Contrast: solving the dual alone gives 0; §6.2.4.)
6.4.2 Exact vs relaxed inner optimality
The inner problem need not be solved to the optimum — only near it. The module carries
INNER_OPT_TOL \(\in (0, 1]\) (default 1.0, exact), handled at strainDesignProblem.py:
Exact (
inner_opt_tol = 1.0,strainDesignProblem.py). The single equality row of §6.4.1:c_v^T v + c_inner_dual^T y = 0. The optimizing flux must land on the inner-optimal face.Relaxed (
inner_opt_tol < 1.0,strainDesignProblem.py). Being within a fractiontolof the optimum is not, by itself, a linear condition — you still need to know the optimum. The code introduces a second, reference copy of the inner primal (variablesx_ref) whose only job is to attain the true optimum and anchor the dual. Two rows are added:equality (anchor): c_inner^T x_ref + c_inner_dual^T d = 0 (dual at optimum) inequality (relax): c_v^T v + tol · c_inner_dual^T d ≤ 0 (actual ≥ tol·optimal)
The anchor equality pins the dual
dto the true inner optimum via the reference primalx_ref; the relaxed inequality then requires the actual fluxv’s inner-objective value to be at leasttoltimes that optimum. The three blocks — actual primal_v, reference primal_inner, dual_dual— are block-diagonalized atstrainDesignProblem.py, and theirz-maps concatenated (the anchor/relax rows get zero knockable columns). This reference-primal pattern recurs verbatim in the relaxed outer objective (strainDesignProblem.py) and in DoubleOpt (§6.5.5).
6.4.3 Optional outer objective on PROTECT/SUPPRESS
A PROTECT or SUPPRESS module may itself carry an outer objective to be optimized over the
inner-optimal set (strainDesignProblem.py). The already-assembled bilevel _p (region
primal ⊕ inner dual) is dualized again by LP_dualize with the outer objective c_out
(strainDesignProblem.py), and coupled by the same strong-duality equality
(strainDesignProblem.py exact, :604-631 relaxed with a reference copy of the whole _p).
Nesting LP_dualize on an already-dual system is possible precisely because it returns its output in
the same standard container it consumes (§6.2.3) — the transform is closed under composition.
6.5 One dualization, reused across every module type
The reason a single LP_dualize (plus its zero-objective specialization farkas_dualize) suffices
for all of MCS, OptKnock, RobustKnock, OptCouple, and DoubleOpt is a structural one:
Every strain-design assertion reduces to one of two primitives — “this LP attains its optimum” (primal + dual + strong-duality equality) or “this region is infeasible” (Farkas dual + normalization) — and both primitives are instances of dualizing a standard-form system.
Because LP_dualize (a) consumes and produces the same container, (b) transposes the z-maps so
knockouts survive the transform, and (c) can be applied to its own output, the module builders in
addModule are just different stackings of the two primitives. The following subsections walk each.
6.5.1 Inner-objective PROTECT / SUPPRESS (strainDesignProblem.py)
One strong-duality link. Build region primal _v (with the module’s desired/undesired constraints),
dualize the unconstrained inner primal, couple with c_v^T v + c_inner_dual^T y = 0 (§6.4.1). Then
dispatch by type (strainDesignProblem.py): PROTECT treats the coupled system as a raw
feasibility block (reassign_lb_ub_from_ineq, no MILP objective, strainDesignProblem.py);
SUPPRESS wraps the coupled system in farkas_dualize to demand its infeasibility
(strainDesignProblem.py). The same bilevel _p thus serves both “keep the inner-optimal
production reachable” and “make inner-optimal-with-target-production impossible,” differing only in
which final primitive (feasibility vs Farkas) is applied.
6.5.2 OptKnock — bilevel max-min (strainDesignProblem.py)
OptKnock maximizes an outer objective c_out over the inner-optimal flux set:
\(\max_z \max_{v \in \arg\max c_{\text{inner}}^\top v} c_{\text{out}}^\top v\). Construction:
Region/inner primals and the inner dual are built as in §6.5.1, coupled by strong duality (
strainDesignProblem.py), giving a system whose feasible set is exactly the inner-optimal face.The whole coupled inner is then dualized once more with the outer objective
c_out_in_p(strainDesignProblem.py), and the outer problem_ris joined to that second dual by a further strong-duality equality (strainDesignProblem.py). Bounds are reassigned (strainDesignProblem.py) and the outer objective set (strainDesignProblem.py, and the final MILP objective at:675-685).
The max-min is thus two LP_dualize calls: one to characterize the inner-optimal face, one to turn
the maximization over that face into flat rows.
6.5.3 RobustKnock — three levels, two nested dualizations (strainDesignProblem.py)
RobustKnock is the pessimistic OptKnock: \(\max_z \min_{v \in \arg\max c_{\text{inner}}^\top v} c_{\text{out}}^\top v\) — it
guards against the worst production the cell might choose among its growth-optimal fluxes. The extra
min over the inner-optimal set is the third level. The code (same branch as OptKnock, distinguished
by MODULE_TYPE == ROBUSTKNOCK) dualizes the inner primal (strainDesignProblem.py), builds
the combined inner (region ⊕ inner-dual coupled, strainDesignProblem.py), then dualizes
that combined system with the negated outer objective (strainDesignProblem.py, the joint
min-max), and finally connects the outer primal _r to the dualized combined inner
(strainDesignProblem.py). Two nested LP_dualize calls convert the three-level
max-min-max into a single flat system; the inner min is expressed by dualizing it into a max
that can share the outer maximization’s sense.
6.5.4 OptCouple — growth-coupling distance (strainDesignProblem.py)
OptCouple maximizes the gap between the inner (growth) optimum with target production and the
inner optimum without it — a design where product synthesis is forced by growth. It builds two
bilevel systems: the production one (_p, inherited from the OptKnock-style block above) and a
no-production one (_b), the latter constructed by adding the production reaction fixed to zero
(V_eq = prod_eq, v_eq = [0], strainDesignProblem.py), building its primal, and dualizing
it (strainDesignProblem.py) with its own strong-duality coupling
(strainDesignProblem.py). The two bilevel systems are block-diagonally joined
(strainDesignProblem.py), an optional minimum growth-coupling potential is enforced as an
inequality on the difference of the two inner objectives (strainDesignProblem.py), and the
MILP objective is set to maximize that difference \(c_p - c_b\) (strainDesignProblem.py).
Same primitive, instantiated twice and subtracted.
6.5.5 DoubleOpt — two parallel strong-duality links (strainDesignProblem.py)
DoubleOpt enforces two optimality conditions on the same primal flux simultaneously (e.g. an inner
and an outer objective both attained). It builds the region primal _v
(strainDesignProblem.py) and dualizes two independent unconstrained inner primals — one per
objective c_in and c_in2 (strainDesignProblem.py). Two strong-duality links are then
added over a shared column layout [x_v | (x_ref1) | dual1 | (x_ref2) | dual2]
(strainDesignProblem.py), each either exact (one equality row) or relaxed (anchor equality
relaxed inequality with a reference primal, §6.4.2), independently governed by
INNER_OPT_TOLandOUTER_OPT_TOL. DoubleOpt dispatches PROTECT-style as a feasibility block (strainDesignProblem.py) with no MILP objective, and — notestrainDesignProblem.py— is grouped with PROTECT/SUPPRESS as an “MCS-type” module for objective selection (minimize intervention cost). It is literally §6.5.1’s link, laid down twice on one primal.
6.5.6 The unifying picture
module |
primitive(s) |
# |
final assertion |
|---|---|---|---|
PROTECT (plain) |
region primal |
0 |
region feasible |
SUPPRESS (plain, MCS) |
Farkas dual |
1 (via |
region infeasible |
PROTECT / SUPPRESS (inner obj) |
1 strong-duality link |
1 (+1 if outer obj) |
inner-optimal flux feasible / infeasible |
OptKnock |
inner-optimal face + outer max |
2 |
max over inner-optimal set |
RobustKnock |
inner-optimal face + inner min + outer |
2 (nested) |
worst-case max-min-max |
OptCouple |
two bilevels, subtracted |
2 (one per bilevel) |
max growth-coupling distance |
DoubleOpt |
two strong-duality links |
2 |
two objectives jointly optimal |
Every row is a stacking of “assert an LP’s optimum via primal + dual + strong-duality equality” or
“assert infeasibility via Farkas + normalization.” Both are the single transpose-with-bookkeeping of
LP_dualize. That is what makes the dualization machinery reusable: the metabolic content changes,
the linear-algebra primitive does not.
6.6 Boundary with Chapter 7
Everything above produces continuous rows only: dual variables y = (λ, μ), dual-feasibility
constraints, strong-duality equality rows, Farkas normalization rows, and the primal blocks they are
paired with — together with the z_map_vars, z_map_constr_ineq, z_map_constr_eq matrices that
record which reaction’s knockout removes which row or variable after all the transposition. What is
not done here is attaching the binary intervention variables z to those rows. That is
link_z (strainDesignProblem.py), Ch 7: it reads the z_map_* matrices, splits knockable
equalities into directional inequalities, tries to bound each row with an LP to obtain a valid
big-M, and — where the bounding LP returns ±∞, as it always does for the scale-free Farkas dual
rows (§6.3.3) — falls back to native indicator constraints. The emergent split noted throughout this
chapter (SUPPRESS’s unbounded Farkas rows → indicators; PROTECT’s finite-flux primal rows → big-M)
is a consequence of the bound structure this chapter’s dualization produces, decided in Ch 7’s
self.M/bounding-LP fork, not a per-type switch. Read this chapter for what the rows mean; read
Ch 7 for how z turns them on and off.
7. MILP construction & the z-linking
By the time this chapter’s code runs, every strain-design module has been turned into a
self-contained linear (in)equality block — a Farkas infeasibility certificate for SUPPRESS, a raw
primal feasibility system for PROTECT, or a strong-duality sandwich for the bilevel types (Ch 6
owns that content). What remains is assembly: stacking those blocks into one matrix, attaching the
seed rows that account for intervention cost, and — the substance of this chapter — wiring the binary
intervention variables z to the continuous rows so that flipping z_j genuinely removes reaction
j from the flux system. That wiring is done two ways, native indicator constraints or big-M
linearization, and the choice between them is made per-constraint by a bound-computing LP. Getting it
right is what separates a correct, numerically well-behaved MILP from one that either admits phantom
solutions (M too small) or grinds through a useless LP relaxation (M too large).
All line references are to strainDesignProblem.py unless noted; the indicator container lives in
indicatorConstraints.py.
7.1 Notation and the shape of the master problem
The MILP variable vector is partitioned as
x = [ z ; y ] z ∈ {0,1}^{num_z}, y ∈ ℝ^{n_cont}
with the num_z binaries occupying the leading columns (self.idx_z = [0..numr-1],
SDProblem.__init__:164) and all continuous module variables y appended afterward. The final
self.vtype = 'B'*num_z + 'C'*(z_map_vars.shape[1]-num_z) simply records that split.
z_j = 1 means “intervention j is applied”. For a knockout that is removal of reaction j; for
a knock-in the meaning is inverted (z_inverted[j] = True, set from ki_cost), and
the sign machinery of §7.6 flips the coupling so that z_j = 1 still reads as “the intervention is
made”. One binary per compressed reaction: self.num_z = numr (numr = len(model.reactions)), because at this point the model has already been through both compression
passes and GPR extension (Ch 3, Ch 4), so a “reaction” may be a lumped subnet or a gene
pseudoreaction. There is deliberately no separate binary per constraint or per variable — a single
z_j fans out to all rows and variables that reaction j controls, tracked by the three maps
introduced below.
Throughout, the master inequality system is A_ineq · x ≤ b_ineq, the equality system A_eq · x = b_eq, with variable box lb ≤ x ≤ ub.
The three z-maps
Coupling bookkeeping is carried in three sparse matrices, each with num_z rows (one per binary) and
one column per constraint/variable of the system being tracked:
map |
shape |
entry |
|---|---|---|
|
|
|
|
|
|
|
|
|
The stored value encodes both which binary and the coupling polarity: +1 = knockout (this row
disappears when z_j = 1), −1 = knock-in / addition (the row is present only when z_j = 1),
0 = no coupling. These are the maps link_z reads to decide, for every row, which z column to
write into and with which sense. They are the single source of truth linking the combinatorial layer
(z) to the continuous layer (fluxes, dual variables).
7.2 SDProblem.__init__ — the seed rows, num_z, and the M switch
Before any module is added, __init__ lays down a 3-row skeleton over the z columns only.
The three fixed seed rows
self.A_ineq = sparse.csr_matrix([[-i for i in self.cost], # row 0: idx_row_maxcost
self.cost, # row 1: idx_row_mincost
[0 for _ in range(num_z)]]) # row 2: idx_row_obj
self.b_ineq = [0.0, max_cost_or_sum, np.inf]
with self.cost the per-reaction intervention weight (KO cost, overwritten by KI cost where a KI is
defined;, nan→0). The three rows and their right-hand sides:
Row 0,
idx_row_maxcost: \(-\sum_j \text{cost}_j \cdot z_j \le 0\), i.e. \(\sum_j \text{cost}_j z_j \ge 0\). With non-negative costs this is slack at construction, but it is a live lower bracket on total intervention cost: the enumeration/optimization layer (Ch 8) raises its RHS to force the solver past cost levels already exhausted, turning it into \(\sum \text{cost}_j z_j \ge \kappa\). Keeping it as a permanent row means that lower bound can be tightened in place without restructuring the matrix.Row 1,
idx_row_mincost: \(\sum_j \text{cost}_j z_j \le b\), the budget cap. Its RHS isself.max_costwhen the user supplied one, else \(\sum_j |\text{cost}_j|\) — the latter is a vacuous cap (no design can cost more than the sum of all weights), present so the row always exists and can be tightened later. This is the constraint that makes “minimal” cut sets minimal-enough: no design exceeding the budget is admitted.Row 2,
idx_row_obj: an all-zero placeholder with RHS \(+\infty\). For a pure MCS problem the objective is minimize intervention cost and lives in the objective vectorself.c(lines 202–205:c[j] = cost[j]), so this row stays inert. For bilevel problems (OptKnock, OptCouple, …) the outer objective is a flux expression, not a cost sum; the row is then overwritten with the objective coefficients and used byfixObjective(strainDesignMILP.py:239–241) to pin \(c \cdot x \le \text{value}\) during the BEST search. Reserving row 2 up front lets that pin be a singleset_ineq_constraintcall rather than a matrix resize.
The naming (maxcost on the ≥ 0 row, mincost on the ≤ budget row) reads backwards against the
RHS values and is best treated as an internal label; the mathematics is: row 0 lower-brackets and
row 1 upper-brackets the weighted intervention sum, and row 2 is the swappable objective slot.
The companion z_map_constr_ineq is initialised to (numr × 3) zeros: the seed rows
are not knockable — they constrain z, they are not part of any flux subsystem, so no z ever
“removes” them.
self.M — the master indicator/big-M switch
bound_thres = max(|cobra_conf.lower_bound|, |cobra_conf.upper_bound|)
if self.M is None and solver == 'glpk': self.M = bound_thres # GLPK: no indicators
elif self.M is None: self.M = np.inf # default
# else: user-supplied M kept as-is
self.M is the fallback big-M used only when the per-constraint bounding LP (§7.5) cannot produce a
finite bound. Its three regimes:
inf(default). Rows with no finite bound get no big-M row; they fall through to native indicator constraints (§7.7). This is the preferred, numerically clean path.cobra bound (GLPK). GLPK has no indicator-constraint API, so
self.Mis forced finite (the cobra default bound, typically 1000) and every unbounded row becomes a big-M row with that constant. A warning is logged. This is the escape hatch that lets the open-source solver run at all, at the cost of a loose, uniform M.user override. Passing
M=<value>in kwargs pins the fallback explicitly (for a solver that supports indicators, this forces big-M everywhere a bound is missing).
So self.M decides what happens to the rows the bounding LP cannot bound; the bounding LP decides
everything else. The emergent SUPPRESS→indicator / PROTECT→big-M split (§7.8) is a downstream
consequence of this, not a separate branch.
7.3 addModule — block-diagonal assembly
Each module produces its own block (A_ineq_i, b_ineq_i, A_eq_i, b_eq_i, lb_i, ub_i, c_i) plus its
own three z-maps z_map_*_i (the Ch 6 dual/primal machinery; here we only care about how the block
joins the master). The join is:
self.z_map_constr_ineq = hstack((self.z_map_constr_ineq, z_map_constr_ineq_i)) # 688
self.z_map_constr_eq = hstack((self.z_map_constr_eq, z_map_constr_eq_i)) # 689
self.z_map_vars = hstack((self.z_map_vars, z_map_vars_i)) # 690
self.A_ineq = sparse.bmat([[self.A_ineq, None],
[None, A_ineq_i]]).tocsr() # 691
self.b_ineq += b_ineq_i
self.A_eq = sparse.bmat([[self.A_eq, None], [None, A_eq_i]]).tocsr() # 693
self.b_eq += b_eq_i
self.c += c_i; self.lb += lb_i; self.ub += ub_i
The constraint matrices grow block-diagonally: the new module’s rows occupy new rows and new
columns, with explicit None (zero) off-diagonal blocks. The z-maps, in contrast, grow only in
columns (hstack) — they keep their num_z rows.
Why block-diagonal for the continuous part
Each module owns a private set of continuous variables. A SUPPRESS module’s block is a Farkas dual
living in dual space (one dual variable per primal constraint of that module’s flux system); a
PROTECT module’s block is a primal flux vector v; a bilevel module carries primal flux and dual
variables. These variable sets are semantically disjoint — the flux that must stay feasible in a
PROTECT module has nothing to do with the dual ray that certifies infeasibility in a SUPPRESS module,
and two SUPPRESS modules certify infeasibility of two different behaviors, each needing its own ray.
Sharing continuous columns between them would impose spurious equalities (module A’s flux = module B’s
flux) that are simply wrong. Block-diagonal placement gives each module an independent copy of flux
space; the modules never see each other’s continuous variables.
7.4 prevent_boundary_knockouts — why nonzero-sign bounds must be moved
This runs inside build_primal_from_cbm, before dualization, on every primal flux
system. It repairs a specific incompatibility between the KO encoding and reactions whose flux is
forced away from zero.
The KO encoding and the failure
A knockout of reaction j is ultimately realized (link_z, §7.5–7.6) by driving its flux v_j to 0.
The mechanism tightens the reaction’s box toward 0: for a variable with ub_j > 0 it adds the row
v_j ≤ 0 gated by z; for lb_j < 0 it adds −v_j ≤ 0. This is valid iff 0 ∈ [lb_j, ub_j] —
the KO row merely collapses the box onto a value the box already contains.
Now suppose the reaction has a nonzero-sign bound: lb_j > 0 (obligatorily forward) or ub_j < 0
(obligatorily reverse). Then 0 ∉ [lb_j, ub_j]. The variable’s own box bound — which is a property
of the variable, not a constraint row, and is therefore never multiplied by z — keeps forcing
v_j ≥ lb_j > 0 even when the KO row v_j ≤ 0 is active. The two are contradictory: the “knockout”
does not remove the reaction, it renders the subsystem infeasible. Equivalently, in the
bound-multiplication view the docstring uses (multiply the bound by z to simulate the KO):
multiplying a bound that lies strictly on one side of 0 can never reach 0, so the residual bound
still forces flux.
The transformation
For each knockable column (col_has_z, from z_map_vars):
if lb_j > 0: add row -v_j ≤ -lb_j (i.e. v_j ≥ lb_j), then set lb_j := 0
if ub_j < 0: add row +v_j ≤ ub_j (i.e. v_j ≤ ub_j), then set ub_j := 0
The obligation is moved out of the variable box and into an explicit inequality row, and the box is
reset so that 0 ∈ [lb_j, ub_j]. Concretely, lb_j > 0 becomes box [0, ub_j] plus a standalone row
v_j ≥ lb_j. The new rows are appended with zero z-columns (: hstack([z_map_constr_ineq, zeros(numz, new_z_cols)])) — they are non-knockable. That is the crucial point: the obligation is
now a fixed property of the flux system that survives into the dual as an ordinary constraint with an
unconditioned multiplier, rather than a variable bound that the z-machinery would try (and fail) to
multiply. The KO machinery can now cleanly collapse the (0-containing) box, and the moved row, carrying
no z, cannot be corrupted by the coupling.
(It moves the nonzero-sign bounds — lb > 0 and ub < 0, the ones that exclude 0, since those are what break the encoding.)
In practice this fires rarely, because FVA preprocessing (Ch 5) has already relaxed non-binding bounds
to ±∞ and pinned irreversible/blocked reactions to 0; the survivors are the genuinely
obligatory-flux reactions, and this function is what keeps them knockable.
7.5 link_z — the heart of the chapter
link_z transforms the assembled but unlinked system — where z-columns are still zero in every
module row — into a fully coupled MILP. Six steps.
Step 1: knockable equalities → ± inequality pairs
You cannot “relax an equality with a big-M” in one row: a·x = b gated off needs both a·x ≤ b and
a·x ≥ b to disappear. So each knockable equality (a nonzero column of z_map_constr_eq) is split:
a·x = b → a·x ≤ b and −a·x ≤ −b
Both new inequalities are gated by the same z (z_eq = z_map_constr_eq[:, tuple(idx)*2],
— the column is duplicated). The originals are deleted from A_eq. When the
gate is inactive, the pair re-imposes the equality exactly; when active, both directions relax. (If
this equality later lands on the indicator path with both directions unbounded, §7.7’s lumping step
fuses the pair back into a single 'E' indicator — the split is undone once it is no longer needed.)
Step 2: variable-KOs → inequality rows
A knockable variable (nonzero column of z_map_vars) is translated into an inequality that pins its
flux to 0 on the relevant side:
if ub_j > 0: row +1·v_j ≤ 0 (knock the positive side toward 0)
if lb_j < 0: row −1·v_j ≤ 0 (knock the negative side toward 0)
A reversible reaction (lb_j<0<ub_j) gets both rows. The gating column is z_lb_ub = −z_map_vars[:, cols] — note the negation. A plain KO reaction has z_map_vars entry
+1; negated to −1, which by the polarity convention (§7.6) means “enforce the row when z=1” —
exactly right: applying the KO (z=1) must enforce v_j ≤ 0. So the negation is what makes a
knockout gate correctly.
Step 3: the per-constraint bounding LP
For every knockable inequality row a·x ≤ b, we need a big-M large enough that when the gate relaxes
it, the row becomes non-binding. The valid/tight-M theory:
A big-M is valid if \(M \ge \max\{\, a \cdot x : x \in P_{\text{relaxed}} \,\}\) and tight if it equals that max, where
P_relaxed is the most relaxed polytope any knockout combination can produce. The code builds
P_relaxed by dropping all knockable inequality rows and keeping only the non-knockable
inequalities, all equalities, and the (continuous-variable) box:
cont_vars = columns that are not z # 765
M_A_ineq / M_b_ineq = A_ineq/b_ineq with knockable rows removed # 766-767 (the constraints of P)
M_A_eq / M_b_eq = all equalities # 768-769
M_lb / M_ub = box over continuous vars # 770-771
M_A_sparse / M_b = the knockable rows themselves (the objectives) # 774-775
Dropping every knockable row is what makes P_relaxed a superset of every actually-reachable knocked
polytope (any real design drops only some rows), so max a·x over P_relaxed upper-bounds a·x
over any knocked subsystem — hence a valid M — and taking the exact max makes it tight.
Because solving one LP per knockable row is expensive, rows are triaged by sparsity:
nnz == 0(empty row):max = 0. (n_zero)nnz == 1(single variablecoeff·v_c):max = coeff·ub_cifcoeff>0elsecoeff·lb_c, read straight off the box;∞if that bound is infinite. (n_single)nnz ≥ 2: needs an actual LP, \(\max a \cdot x = -\min(-a \cdot x)\) overP_relaxed. (n_lp)
logged as Bounding MILP: N constraints (X zero, Y single-var, Z need LP). Only the n_lp
rows hit the solver, optionally across a worker pool (worker_compute maximises a·x by minimising
−a·x and negating). Finite results are rounded up to 5 digits (ceil(M·1e5)/1e5,
) to stay safely on the valid side; infinite results are replaced by self.M —
the point where §7.2’s switch takes effect.
Step 4: the fork at the M value
For each knockable inequality row, Ms[row] is now either a finite number or self.M (which may be
inf). The loop:
for row in ...:
if not isinf(Ms[row]) and not isnan(Ms[row]): # finite M → big-M row
z_i = z_map_constr_ineq[:, row].nonzero()[0][0]
sense = z_map_constr_ineq[z_i, row]
if sense > 0: # z_i = 1 knocks out (KO)
A_ineq[row, z_i] = -Ms[row] + b_ineq[row]
else: # z_i = 0 knocks out (KI convention)
A_ineq[row, z_i] = Ms[row] - b_ineq[row]
b_ineq[row] = Ms[row]
Rows with isinf(Ms[row]) are skipped here and picked up by the indicator path in step 5. The two
sense cases, written out (let a·x ≤ b be the row, M = Ms[row]):
sense > 0(KO, active whenz=1) — coefficientb − Min the z-column gives the row \(a \cdot x + (b - M) \cdot z \le b\):z = 0: \(a \cdot x \le b\) — enforced.z = 1: \(a \cdot x \le M\) — relaxed to the tight maximum, hence non-binding (since \(M = \max a \cdot x\)).
This is exactly tight: at the knocked state the bound equals the reachable maximum, not the looser
b + Ma naive formulation would use.sense < 0(KI, active whenz=1, absent whenz=0) — coefficientM − b, andbreset toM, giving \(a \cdot x + (M - b) \cdot z \le M\):z = 1: \(a \cdot x \le b\) — enforced (reaction present).z = 0: \(a \cdot x \le M\) — relaxed, non-binding (reaction absent).
Both cases realize the same logic — “constraint holds in the active state, evaporates in the knocked
state” — with the polarity dictated by the z_map sign. The finite-M rows are now permanently part of
A_ineq; only their z-column entries changed.
Steps 5–6: indicators and cleanup
Every row still carrying isinf(Ms[row]) (knockable_constr_ineq_ic) becomes a native
indicator constraint. First, a lumping pass undoes the step-1 split where it is
no longer useful: rows are canonicalised by the sign of their first nonzero entry, grouped
by an exact (indices, data) key, and pairs found to be identical up to a global sign
flip (ident_rows product −1) — i.e. an a·x ≤ b and an a·x ≥ b on the same z — are fused into
a single equality indicator; exact duplicates (product +1) drop one copy. The
survivors are packaged into an IndicatorConstraints object and removed from the
static A_ineq/A_eq, because an indicator row is enforced by the solver’s logic
engine, not by the LP matrix.
7.6 Indicator constraints (indicatorConstraints.py)
IndicatorConstraints(binv, A, b, sense, indicval) is a thin container (constructor) for
rows of the form
z_{binv[k]} = indicval[k] ⇒ A[k]·x <sense[k]> b[k]
with sense ∈ {'L','E','G'} (≤, =, ≥). The container is populated in link_z:
binv— thezindex gating each row, read from the nonzero of the row’sz_mapcolumn.A, b— the surviving knockable inequality rows first ('L'), then the lumped equality rows ('E'):sense = 'L'*n_ineq + 'E'*n_eq.indicval— which value of the binary triggers enforcement, derived from thez_mappolarity :[0 if d == 1 else 1 for d in data]. So az_mapentry of+1(KO) →indicval = 0(the constraint is enforced while the reaction is present,z=0, and released on knockout), and−1(KI/addition) →indicval = 1(enforced only when the reaction is added,z=1). The code comment states this mapping directly. This is the exact combinatorial analogue of the big-M sense cases in §7.5 step 4.
Semantically, \(z = \text{indicval} \Rightarrow A \cdot x \;\{\le,=\}\; b\) and, when \(z \ne \text{indicval}\), the constraint is simply not present — there is no slack variable, no large constant, nothing in the LP relaxation. The solver enforces the implication by branching/logic.
7.7 Why indicators give a tighter LP relaxation than big-M
Take the KO row from §7.5, \(a \cdot x + (b - M) \cdot z \le b\), and relax the binary to \(z \in [0,1]\) (what every LP node in branch-and-bound actually sees). Rearranged:
a·x ≤ b + (M − b)·z
At a fractional z the right-hand side floats up proportionally to z: the relaxation lets a·x
exceed its true bound b by up to (M−b)·z. The feasible region of the relaxation is therefore
enlarged, and the enlargement grows linearly with M. A loose (large) M produces a weak
relaxation: the LP bound at each node is poor, branch-and-bound explores more nodes, and the wide
spread between M and the unit-scale flux coefficients degrades numerical conditioning (FeasibilityTol
/ IntFeasTol interactions, ill-scaled bases). This is the concrete cost of a bad M.
The indicator constraint has no continuous relaxation of the implication: at fractional z the
solver does not manufacture a proportional slack; it enforces z=indicval ⇒ a·x ≤ b combinatorially.
The relaxation it presents is at least as tight as the big-M one and usually strictly tighter, with no
M to condition on. That is why indicators are the default whenever the solver supports them, and why
the per-constraint tight M matters when it does not: the bounding LP of §7.5 exists precisely to
make each finite M as small as validly possible. This is also the payoff of Ch 5’s FVA bound
relaxation — by pushing non-binding bounds to ±∞, FVA makes the corresponding max a·x infinite,
which routes those rows to indicators (the tightest option, no M at all) instead of leaving them with a
finite-but-large M. Tight preprocessing and tight linearization are the same fight.
7.8 The emergent SUPPRESS→indicator / PROTECT→big-M split
A frequently observed pattern under the default M = inf: SUPPRESS modules end up almost entirely on
indicator constraints, PROTECT modules almost entirely on big-M. This is emergent from bound
structure, not a per-type branch anywhere in the code.
A SUPPRESS module is a Farkas dual (
farkas_dualize, Ch 6). Its variables are the components of an unbounded dual ray; the dual feasible set is a homogeneous cone, so the dual variables are unbounded above. The knockable rows are constraints on these unbounded dual variables, so their bounding LP returnsmax a·x = +∞→Ms = self.M = inf→ indicator.A PROTECT module is a raw primal flux system (
reassign_lb_ub_from_ineq, Ch 6). Its variables are fluxes with finite FVA bounds; the knockable rows are ordinary flux constraints, so their bounding LP returns a finitemax a·x→ big-M with that tight constant.
So the fork is decided entirely by whether max a·x over the relaxed polytope is finite — a property
of the bounds, funneled through the single self.M/bounding-LP mechanism in link_z. Change the
bound structure (e.g. cap the dual variables, or lose FVA relaxation on the primal) and the split
moves. On GLPK it collapses entirely: self.M is finite, so even the unbounded SUPPRESS rows get a
big-M, and there are no indicators at all. This is the mechanistic content behind the memory note that
SUPPRESS means “cannot” (make a behavior infeasible — certified by an unbounded dual ray, hence
indicators) and PROTECT means “can” (keep a behavior feasible — a bounded primal flux, hence big-M).
7.9 Final consolidation and the binary block
After link_z, the master problem is:
A_ineq— seed rows 0–2, then the block-diagonal module rows, plus the eq→ineq rows (step 1) and var-KO rows (step 2), with finite-Mz-column coefficients written in place; indicator rows have been removed (they live inself.indic_constr).A_eq— the non-knockable equalities (stoichiometryS·v = 0, fixed module equalities) plus any lumped equalities that stayed on the big-M path; indicator equalities removed.self.indic_constr— theIndicatorConstraintsbundle.self.c— for a pure MCS problem,c[j] = cost[j]on thezblock, 0 elsewhere (minimize intervention cost,is_mcs_computation = True); for bilevel,conzis 0 and the outer objective sits in seed row 2.self.c_bubacks it up.self.vtype = 'B'*num_z + 'C'*(z_map_vars.shape[1]-num_z): the binary block is the leadingnum_zcolumns — the design variablesz, which every module’s coupling was wired into — and everything after is the continuous module variables (fluxes, dual rays) that hang off them block-diagonally.
The ContMILP snapshot stores the continuous projection (all columns except idx_z)
together with the three z-maps, so that a candidate design z* can be validated by substitution
without re-solving the full MILP (used by verify_sd, Ch 8). At this point the problem is a complete,
solver-ready MILP: binaries coupled to continuous rows through tight per-constraint big-Ms where
bounds are finite and native indicators where they are not.
8. Solving & enumeration
By the time this chapter’s code runs, the strain-design problem is a fully assembled MILP: binary
intervention variables z ∈ {0,1}^{n_z}, continuous variables (fluxes, dual/Farkas variables, big-M
slacks), a stack of static inequality/equality rows, a set of indicator constraints or big-M rows
linking z to the continuous block (Ch 7), and an objective. What remains is the search: driving
the solver to produce not one design but a stream of minimal, distinct, valid designs, and doing so
with a strategy appropriate to the question being asked (“give me any design”, “give me the cheapest
design”, “give me all cheapest designs”). That orchestration lives almost entirely in
strainDesignMILP.py, in the three public entry points compute (ANY), compute_optimal (BEST) and
enumerate (POPULATE), plus the shared machinery solveZ/solve, fixObjective/resetObjective/
setMinIntvCostObjective, add_exclusion_constraints(_ineq) and verify_sd.
This chapter assumes the MILP already exists. How z attaches to the continuous rows (indicator vs
big-M, the bound-driven fork) is Ch 7; the dual/Farkas content of those rows is Ch 6. Here we take the
constraint matrix as given and study what happens at solve time.
8.1 The objective is both a vector and a constraint row
Everything in this chapter hinges on one structural decision made at MILP-construction time
(strainDesignProblem.py): the top three rows of A_ineq are reserved, and row 2 is a copy
of the objective.
self.idx_row_maxcost = 0 # -cost·z ≤ 0 (unused lower guard)
self.idx_row_mincost = 1 # cost·z ≤ max_cost (the max_cost budget)
self.idx_row_obj = 2 # c·x ≤ b_ineq[2] (the objective, as a constraint)
self.A_ineq = sparse.csr_matrix([[-i for i in self.cost], self.cost,
[0 for _ in range(self.num_z)]])
self.b_ineq = [0.0, max_cost, np.inf] # row 2 rhs starts at +inf (inert)
So the objective exists in two representations simultaneously:
As the solver’s objective vector
self.c/self.c_bu(the backup copy,strainDesignProblem.py). This is what the branch-and-bound engine minimizes.As inequality row
idx_row_obj = 2ofA_ineq, of the form \(c \cdot x \le \beta\). Initially \(\beta = +\infty\), so the row is inert (it constrains nothing).
fixObjective (strainDesignMILP.py) is nothing but a rewrite of that row:
def fixObjective(self, c, cx):
self.set_ineq_constraint(self.idx_row_obj, c, cx) # row 2 := (c·x ≤ cx)
resetObjective (:243-245) restores the vector to c_bu; setMinIntvCostObjective (:247-250)
clears the vector and installs the intervention-cost objective \(\sum cost_i z_i\) over targetable z;
clear_objective (solver_interface.py) zeroes the vector.
Why carry the objective as a row at all? Because the algorithms below need to decouple two uses of the same linear form:
as an optimization direction — “minimize \(c \cdot x\)” — which the solver’s objective vector expresses;
as a feasibility cap — “hold \(c \cdot x\) at the value we just found and now search within that level set” — which only a constraint row can express.
You cannot express “fix the objective at its optimum and then optimize a different objective in the
resulting face” with a single objective vector. You need the optimum value pinned as a constraint while
the vector is repurposed. Row 2 is exactly that pin. Concretely, BEST does: solve with vector = c_bu
to get optimum opt; then fixObjective(c_bu, opt) pins \(c_{bu} \cdot x \le opt\) (row 2) and
setMinIntvCostObjective swaps the vector to \(\sum cost \cdot z\), so the next solve minimizes intervention
count inside the optimal face. Same linear form, two jobs, held apart by the vector/row duality.
A subtle consequence: because row 2 is a genuine ≤ inequality, “fixing” the objective at value v
really imposes \(c \cdot x \le v\), a half-space, not an equality. For a minimization that has already reached
its optimum v, the polytope is empty above v, so \(\le v\) and \(= v\) coincide on the feasible set — the
inequality is enough and avoids the numerical fragility of an equality row.
8.2 solveZ / solve: what one solver call returns
solveZ (strainDesignMILP.py) is the workhorse wrapper:
def solveZ(self):
x, opt, status = self.solve()
z = sparse.csr_matrix([round(x[i], 5) for i in self.idx_z])
return z, x, opt, status
self.solve (inherited from MILP_LP, solver_interface.py) dispatches to the backend,
then rounds integer-typed variables to the nearest integer (int(round(x[i]))) for all
vtype=='B'/'I' positions. solveZ additionally slices out just the binary block idx_z, rounds to 5
decimals, and returns it as a 1×n_z sparse row z, alongside the full primal x, the objective
value opt, and the solver status. The rounding matters: with IntFeasTol/integrality set to 1e-9
(see §8.6) the solver’s z are already all but exact, and rounding removes the last 1e-10-scale dust
so that z.indices — the support — is exact set membership, which the exclusion cuts and
verify_sd rely on.
status is one of the solver-neutral constants OPTIMAL, INFEASIBLE, UNBOUNDED, TIME_LIMIT,
TIME_LIMIT_W_SOL, ERROR (mapped from raw CPLEX/Gurobi codes in the backends). The loops below treat
OPTIMAL and TIME_LIMIT_W_SOL as “a usable solution exists” and everything else as “stop”.
8.3 The three approaches, their objective setups, and why
All three share the same skeleton: an outer while loop that repeatedly asks the solver for a design,
verifies it, records it, and adds an exclusion cut so the next iteration must produce something new. They
differ in how the objective is set before each solve, and that difference is the whole story of
ANY vs BEST vs POPULATE.
8.3.1 ANY — compute (strainDesignMILP.py): feasibility-first, then subspace minimization
The user wants some valid design, not necessarily the smallest. Each outer iteration does two solves.
Solve 1 — zero-objective feasibility (:443-446).
self.resetTargetableZ() # all candidate z free again (ub=1)
self.clear_objective() # objective vector := 0
self.fixObjective(self.c_bu, np.inf) # row 2 := (c_bu·x ≤ +inf) → inert
z, x, _, status = self.solveZ()
The objective vector is all zeros and the objective row is inert. This is a pure feasibility
problem: “find any (z, x) satisfying all constraints”. Why do this first?
It is cheap. With a zero objective there is no optimality gap to close — the branch-and-bound tree terminates the instant it finds one integer-feasible leaf, because every feasible point has the same objective (0) and is therefore optimal. There is no lower-bound/upper-bound race, no need to prove optimality by exhausting the tree. For a genome-scale MCS MILP this is the difference between “descend to the first feasible leaf” and “search the whole tree to prove nothing cheaper exists”. The first feasible
zthe solver stumbles onto is typically far from minimal (it may knock out dozens of reactions), but that is fine — we only wanted a foothold.
Solve 2 — minimize intervention cost within the found subspace (:470-492).
cx = np.sum([c*x for c,x in zip(self.c_bu, x)]) # objective value at the found point
self.setMinIntvCostObjective() # vector := Σ cost_i z_i over targetable z
self.setTargetableZ(z) # forbid every z_i that was 0 in the found design
self.fixObjective(self.c_bu, cx) # row 2 := c_bu·x ≤ cx (stay in this objective level)
while ...:
z1, _, _, status1 = self.solveZ()
...
setTargetableZ(z) (:256-258) sets ub=0 on every candidate z_i that the feasibility solve left
at 0. This restricts the search to the subspace spanned by the reactions the first design already
touched — the support of z and its subsets. Inside that tiny subspace the solver now minimizes
\(\sum cost_i z_i\): it finds the cheapest sub-design that still satisfies all modules.
What subspace minimization achieves. The expensive part of an MCS MILP is choosing which reactions
to cut out of thousands of candidates — a huge combinatorial space. Once the feasibility solve has
handed us a concrete superset \(K = \operatorname{supp}(z)\) of reactions that demonstrably suffice, the minimization
only has to decide which subset of K (at most \(2^{|K|}\) choices, and \(|K|\) is small — a handful to a
few dozen) is minimal. That is a dramatically smaller MILP: the binary variables outside K are pinned
to 0, so branch-and-bound never explores them. The two-phase structure trades one hard global
optimization for one cheap feasibility solve plus one small local optimization — and crucially it can
then iterate, peeling off multiple minimal designs from the same subspace in the inner while before
returning to the full space for a fresh foothold. This is why ANY is the fastest way to get a stream
of valid minimal designs when you do not care about global cost-optimality across designs.
Note the design is minimal within the subspace — ANY does not guarantee it is globally cost-minimal (a cheaper design might live in a subspace the feasibility solve never visited). That is precisely the guarantee BEST adds.
8.3.2 BEST — compute_optimal (strainDesignMILP.py): global optimum, then fix and iterate
The user wants the globally cheapest design(s), in nondecreasing cost order. The first solve is not
a feasibility solve; it is a genuine global optimization (:335-338):
self.resetTargetableZ()
self.resetObjective() # vector := c_bu (the real cost objective)
self.fixObjective(self.c_bu, np.inf) # row 2 inert
z, _, opt, status = self.solveZ()
Here the objective vector is the cost objective c_bu and the solver must prove global optimality —
close the gap between the best incumbent and the lower bound. That is inherently more work than ANY’s
feasibility solve (the whole tree may need pruning to certify no cheaper design exists), which is the
price of the stronger guarantee.
For a pure MCS problem (is_mcs_computation, :342-351) the objective is the intervention cost, so
the optimal z is already a minimal design; BEST verifies it, records it, adds the exclusion cut, and
loops — each iteration returns the next-cheapest design because the accumulated cuts push the solver to
progressively higher cost.
For a bilevel problem (OptKnock etc., is_mcs_computation == False, :352-373) the primary objective
is a production objective, not cost, so BEST does the same fix-and-reminimize trick as ANY but around
the global optimum: fixObjective(c_bu, opt) pins the optimal production value, setMinIntvCostObjective
switches to minimizing knockouts, setTargetableZ(z) restricts to the found subspace, and the inner
loop enumerates minimal-intervention designs that all achieve the optimal production value.
8.3.3 POPULATE — enumerate (strainDesignMILP.py): native solution pool per cost level
The user wants all equally-optimal designs at each cost level — the exhaustive enumeration used for
the correctness gates (e_coli_core = 455 MCS, iML1515 393 gene-MCS). The objective setup is the same as
BEST (optimize, then fix the optimal value), but instead of extracting one solution per solve it calls
the solver’s native solution pool via populateZ (:221-237) → populate (solver_interface.py).
For pure MCS (:571), the cost objective is already installed, so enumerate goes straight to
populateZ(remaining). For bilevel (:571-580) it first optimizes the production objective, fixes it,
and swaps to the cost objective — then populates.
z, status = self.populateZ(self.max_solutions - sols.shape[0])
for i in range(z.shape[0]):
if all(self.verify_sd(z[i])):
self.add_exclusion_constraints(z[i]); sols = vstack((sols, z[i]))
else:
self.add_exclusion_constraints(z[i]) # drop invalid, still exclude
populateZ (:221-237) collects the whole pool, rounds the binary blocks, and deduplicates by
support (two pool members with identical z.indices are the same design even if their continuous
tails differ — the same cut set can be certified by different Farkas rays / flux distributions). The
pool is configured to contain only equally-optimal members (pool gaps set to ~0, §8.6), so one
populate call returns every design at the current optimal cost. The outer loop then adds cuts for all
of them, re-optimizes to the next cost level, and populates again — walking up the cost ladder,
emitting a complete pool at each rung, until infeasible or max_solutions reached.
This is why POPULATE is the tool for the correctness gates: it is the only mode that provably returns all minimal designs at each cost, so a count like “393” is meaningful. It is also the most expensive mode, because filling a pool means the solver keeps searching after finding the optimum (see §8.7).
8.4 The iterative loop and integer cuts (the minimal-and-distinct guarantee)
All three modes are iterative: find a design, exclude it, repeat until infeasible. “Exclude it” is where the minimality and distinctness guarantees are actually enforced, via two different exclusion constraints chosen by whether the found design is valid.
8.4.1 The superset-excluding cut — add_exclusion_constraints (:162-181)
Given a found binary design z* with support \(K = \{i : z^*_i = 1\}\), \(|K| = k\), this routine handles
three cases:
Case \(k \ge 2\) (the classic no-good / integer cut, :177-181):
Claim: a binary point z' violates this cut (is excluded) iff \(\operatorname{supp}(z') \supseteq K\), i.e. iff z' is
z* or any superset of it. Proof: \(\sum_{i \in K} z'_i \le k-1\) fails exactly when \(\sum_{i \in K} z'_i = k\), and since
each \(z'_i \in \{0,1\}\), that sum reaches k only if \(z'_i = 1\) for every \(i \in K\) — i.e. \(K \subseteq \operatorname{supp}(z')\).
Values of z' outside K are unconstrained by the cut. ∎
Why exclude supersets, not just z*? Because for MCS, any superset of a valid cut set is itself a
valid cut set — adding more knockouts cannot make a suppressed behavior feasible again (it can only
remove flux capability). A superset is therefore always non-minimal and must never be reported. The
single cut \(\sum_{i \in K} z_i \le k-1\) removes z* and its entire up-set in one row, guaranteeing that once a
minimal design is found, no bloated version of it can ever be returned. This is the mechanical heart of
the minimality guarantee. (The PROTECT constraints mean a superset is not automatically feasible in
the MILP, but excluding it is still correct and keeps the enumeration to minimal designs; the inner
subspace minimization is what ensures we found the minimal member of that up-set before cutting it.)
Case \(k = 1\) (single-reaction cut, :172-175):
interv_idx = int(z[i].indices[0])
self.z_non_targetable[interv_idx] = True
self.set_ub([[interv_idx, 0.0]])
Instead of adding a row z_{i*} ≤ 0, it directly pins ub(z_{i*}) = 0 and marks the reaction
non-targetable. This is strictly stronger and cheaper than a constraint row: setting the upper bound to
0 removes the variable from consideration entirely (presolve fixes it), and it excludes z* and every
superset containing i* — same up-set semantics as the k≥2 cut, but implemented as a bound rather
than a row, so it does not grow the constraint matrix. A size-1 MCS means reaction i* alone suffices;
no design containing i* can ever be minimal-and-new, so banning i* outright is exactly right.
Case \(k = 0\) (empty design, :166-170): adds the row \(\sum_i z_i \le -1\), which is infeasible for
any nonnegative z. This deliberately makes the MILP infeasible to force clean termination. It is only
reachable in degenerate setups (the “no interventions needed” case is caught earlier by the verify_sd
of the all-zero design at :322/:429/:548); the guard is defensive — some solvers reject genuinely
empty constraint rows, so a -1 rhs is used rather than an empty row.
8.4.2 The exact-pattern cut — add_exclusion_constraints_ineq (:183-198)
Sometimes we must exclude exactly z* but not its supersets:
In code the row is built with coefficient +1 on \(i \in K\) and -1 on \(i \notin K\), rhs k-1. Claim: a binary
z' is excluded iff \(z' = z^*\) exactly. Proof: the left side is \(\sum_{i \in K} z'_i - \sum_{i \notin K} z'_i\). It
reaches k (violating \(\le k-1\)) only when \(\sum_{i \in K} z'_i = k\) and \(\sum_{i \notin K} z'_i = 0\), i.e.
\(z'_i = 1\ \forall i \in K\) and \(z'_i = 0\ \forall i \notin K\) — the single point \(z' = z^*\). Any superset (which turns on some
\(i \notin K\), subtracting from the sum) survives; any subset (which turns off some \(i \in K\)) survives. ∎
Why would we ever want to keep supersets? When the found z* is invalid — the MILP produced a
z that its relaxation accepted but which verify_sd (§8.5) rejects. An invalid pattern’s supersets
may well be valid designs, so we must not cut the whole up-set; we surgically remove only the exact
offending point and let the search revisit its supersets. This is the asymmetry that makes the loop
both complete (no valid design lost) and minimal (no non-minimal design kept):
Found design |
validity |
exclusion used |
removes |
|---|---|---|---|
valid, minimal-in-subspace |
|
|
|
invalid (relaxation artifact) |
|
|
exactly |
You can see the branch explicitly in compute (:484-490) and compute_optimal (:365-371): valid →
superset cut + record; invalid → exact cut, no record.
8.5 verify_sd: re-checking validity in the true continuous subsystem
verify_sd (strainDesignMILP.py) is the referee. Given one or more binary designs, it
reconstructs — for each — the continuous LP that the design actually induces and checks whether the
strain-design intent is met, independently of the MILP’s big-M/indicator machinery.
Mechanically, it uses the stored cont_MILP (the continuous-only slice of the MILP,
strainDesignProblem.py) together with three z→row/variable maps (z_map_vars,
z_map_constr_ineq, z_map_constr_eq). For a design sol:
inactive_vars = [ var for z_i,var,sense in zip(z_map_vars.row, .col, .data)
if np.logical_xor(sol[0, z_i], sense == -1) ]
active_vars = [ i for i in range(...) if i not in inactive_vars ]
# same for ineqs and eqs
lp = MILP_LP(A_ineq=cont_MILP.A_ineq[active_ineqs][:, active_vars], ...,
solver=self.solver, seed=self.seed)
valid[i] = not np.isnan(lp.slim_solve())
The logical_xor(sol[z_i], sense == -1) handles the knock-in inversion: for a normal knockout
(sense == +1) a z_i = 1 deactivates the linked variable/row; for an addition/knock-in
(sense == -1, the sign flip installed at strainDesignProblem.py) the polarity is reversed.
The result is the set of variables and constraints that remain live after the design is applied.
verify_sd then builds the reduced LP over exactly those, and returns validity = “the reduced LP is
feasible” (slim_solve not NaN).
Why re-verify at all, when the MILP already enforces the modules? Three reasons:
Big-M / indicator slack. With finite big-M (Ch 7) or indicator activation the MILP enforces the Farkas/primal conditions only to within
FeasibilityTol(1e-9here, but scaled by M). Azcan satisfy the relaxed certificate to tolerance yet not correspond to a genuinely infeasible/feasible continuous subsystem.verify_sdre-solves the exact continuous LP with no M and no tolerance fudge, catching these artifacts.Subspace minimization can overshoot. The “minimize intervention cost in subspace” step can, in edge cases, drop a knockout the certificate needed, producing a
zthe MILP’s relaxation still accepts but that is not truly valid. Re-verification is the guard that routes such azto the exact-pattern cut.The all-zero pre-check. At the top of each mode (
:322,:429,:548)verify_sdis called on the empty designcsr_matrix((1, num_z)); if the untouched strain already satisfies the modules, no interventions are needed and the mode returns[{}]immediately.
Because verify_sd builds a fresh MILP_LP per design per call, it is not free — for large models the
repeated LP feasibility solves are a measurable slice of enumeration time — but it is what lets the loop
trust the solver’s output enough to add the strong (superset) cut, which is what keeps enumeration
tractable. Note it is passed self.seed, so even these auxiliary LPs are reproducible.
8.6 Solver parameters and determinism
8.6.1 No MIP optimality gap is set — the 1e-4 consequence
Neither backend sets a MIP relative or absolute optimality gap. Grep the interfaces:
cplex_interface.py sets the pool gaps (mip.pool.absgap, mip.pool.relgap) and the
integrality tolerance, but never mip.tolerances.mipgap; gurobi_interface.py sets
PoolGap/PoolGapAbs but never MIPGap. Both solvers therefore run at their default relative
MIP gap of 1e-4.
Consequence, stated plainly: the solver considers a MILP “solved to optimality” once the incumbent is
within 0.01 % of the proven bound. For BEST and for each cost rung of POPULATE, the reported “optimal
cost” can in principle be off by up to 1e-4 · |opt|. For integer intervention costs (the common case,
cost_i = 1) this is harmless — 1e-4 is far below the unit spacing between distinct cost levels, so
the cost ordering and the set of designs at each level are unaffected. But it is a latent hazard for
non-integer or widely-scaled cost vectors, where two genuinely different cost levels could fall
within 1e-4 relative of each other and be conflated. A developer tightening correctness for weighted
costs should set MIPGap/mipgap to 0 explicitly (accepting the extra time to close the last sliver
of gap). Do not confuse this with the 1e-9 values that are set: those are OptimalityTol/
FeasibilityTol/IntFeasTol (Gurobi) and simplex.tolerances.optimality/feasibility +
mip.tolerances.integrality (CPLEX) — LP-level and integrality tolerances, not the MIP optimality gap.
8.6.2 The solution-pool parameters are inert for single solve
The CPLEX pool parameters mip.pool.intensity = 4, mip.pool.absgap = 0, mip.pool.relgap = 0
(cplex_interface.py), and the Gurobi PoolGap/PoolGapAbs = 1e-9 (:162-163), only take
effect during pool generation (populate_solution_pool / PoolSearchMode = 2). During an ordinary
solve — which is all ANY and BEST ever call — the pool stays empty and these settings do nothing.
They matter only for POPULATE, where intensity = 4 (CPLEX’s most aggressive pool search) and
zero pool gaps mean “find every solution tied at the optimum”. These are verified inert for single
solve; they are not a performance bug and predate the current code (2022). Gurobi’s populate
additionally flips PoolSearchMode = 2, NumericFocus = 2 on entry and resets them to 0 on exit
(gurobi_interface.py), so single solves see the defaults.
8.6.3 Seed → branch-and-bound tree shape → why speed needs a distribution
The seed flows from the SD problem to the backend and lands on randomseed (CPLEX,
cplex_interface.py), Params.Seed (Gurobi, :157), and randomization/randomseedshift (SCIP,
scip_interface.py). If the user gives no seed, each backend draws one from [0, 2^16) and logs
it — so even an unseeded run is reproducible after the fact, given the logged seed.
The seed perturbs tie-breaking throughout branch-and-bound: which fractional variable to branch on when
several are equally attractive, which node to explore next, the order heuristics fire, how the simplex
breaks degenerate pivots. On a genome-scale MCS MILP the LP relaxation is massively degenerate (many
equally-good fractional z), so tie-breaking dominates the path the solver takes to the optimum. Two
seeds can produce wildly different tree sizes and hence wildly different wall-times for the same
instance and the same final design set.
The practical rule this forces: any speed claim — ANY vs BEST, CPLEX vs Gurobi, and especially POPULATE — must be measured over multiple seeds and reported as a distribution (median/IQR), not a single number. A single-seed “Gurobi is 4.4× faster” could be an artifact of one lucky/unlucky tree. The numbers in §8.7 are useful as orders of magnitude but should be reproduced across seeds before any optimization is judged to have helped. This is the single most important benchmarking discipline for this code: never tune against one seed.
The _trim_z_variables step (strainDesignMILP.py) is a determinism-adjacent optimization
worth noting: it physically removes non-knockable (ub=0, cost=0) binary columns from the matrices
before the solver sees them, shrinking the binary count and keeping the B&B tree from carrying dead
variables. Solutions are expanded back to the original z-space afterward (_expand_z_to_orig,
:151-160).
8.7 Verified performance: the phase timeline and CPLEX vs Gurobi
For the canonical iML1515 gene-MCS problem (SUPPRESS biomass ≥ 0.001, POPULATE, max_cost = 3,
gene KOs) yielding 393 MCS (package v1.18):
Phase |
Time |
Notes |
|---|---|---|
Preprocessing: blocked/irreversible FVA |
~117 s |
solver-agnostic, one-time |
MILP build |
~4 s |
matrix assembly + |
Populate (enumeration) |
~1101 s (CPLEX) |
dominates |
Total |
CPLEX 1241 s / Gurobi 280 s (≈4.4×) |
For e_coli_core (455 MCS) the whole thing is ~1.2 s on CPLEX — small enough that phase structure is irrelevant.
Interpretation. On iML1515, preprocessing FVA (~117 s) and build (~4 s) are essentially fixed costs
independent of the MILP solver; they are ~10 % of the CPLEX total. The remaining ~89 % is the
pool search inside populate. So the thing that dominates genome-scale enumeration is not solving
a single MILP to optimality — a single feasibility or optimality solve is comparatively quick — it is
exhaustively filling the solution pool at each cost level: the solver must, after finding the optimal
cost, keep branching to enumerate every tied design and prove there are no more. That is intrinsically
harder than a single optimize, and it is where CPLEX and Gurobi diverge: Gurobi’s pool search
(PoolSearchMode = 2) closes this instance ~4.4× faster than CPLEX’s populate_solution_pool at
intensity = 4. The preprocessing FVA (Ch 5) is the second-largest lever and, being solver-agnostic, is
where portable speedups live; the pool search is a solver-quality question.
Because this 4.4× is a single-seed figure, per §8.6.3 it should be read as “Gurobi is materially faster here”, not as a precise constant — reproduce across seeds before quoting it as a benchmark.
The discredited “big-M / indicators-catastrophic” dead-end. An earlier performance hypothesis held
that native indicator constraints were catastrophically slow at genome scale and that forcing a
global big-M reformulation would fix it. This was investigated and discredited — do not repeat
it. Two reasons: (1) The dominant cost is pool enumeration (~89 % above), not the LP relaxation of the
z-linking, so swapping the linking mechanism cannot address the actual bottleneck. (2) Indicator
constraints give a tighter LP relaxation than big-M (Ch 7) — a valid big-M must be large enough to
never spuriously bind, which loosens the relaxation and generally hurts branch-and-bound, the opposite
of the hypothesis. Recall also (CONTEXT §, Ch 7) that under the default M = inf, SUPPRESS’s unbounded
Farkas-dual rows become indicator constraints and PROTECT’s finite-flux primal rows become big-M
emergently from the bound structure in link_z — there is no per-module type switch to “fix”. The
lever that actually moves genome-scale time is faster pool search (solver choice) and cheaper
preprocessing FVA, not the linking encoding.
8.8 How SCIP and GLPK differ
Neither SCIP nor GLPK exposes a native optimal-solution pool, so enumerate/POPULATE is emulated at
the Python level, and both backends emit a warning steering the user toward compute_optimal instead
(strainDesignMILP.py).
SCIP (scip_interface.py) emulates populate by a solve-and-exclude loop: solve to the
optimum, add a constraint pinning c·x ≤ min_cx (optimality), exclude the found solution with an
exact-pattern inequality cut (addExclusionConstraintIneq), and repeat until infeasible — reconstructing
the pool one solution at a time. It supports indicator constraints natively (so no big-M is forced), and
its seed enters via randomization/randomseedshift. It is correct but slower than a native pool, hence
the “consider compute_optimal” advice.
GLPK (glpk_interface.py) emulates populate the same solve-and-exclude way, but with two
extra handicaps. First, GLPK has no indicator constraints at all, so z-linking is done entirely by
big-M, defaulting to the COBRA bound M = bound_thres (typically 1000, strainDesignProblem.py).
At genome scale this big-M is too weak: 1000 is simultaneously large enough to admit numerical slop
in the Farkas certificate (values that are “zero” only to 1e-3·M) and small enough to occasionally
bind a flux it should not, so the relaxation is both loose and numerically fragile. Second, the emulated
pool is explicitly flagged “instable” in code and warnings. The practical upshot: GLPK is fine for
small models (e_coli_core-scale validation) but not a genome-scale enumeration engine — for iML1515-class
problems use CPLEX or Gurobi. The emulation cleans up after itself by freeing the auxiliary rows’
right-hand sides rather than deleting rows (glpk_interface.py), because GLPK row deletion
proved unstable.
9. Decompression & solution semantics
The MILP does not run on the model the user handed to compute_strain_designs. By the time
SDMILP is built (Ch 7), the network has passed through two lossless compression rounds (COMPRESS
#1 before GPR integration, COMPRESS #2 after — Ch 3), an optional GPR extension that turned genes
into pseudoreactions (Ch 4), and three FVA passes that pruned essential reactions and pulled out
size‑1 minimal cut sets (Ch 5). The binary intervention variables z therefore index compressed
reactions of the GPR‑extended model, not the original reactions or genes the user cares about.
Decompression is the inverse map. It takes each compressed intervention set the solver returned and
rewrites it in terms of the original model’s reactions (and, for gene‑based problems, the original
genes), re‑injects the size‑1 MCS that never entered the MILP, re‑checks the max‑cost budget that
expansion can silently violate, and finally packages everything into an SDSolutions object whose
display methods hide the internal bookkeeping. This chapter covers the mechanics and the math of each
step, and the precise value‑encoding (-1 / +1 / 0 / (nan,nan)) that the downstream tooling —
and two open bugs, deferred to Ch 10 — depends on.
The entry point is _decompress_solutions (compute_strain_designs.py), called from the main
orchestrator at compute_strain_designs.py after the solve, and again from the
resume‑from‑pickle path at compute_strain_designs.py. The two workhorse routines it delegates
to live in networktools.py: expand_sd (networktools.py) and filter_sd_maxcost
(networktools.py).
9.1 Why decompression is needed, and the shape of the compression map
Compression merges reactions in two ways (Ch 3), and the merge is recorded step by step in a list
called cmp_mapReac. It is assembled by concatenating the two compression rounds’ maps at
compute_strain_designs.py:
cmp_mapReac = cmp_mapReac_1 + cmp_mapReac_2
Each element of cmp_mapReac is one compression step — a Python dict with the fields that
compress_model and compress_ki_ko_cost write:
field |
meaning |
|---|---|
|
|
|
|
|
the knockout‑cost dict in the pre‑step (finer) reaction space |
|
the knock‑in‑cost dict in the pre‑step reaction space |
The reac_map_exp structure is produced by the compressors (compress_model_coupled documents the
return type as {compressed_id: {orig_id: factor, …}} at compression.py; the parallel
compressor is analogous), and parallel is stamped on when the step is appended to the list
(compression.py for parallel, compression.py for coupled). The two cost dicts are
attached later by compress_ki_ko_cost (networktools.py): cmp.update({KOCOST: kocost, KICOST: kicost}) records the cost vectors as they stood entering that step, before that step’s
lumping rewrites them. This is the crucial invariant that makes reverse expansion self‑describing:
step k carries exactly the cost dicts keyed by the reaction ids that step k’s expansion will
re‑introduce.
The factor in reac_map_exp is the stoichiometric scaling that made the merge exact. It is used
when constraints and objectives are pushed into the compressed space (compress_modules,
networktools.py: c[0][new_reac] = Σ c[0][k]·old_reac_val[k]) and when a compressed flux
vector is mapped back to originals. For strain‑design expansion the factor is not needed — a
knockout of a lumped reaction is a set decision (which originals to cut), not a numeric scaling — so
expand_sd iterates only the keys of r_orig and ignores the factors.
Why compress at all before solving, given that we must undo it here? Because the MILP’s size — number
of binary z, number of continuous dual/primal rows, big‑M/indicator wiring — scales with the
compressed reaction count, and the branch‑and‑bound cost is super‑linear in that. Compression on
iML1515 removes the overwhelming majority of columns losslessly (the merged reactions are provably
flux‑coupled or scaled‑parallel, so no cut set is lost — see Ch 3). Solving small and expanding after
is strictly cheaper than solving large, provided the expansion is faithful. The rest of this
section is that faithfulness argument.
9.2 The math of reverse expansion (expand_sd)
A compressed solution is a dict m = { cmp_id : val } where val ∈ {-1, +1, 0} (the encoding is
§9.5). Expansion must invert the composition of the step maps. Compression built the compressed
reactions by applying step 1, then step 2, …, then step L; so a compressed id in the final space is
the image of step L ∘ … ∘ step 1. To recover originals we apply the inverse steps in the opposite
order — step L⁻¹ first, then step L−1⁻¹, …, ending at step 1⁻¹. expand_sd does exactly this by
reversing the list once at the top (networktools.py):
cmp_map = cmp_mapReac[::-1]
for exp in cmp_map:
reac_map_exp = exp["reac_map_exp"]
ko_cost = exp[KOCOST]
ki_cost = exp[KICOST]
par_reac_cmp = exp["parallel"]
for r_cmp, r_orig in reac_map_exp.items():
if len(r_orig) > 1:
for m in sd.copy():
if r_cmp in m:
...
sd is a list of solution dicts that grows during the loop; one compressed design can fan out into
several expanded designs. Only genuinely lumped reactions (len(r_orig) > 1) need work — a
compressed id that stands for a single original is renamed implicitly because the same string id was
kept, so no rewrite is required. When a design m mentions a lumped id r_cmp, the value val = m[r_cmp] is popped and the members of r_orig are re‑introduced. There are four cases, and they
split on the KO/KI sign of val crossed with whether the step was parallel or coupled — because
the biology of “what does cutting/adding the group mean for its members” differs between the two merge
types.
Case KO of a parallel group (val < 0, par_reac_cmp True)
Parallel reactions carry flux in fixed proportion because their S‑columns are scalar multiples of
one another; they are, metabolically, redundant routes for the same conversion. To suppress the
group you must remove every knockable member — leaving any one open leaves the conversion
possible. So expansion produces one design that knocks out all knockable members
(networktools.py‑1504):
if par_reac_cmp:
new_m = m.copy()
for d in r_orig:
if d in ko_cost:
new_m[d] = val # -1 on every knockable member
sd += [new_m]
The if d in ko_cost guard matters: a member that is not knockable (no KO cost — e.g. it was made
essential, or the user never offered it as a target) is simply not added, and the group is still
considered “knocked out” to the extent the modeller allowed. This is why the pre‑step ko_cost
dict is carried on the step.
Case KO of a coupled group (val < 0, par_reac_cmp False)
Coupled (flux‑coupled) reactions must all carry flux together in every steady state — v_i = 0 ⇔ v_j = 0 for members of the group. Therefore killing any single member forces the whole group to
zero. Cutting the group is not “cut them all”; it is “cut one, your choice.” Each choice is a
distinct, minimal strain design, so expansion branches — it emits one new design per knockable
member (networktools.py‑1510):
else: # coupled
for d in r_orig:
if d in ko_cost:
new_m = m.copy()
new_m[d] = val # a separate design per member
sd += [new_m]
This is the source of solution multiplicity: a single compressed KO of a coupled 4‑reaction group becomes up to four original‑model MCS. All are correct and all are minimal — they are genuinely different interventions with the same downstream effect. Keeping them distinct (rather than reporting one representative) is what lets the user pick the intervention that is easiest to realise in the lab.
Case KI of a group (val > 0)
Knock‑ins mirror the KOs with parallel/coupled swapped, because “adding capability” is dual to “removing it”:
Parallel KI (
networktools.py‑1520): the parallel members are interchangeable routes, so adding any one suffices. Expansion branches — one design per KI‑able member — and, in each branch, explicitly marks the other members as not added with value0.0:if par_reac_cmp: for d in r_orig: if d in ki_cost: new_m = m.copy new_m[d] = val for f in [e for e in r_orig if (e in ki_cost) and e != d]: new_m[f] = 0.0 # the alternatives, explicitly un-added sd += [new_m]
The
0.0tags are not cosmetic — they carry the “this KI candidate existed and was deliberately left out” information that §9.5 andstrip_non_kidepend on.Coupled KI (
networktools.py‑1526): coupled members only carry flux together, so a functional insertion must add all of them; expansion emits one design that knocks in every KI‑able member.
Case KI not introduced (val == 0)
A compressed id may appear in the design with value 0 — a KI candidate the solver decided not to
use (§9.5). Expansion propagates that “not added” verdict to every member of the group
(networktools.py‑1532):
elif val == 0: # KI that was not introduced
new_m = m.copy()
for d in r_orig:
if d in ki_cost:
new_m[d] = val # 0.0 on every member — none inserted
sd += [new_m]
No branching here: “added nothing” has exactly one realisation.
A worked micro‑example
Take a two‑step map. COMPRESS #1 lumped coupled reactions {R1, R2} → C (parallel: False), and
COMPRESS #2 lumped parallel reactions {C, R3} → P (parallel: True), where after step 1 the space
is {C, R3, …}. All of R1, R2, R3 are knockable. The solver returns the compressed KO {P: -1}.
Reverse order: apply step 2⁻¹ (parallel) first. P is a parallel lump of {C, R3}, val < 0, so
we emit one design knocking out all members: {C: -1, R3: -1}. Now apply step 1⁻¹ (coupled). C
is a coupled lump of {R1, R2}, val < 0, so we branch into one design per member, carrying R3
along: {R1: -1, R3: -1} and {R2: -1, R3: -1}. Final: two original‑model MCS. Both cut R3 (parallel
redundancy demanded it) and each cuts one of the coupled pair (either suffices). This is precisely the
set of minimal cut sets in the original network that the compressed {P:-1} stands for.
estimate_expansion_size (networktools.py) computes the count of this fan‑out without doing
it, by walking the same reversed map and multiplying a factor: coupled‑KO and parallel‑KI multiply
by the number of eligible members (they branch), parallel‑KO and coupled‑KI multiply by 1 (they
don’t). It returns an exact count for single‑step compression and an upper bound otherwise (because
across steps the same original could in principle be reached twice); it drives the lazy‑expansion
decision in §9.4.
9.3 Size‑1 MCS re‑injection
Recall from Ch 5 that FVA #3 (compute_strain_designs.py‑491) finds reactions that are
essential for the SUPPRESS behaviour but not for any PROTECT behaviour — i.e. reactions whose sole
knockout already makes the undesired flux infeasible while keeping the desired flux feasible. These
are size‑1 minimal cut sets. They are deliberately removed from the knockable set before the MILP is
built (cmp_ko_cost.pop(r, None) at compute_strain_designs.py) and stored separately:
cmp_size1_mcs = [{r: -1} for r in size1_mcs_knockable] # compute_strain_designs.py:481
The rationale (Ch 5) is twofold: they need no search, and — more importantly — leaving them in the
MILP would let the enumerator report every superset that contains a size‑1 MCS, which is
non‑minimal. Pulling them out keeps the MILP’s minimal‑cut‑set guarantee clean. But they are still
real solutions, so decompression must add them back. Note this happens only for classical MCS
problems (exactly one SUPPRESS + only PROTECT modules — the is_classical_mcs gate at
compute_strain_designs.py‑475); bilevel problems (OptKnock etc.) never populate cmp_size1_mcs.
Re‑injection runs after the MILP designs have been expanded (compute_strain_designs.py‑712).
Each stored size‑1 MCS {r:-1} is itself a compressed design — r is a compressed reaction id — so
it goes through the same expand_sd + filter_sd_maxcost pipeline (one size‑1 compressed cut can
still fan out to several originals if r is a lumped reaction). It is then de‑duplicated against the
already‑expanded MILP designs before being appended:
existing = [frozenset(s.items()) for s in sd]
for grp_idx, cmp_s in enumerate(cmp_size1_mcs):
expanded = expand_sd([cmp_s], cmp_mapReac)
expanded = filter_sd_maxcost(expanded, max_cost, uncmp_ko_cost, uncmp_ki_cost)
expanded = postprocess_reg_sd(uncmp_reg_cost, expanded)
for s in expanded:
if frozenset(s.items()) not in existing:
sd.append(s)
group_map.append(next_grp + grp_idx)
existing.append(frozenset(s.items()))
compressed_sd.append(cmp_s)
The de‑dup guard exists because a size‑1 MCS can coincide with something the MILP also found through a different route (e.g. via a coupled expansion), and we must not report it twice. Two further details:
Group bookkeeping. Each compressed design (MILP or size‑1) is a group; its expanded members share a
group_mapindex (compute_strain_designs.py,next_grp = len(compressed_sd)). This is what powersget_group/get_representative_sdon the result object — the user can collapse the fan‑out back to “one decision per group” for display.Status promotion. If the MILP itself found nothing (INFEASIBLE) but size‑1 MCS exist, the status is lifted to OPTIMAL so the result is not reported as “no solution” (
compute_strain_designs.py). Thedump_preprocessedearly‑return path (compute_strain_designs.py‑592) uses the same expand→filter→postprocess sequence to return size‑1 MCS even when the MILP solve is skipped entirely.
9.4 filter_sd_maxcost: why a post‑expansion cost re‑check is mandatory
max_cost bounds the total intervention cost of an acceptable design. The MILP already enforces it in
compressed space (the cost row over z, Ch 7). Why filter again after expansion? Because expansion
can change a design’s effective cost, in both directions, so a compressed design that was within budget
can expand into original‑model designs that are not — and vice versa.
The reason is that compress_ki_ko_cost (networktools.py‑1410) does not preserve cost
additively; it collapses a group’s member costs to a single number using rules that are correct for
the group decision but lossy about the members:
coupled KO cost =
minof member KO costs (networktools.py) — because cutting the group costs only as much as cutting its cheapest member (you only need one).parallel KO cost =
sumof member KO costs (networktools.py) — because you must cut them all.coupled KI cost =
sum; parallel KI cost =min(networktools.py,1409) — the duals.
Now cross this against §9.2’s expansion. A coupled KO was compressed at cost min, but expansion
branches into one design per member, and each branch’s true cost is that member’s KO cost — which
for every member other than the minimum is larger than the compressed cost. The compressed design
passed the MILP budget at the cheap member’s price; several of its expansions must be discarded because
their actual price exceeds max_cost. Concretely: a coupled pair with KO costs {1, 5} compresses to
cost 1; under max_cost = 3 the compressed design is feasible, but only the cost‑1 expansion
survives — the cost‑5 sibling is filtered out. Without the re‑check we would report an over‑budget
design.
filter_sd_maxcost recomputes the true cost in original space and keeps designs within a small
tolerance of the budget (networktools.py‑1554):
if max_cost:
costs = [np.sum([(kocost[k] if k in kocost else kicost.get(k, 0)) if v != 0 else 0
for k, v in m.items()]) for m in sd]
sd = [sd[i] for i in range(len(sd)) if costs[i] <= max_cost + 1e-8]
[s.update({'**cost**': c}) for s, c in zip(sd, costs)]
sd.sort(key=lambda x: x.pop('**cost**'))
Three things to read carefully here. First, the if v != 0 clause: only interventions actually made
count toward cost. A KI candidate left un‑made carries value 0 and is free — this is exactly the
(nan,nan) / value‑0 encoding of §9.5, and it is why that encoding must survive expansion rather than
being stripped early. Second, it costs each original reaction independently with the uncompressed
cost dicts uncmp_ko_cost / uncmp_ki_cost (assembled in the orchestrator and, for gene problems,
merged with gene costs at compute_strain_designs.py‑422) — never the compressed dicts. Third,
the surviving designs are sorted by ascending true cost via a throwaway '**cost**' key, so the
cheapest realisations surface first; in the lazy path (below) this ordering is what makes
expanded[0] the “cheapest representative” of a group (compute_strain_designs.py).
The lazy‑expansion path (estimated count > 100 000)
For problems where the fan‑out is enormous — many deep coupled groups multiplying together —
materialising every expanded design would exhaust memory even though the search itself finished
(this is issue #47, noted in SDSolutions.save). _decompress_solutions guards against this
(compute_strain_designs.py,654‑681):
LAZY_EXPANSION_THRESHOLD = 100_000
...
estimated = estimate_expansion_size(cmp_sds, cmp_mapReac)
estimated += estimate_expansion_size(cmp_size1_mcs, cmp_mapReac)
if estimated > LAZY_EXPANSION_THRESHOLD:
sd, group_map, compressed_sd = _build_lazy_representatives(...)
_build_lazy_representatives (compute_strain_designs.py) expands each compressed group just
far enough to keep one representative — the cheapest survivor of expand_sd + filter_sd_maxcost
— and records the machinery (the compressed designs, the map, the uncompressed cost dicts, the model)
in an _expansion_meta dict on the SDSolutions (compute_strain_designs.py‑677). The result
reports get_num_sols as the estimated total while only a handful are materialised
(get_num_materialized), and the user can force any group’s full expansion on demand via
expand_group / expand_all (strainDesignSolutions.py,520), which run the identical
expand→filter→translate pipeline lazily. This is a pure space/time optimisation — the eager and lazy
paths compute the same designs; lazy just defers the combinatorial blow‑up until (if ever) the user
asks for it.
9.5 KI/KO value encoding and the strip semantics
Every solution dict — compressed or expanded, gene‑ or reaction‑level — encodes each intervention as a numeric value with a fixed meaning:
value |
meaning |
bounds it maps to ( |
|---|---|---|
|
reaction/gene knocked out |
|
|
knock‑in candidate added |
the reaction’s original |
|
knock‑in candidate offered but not added |
|
|
regulatory intervention active |
derived from the constraint |
|
regulatory intervention not added |
— |
(absent) |
reaction never a candidate |
— |
The value originates in sd2dict (strainDesignMILP.py‑213), which reads the solved binary
vector. A z variable is inverted iff it is a KI candidate — z_inverted[i] = not isnan(ki_cost[i])
(strainDesignProblem.py). For a non‑inverted (KO) variable, z=1 means “apply the cut”, written
as -sol = -1; for an inverted (KI) variable, z=1 means “insert”, written as +sol = +1. The
subtle line is the 0.0:
elif args and args[0] and (sol[0, i] == 0) and self.z_inverted[i]:
output[reacID[orig_i]] = 0.0
Only when show_no_ki is on (it is, by default, set at compute_strain_designs.py) and the
variable is a KI candidate that came back at z=0, does the design record an explicit 0.0.
_decompress_solutions reads these 0‑tagged designs via get_reaction_sd_mark_no_ki
(strainDesignSolutions.py, compute_strain_designs.py).
Why encode “not added” at all, instead of just omitting it? Because a knock‑in candidate that the solver chose to leave out is different information from a reaction that was never a candidate, and several steps downstream need to tell them apart:
Expansion correctness. §9.2’s
val == 0branch must propagate “not added” to a lumped KI group’s members, so that a compressed un‑made KI does not silently reappear as made after expansion.Cost correctness.
filter_sd_maxcostcharges onlyv != 0interventions; an un‑made KI must be present‑but‑free, which requires it to be present with value0, not absent.Bounds semantics.
_compute_costs_and_bounds(strainDesignSolutions.py‑255) turns value0into bounds(nan, nan)— a deliberate “no bound change; this capability was considered and declined” marker, distinct from a KO’s(0,0)and from an added KI’s real bounds.
The flip side is that these 0/False entries are noise in a human‑readable listing. strip_non_ki
(strainDesignSolutions.py) removes them:
def strip_non_ki(sd):
return {k: v for k, v in sd.items() if v not in (0.0, False)}
The public accessors get_reaction_sd and get_gene_sd (strainDesignSolutions.py,330) pass
every design through strip_non_ki, so the user sees only interventions that were actually made.
The un‑stripped forms remain available through get_reaction_sd_mark_no_ki /
get_gene_sd_mark_no_ki for callers that need the full picture. This “internal representation keeps
value‑0, display drops it” split is exactly the seam that issues #38 (superset reporting) and #43
(neutral gene KOs) turn on — the semantics are laid out here; Ch 10 owns the bugs. The one property to
carry into that chapter: strip_non_ki uses membership in (0.0, False), so a genuine 0 value —
whatever its origin — is indistinguishable at display time from a declined KI.
9.6 Gene‑level vs reaction‑level translation (_translate_genes_to_reactions)
For gene‑based problems the MILP’s z correspond to gene pseudoreactions (Ch 4), so after expand_sd
the design dicts are keyed by gene ids (and any surviving reaction/regulatory ids). The user
usually wants both views: the gene interventions (what to edit in the lab) and the reaction
phenotype (what those edits actually disable in the network). SDSolutions.__init__ detects a gene
problem (presence of GKOCOST/GKICOST in the setup, strainDesignSolutions.py) and builds both
via _translate_genes_to_reactions (strainDesignSolutions.py).
The translation’s job is: given a set of gene knockouts/knock‑ins, determine which reactions are
disabled. A reaction is governed by its gene–protein–reaction (GPR) rule, an arbitrary Boolean
expression over genes (e.g. (b0001 and b0002) or b0003). The previous implementation re‑parsed these
rules into disjunctive normal form and evaluated a hand‑rolled gpr_eval. The current code instead
reuses cobra’s already‑parsed GPR abstract syntax tree and its evaluator
(strainDesignSolutions.py‑161):
rxn_gpr = {r.id: r.gpr for g in model.genes for r in g.reactions}
reaction.gpr is a cobra.core.gene.GPR AST; gpr.eval(knockouts) walks that tree and returns
True iff the reaction can still be active when the genes in knockouts are removed. The
semantics that make this usable — and the subtlety to get right — is the present‑genes‑default‑active
convention: eval treats every gene listed in knockouts as off and every gene not listed as
present/active. So you drive it entirely through which genes you place in the knockout set.
The translation exploits this by evaluating each reaction’s GPR under three different knockout sets, to
answer three distinct phenotype questions (strainDesignSolutions.py‑195):
ko_off = gene_ko | gene_no_ki # KOs applied; un-made KIs off; made KIs on
all_off = ko_off | gene_ki # additionally undo the knock-ins
noki_off = set(gene_no_ki) # only the un-made KIs are off; KOs undone
...
for r in candidate_reacs:
gpr_r = rxn_gpr[r]
if gpr_r.eval(ko_off): # reaction still possible under the interventions
if not gpr_r.eval(all_off): # ... only because a knock-in kept it alive
reac_ki.add(r)
else: # reaction dead under the interventions
if gpr_r.eval(noki_off): # ... the knock-out is what killed it
reac_ko.add(r)
else: # ... dead regardless (e.g. an un-made knock-in)
reac_no_ki.add(r)
Reading the three comparisons:
eval(ko_off)— the actual post‑intervention world: knocked‑out genes off, un‑made KI genes off, made KI genes on (present by default). If the reaction survives this, it is not knocked out.eval(all_off)vs the above — additionally switch off the made knock‑ins. If the reaction was alive underko_offbut dies once the KIs are also removed, then it was alive only because of a knock‑in → it is a reaction‑level KI (reac_ki, value+1).If the reaction is dead under
ko_off, askeval(noki_off): put back the knockouts (only the un‑made KIs stay off). If it revives, then the knockouts are what killed it → reaction‑level KO (reac_ko, value-1). If it stays dead even with the knockouts undone, it was doomed by something else (typically an un‑made KI it depended on) → reaction “not added” (reac_no_ki, value0).
Only reactions attached to an intervened gene are examined (candidate_reacs is built from the union
of the gene KO/KI/no‑KI sets, strainDesignSolutions.py‑185) — every other reaction is untouched
by definition, so evaluating it would waste time and could only return “unchanged.”
The output preserves the §9.5 encoding on the reaction side: -1.0 for reac_ko, +1.0 for
reac_ki, 0.0 for reac_no_ki, plus True/False for regulatory interventions
(strainDesignSolutions.py‑200). The gene‑level view (gene_sd) is kept verbatim from the raw
solution dicts (strainDesignSolutions.py), including any gene‑name→gene‑id normalisation
(strainDesignSolutions.py‑154), so the two views stay linkable via get_gene_reac_sd_assoc
(the association is typically many gene sets → one reaction phenotype, since different gene KOs can
disable the same reactions).
Because this reaction phenotype is derived by asking the GPR what dies, a gene KO that turns out to disable no reaction (its reaction is protected by an OR‑redundant gene, or the gene is an isozyme partner) produces an empty reaction‑level effect while still showing up as a gene intervention with a cost — the mechanism behind the “neutral gene KO” question of issue #43. The translation is faithful to the GPR; whether such a design should have been enumerated at all is a question for Ch 10.
10. Known issues, gotchas & failure modes
This chapter is a field guide to the ways straindesign can surprise you: two currently-open
correctness issues (#43, #38), one instructive closed one (#44), and a set of API/solver footguns that
have each cost real debugging time. For every item the goal is the same as the rest of this reference —
what goes wrong, the mechanism in the code that produces it, and why the design is shaped that
way. Line numbers were verified against the current source and, like the rest of this guide, may drift with later edits.
A recurring theme unifies most of this chapter: the package carries two parallel identifier spaces for genes (id vs. name) and two parallel encodings for interventions (a “real” nonzero value vs. a value-0 “not-added knock-in” marker). Almost every open gotcha is a place where those two representations are not kept in lockstep. Keep that lens handy while reading.
10.1 Issue #43 (OPEN) — gene-level designs with no reaction-level effect
Symptom (as reported). A returned design lists one or more gene knockouts in gene_sd, but the
corresponding reaction_sd for that design contains no reaction the gene actually disables — the gene KO
is neutral. Critically, the reporter observed that the effect appears when gko_cost is keyed by
gene names and disappears when it is keyed by gene ids, together with a “>255 char reaction name”
trimming warning. That id-vs-name sensitivity is the fingerprint, and it points at two independent
mechanisms, either of which can leave a knockable-but-inert gene in the problem.
Status note. On current
mainthe investigation could not reproduce a genuine neutral gene KO from the reporter’s setup (the setup as re-run only ever produced knock-in designs, so there were no gene KOs to be neutral). Both mechanisms below are therefore best understood as latent, still-live code paths that match the reported id/name signature and remain worth hardening — not as a bug with a known fixing commit. The issue stays open awaiting the reporter’s exact failinggene_sd.
Mechanism 1 — reduce_gpr pops protected/essential genes by id only
reduce_gpr (networktools.py) is the pre-GPR-integration pass that removes genes which cannot
usefully be knocked out — genes that only touch essential reactions, or that are essential to an essential
reaction — so they never become MILP binary variables (see Ch 4 for the full GPR-reduction role). It builds
a protected_genes set (steps 2–3), and then, in step 4:
# line 904
[gkos.pop(pg.id) for pg in protected_genes if pg.id in gkos]
The removal key is pg.id only. If the caller passed gko_cost keyed by gene name
(gkos = {'someGeneName': 1, ...}), then pg.id in gkos is False for every protected gene whose id
differs from its name, so nothing is popped and the protected/essential gene stays in the knockable
cost dict.
The asymmetry is visible one line later. Step 5 protects “all genes that are not knockable”, and this line is name-aware:
# line 907 — note: id OR name
[protected_genes.add(g) for g in model.genes if (g.id not in gkos) and (g.name not in gkos)]
Likewise step 6 restores knock-in candidates by matching either g.id in gkis or
g.name in gkis. So reduce_gpr knows perfectly well that gkos/gkis may be name-keyed — every
membership test checks both id and name — but the one place it mutates gkos, the .pop at line
904, uses pg.id alone. That is the fragility: a single un-mirrored key access in an otherwise
id-or-name-tolerant function.
The downstream effect compounds through the rest of reduce_gpr. protected_genes_dict is keyed by
pg.id and fed to simplify_gpr_ast, which rewrites each reaction’s GPR treating protected
genes as constant-True and deletes them from the Boolean rule; then step 8 removes
protected genes from model.genes entirely. So after reduce_gpr a name-keyed essential
gene can be in an inconsistent state: still present as a cost entry in gkos (because the pop missed it),
but scrubbed out of the GPRs and the gene list. When extend_model_gpr then builds gene pseudoreactions
from model.genes (Ch 4), that gene has no pseudoreaction to attach a z to — the intervention is
declared but wired to nothing, i.e. a neutral gene KO. Fix direction: pop by id and name,
mirroring the membership tests already used at 907/910.
Mechanism 2 — _translate_genes_to_reactions evaluates the GPR only over solution-present genes
Even with a clean knockable set, a gene KO can be genuinely chosen by the MILP and still map to no
reaction, because of how gene designs are translated back to reaction designs at decompression.
_translate_genes_to_reactions (strainDesignSolutions.py) takes a gene-level cut set and asks, for
each reaction the intervened genes touch, whether the reaction survives. It uses cobra’s parsed Boolean GPR
and its .eval (rxn_gpr = {r.id: r.gpr ...}; the AST evaluator replaced the old DNF-only
gpr_eval, per PR #51):
# lines 187–195 (paraphrased structure)
if gpr_r.eval(ko_off): # reaction still possible under the interventions
if not gpr_r.eval(all_off): # ... only because of a knock-in → it's an effective KI
reac_ki.add(r)
else: # reaction dead under the interventions
if gpr_r.eval(noki_off): # ... the KO is what killed it → real reaction KO
reac_ko.add(r)
else:
reac_no_ki.add(r) # dead regardless (un-made knock-in)
The mathematics of cobra GPR.eval(knockouts) is: every gene named in knockouts is treated as
absent, every gene not named is treated as present. The knockout set here is ko_off = gene_ko | gene_no_ki — genes in this solution. Genes absent from the solution default to present/active.
That default is exactly what produces a neutral KO. Consider a reaction with GPR a or b, and a design
that knocks out only b. Then ko_off = {b}, and gpr_r.eval({b}) evaluates False or True = True
(because a, not in the cut set, is treated as present) — the reaction is “still possible”, so b is
not added to reac_ko. The gene b is faithfully recorded in gene_sd (which is just a copy of the
raw solution), but it contributes nothing to reaction_sd. An OR-shadowed gene KO — a gene
behind an or with a non-knocked partner — therefore always appears as a design that has a gene effect but
no reaction effect. This is not a bug in the translator per se; it is the correct GPR semantics. It becomes
a reporting surprise only because such a KO should arguably never have entered the design in the first
place (it is cost with no benefit), which loops back to Mechanism 1 and to the essentiality/knockability
pruning that is supposed to remove inert genes upstream.
The id-vs-name fragility, end to end
Beyond reduce_gpr, the id/name split threads through several stages and is the reason “names break, ids
work” is a plausible signature:
Pseudoreaction vs. pseudometabolite naming diverge. In
extend_model_gpr, whenuse_names=Truethe gene pseudoreaction is named fromgene.name, while the gene pseudometabolite is alwaysg_{gene_id}(see Ch 4). A downstream lookup that expects one convention but gets the other silently misses.Name→id remap happens inside the translator, not before.
_translate_genes_to_reactionsbuildsgene_name_id_dictand rewrites name keys to id keys on its working copy, butgene_sdkeeps the original (possibly name) keys. Two dicts, two key spaces, kept only loosely in sync.Truncation is solver-dependent (§10.5b): long lumped names are sha256-truncated for Gurobi/GLPK but not CPLEX, so a name that is a valid key on CPLEX can be a different (hashed) key on Gurobi — id-keyed runs sidestep this because ids are short.
The practical takeaway: whenever you touch gene-keyed logic, test with gko_cost keyed both ways and
assert the two runs produce identical designs. That equivalence is precisely the regression assertion the
investigation recommended and that no existing test yet enforces.
10.2 Issue #38 (OPEN) — superset/subset (non-minimal) solutions
Symptom (as reported). Pooling sd.ANY results across many random seeds yields designs that are
supersets of other designs in the pool — a 3-intervention cut set that strictly contains a valid
2-intervention one, i.e. apparently non-minimal MCS. The reporter saw “up to ~50%” of pooled solutions
implicated.
There are two genuinely distinct effects here, and separating them is the whole point of triaging #38.
The leading explanation: a reporting artifact from value-0 KI markers
straindesign encodes a not-added knock-in as the value 0.0 in the raw reaction_sd/gene_sd
dicts (and as (nan, nan) bounds in itv_bounds); a made KI is +1, a KO is -1 (see Ch 9 for the full
value/strip_non_ki semantics). The user-facing accessors hide the value-0 entries:
# strainDesignSolutions.py:768
def strip_non_ki(sd):
return {k: v for k, v in sd.items() if v not in (0.0, False)}
get_reaction_sd/get_gene_sd apply strip_non_ki, so the stripped view shows
only real interventions. But the raw sols.reaction_sd attribute is unstripped — it still carries the
(some_KI, 0.0) markers for every knock-in candidate that a given design did not add. If a user dedups
or compares designs by str(sols.reaction_sd) (as the reporter’s notebook did), two designs that make the
same real interventions but differ in which value-0 KI markers happen to be present stringify
differently, and one can string-contain the other. That manufactures spurious subset/superset pairs that
have no difference in actual interventions — a pure reporting artifact of comparing the unstripped
representation.
The corrective is mechanical: compare designs on the stripped view (get_reaction_sd), i.e. on real
interventions only. Value-0 markers must never enter a minimality comparison. This alone accounts for the
bulk of the reported rate, and is consistent with the earlier compression-correctness fix (§10.3) having
already removed the structural half of the reporter’s original ~50%.
The genuine residual: numerical-boundary non-minimality
A small residual (~2% on the re-run, each superset adding exactly one provably-redundant KO) is real
non-minimality, and it has a different root — numerical tolerance at a growth-coupling boundary, compounded
by the fact that sd.ANY gives no cross-seed global-minimality guarantee.
The mechanism: within a single seed, compute already excludes supersets. After it accepts a design it
adds an exclusion (integer-cut) constraint Σ z_active ≤ |active| − 1 (see Ch 8) that forbids that design
and every superset of it from reappearing in the same run. So true supersets cannot arise within one
seed. They only appear when pooling independent seeds: seed A finds an irreducible cut set C; seed B,
exploring a different branch-and-bound tree, finds C ∪ {r} and accepts it because, at that seed’s
numerical tolerance, dropping r looked infeasible. Whether the extra intervention r is redundant is
decided at a SUPPRESS boundary that sits essentially at zero — the observed growth-coupling min is ≈ 4×10⁻⁷,
far below any biologically meaningful flux but far above the essentiality tolerance of 1e-10 (§10.6).
At that boundary the subspace cost-minimization in compute cannot reliably tell the redundant
intervention from a needed one, so the non-minimal design is accepted as (locally) valid. Independent
validation confirms these are real: both the sub- and superset give identical max biomass and identical
suppress-boundary value, so the extra KO is provably inert.
Distinguishing the two in practice. Recompute each pooled design’s interventions in the stripped view and re-check pairwise containment. If a “superset” collapses to equality under stripping, it was the value-0 artifact; if it survives (the larger design has a strictly larger real-intervention set), it is the genuine numerical-boundary residual, and the fix is a post-hoc cross-solution minimality/dedup pass over the pooled result plus better MILP conditioning at the coupling boundary — not a change to the per-seed search, which is already superset-free.
10.3 Issue #44 (CLOSED 2026-06-23) — PROTECT violated under gene_kos, as a cautionary tale
Symptom. With gene_kos (and, in the reporter’s case, mixed reaction KI/KO), some returned designs,
when re-applied to the original model, dropped biomass below the PROTECT threshold (e.g. BIOMASS ≥ 0.1) — invalid designs presented as valid. Never seen with reaction-only interventions.
Root cause — a compressed-model phantom flux. The bug lived at the coupled-compression step. When
compress_model_coupled (Ch 3) merges a flux-coupled group of reactions into one master column, the
master’s admissible flux must be the intersection of the members’ bounds, translated through the
coupling ratios. The pre-fix code merged the group without intersecting bounds. A group whose members’
bounds actually intersect to [0, 0] — i.e. the coupling forces zero net flux — was nonetheless kept as a
flux-carrying master. That master could then carry a phantom flux that no combination of the original
reactions can realize. A strain design that relied on suppressing (or permitting) that phantom flux was
feasible in compressed space but meaningless on the original model: decompressed, PROTECT could fail
because the biomass route the compressed solver “used” cannot exist.
The fix (commit d6f3d28, post-v1.18; now the standing behavior documented in Ch 3): thread each
reaction’s bounds through the coupled-merge work records, intersect the coupled-group bounds as
(max lᵢ, min uᵢ) after ratio/sign translation, and if the intersection is empty or forced to (0,0),
declare the whole group contradicting and remove master and slaves, re-iterating the compression
fixpoint. Version-proofing confirmed the causal story: pre-fix commit c851df2 produced 7/60 (~12%)
PROTECT-violating designs on the reporter’s setup; current code produces 0 across 800+.
Why this is a cautionary tale, not just history. Two lessons carry forward:
The class of bug — compressed-space validity ≠ original-model validity — is not gene-specific. Any future change to compression (merging rules, new coupling detection, the exact-nullspace work) can reintroduce a compressed model that admits flux the original does not. The gene_kos path merely made it visible, because gene KOs exercise more of the coupled/GPR-extended structure.
The blind spot: the existing tests were cardinality-only.
test_05(mcs_gpr) andtest_08asserted the number of solutions, never that each returned design actually satisfies its PROTECT modules on the original model. A bug that returns the right count of wrong designs sails straight through. The guard that would have caught #44 — and must be added as a standing regression test — is: re-evaluate every returned design against every PROTECT module on the ORIGINAL (uncompressed, un-extended) model, by re-applying the gene/reaction interventions via cobra’s own GPR knockout and solving, and assert feasibility. This is a different assertion class from cardinality, and it is the single test most likely to catch any regression of the whole “compressed phantom flux” family. Note the coupled-merge fixd6f3d28shipped without a targeted unit test for the bound-intersection / contradicting-group logic (its test additions were unrelated), so this coverage gap is still open at both the compression-unit level and the end-to-end validation level.
10.4 Gotcha (a) — compute_strain_designs mutates the caller’s reg_cost/module dicts in place
compute_strain_designs is not free of side effects on its arguments. Two are worth internalizing.
Modules are copied; cost dicts largely are not. The sd_modules list is defensively copied
(compute_strain_designs.py, [m.copy for m in sd_modules]), so the module objects the caller
passed are safe. The cost dicts are not copied — they are aliased:
# lines 225–234
if key == KOCOST: uncmp_ko_cost = value
if key == KICOST: uncmp_ki_cost = value
if key == REGCOST: uncmp_reg_cost = value # <-- the caller's dict, by reference
uncmp_reg_cost is the caller’s reg_cost object. The orchestrator makes a deepcopy for its own
bookkeeping (orig_reg_cost = deepcopy(uncmp_reg_cost)), but it keeps operating on the aliased
original.
extend_model_regulatory rewrites its dict’s keys, and the orchestrator writes that back onto the
caller’s object. extend_model_regulatory (networktools.py) turns each human-readable constraint
string (e.g. '1 PDH + 1 PFL <= 5') into a generated pseudoreaction name (e.g. p1_PDH_p1_PFK_le_5) and
mutates its argument dict in place to use those generated names. The orchestrator then does, for the
immediate (reaction-based) regulatory constraints:
# lines 329–330
uncmp_reg_cost.clear()
uncmp_reg_cost.update(_immediate_reg)
Because uncmp_reg_cost aliases the caller’s reg_cost, this empties the caller’s dict and refills it
with the generated-name keys. After one call, the caller’s reg_cost no longer contains the original
constraint strings — it contains parsed pseudoreaction names. Reusing that same dict object in a second
compute_strain_designs call is corrupt input: the generated names are not parseable constraint strings,
so they misroute (deferred as if gene-regulatory) or raise. The same aliasing means the caller’s
ko_cost is also augmented in place — the regulatory pseudoreactions are added to it via
uncmp_ko_cost.update(...).
Consequence & workaround. Never reuse a reg_cost (or ko_cost) dict across runs; pass a fresh
dict(...)/deepcopy each time, or reconstruct the setup per call. This is entirely internal to the API
surface — there is a code comment acknowledging the in-place mutation, but the fix (copy
the caller’s dict on entry, as is already done for modules) has not been applied.
10.5 Gotcha (b) — Gurobi/GLPK-only name truncation (sha256; CPLEX exempt)
extend_model_gpr can generate very long pseudo-metabolite/pseudoreaction names, especially after
compression lumps many reactions into one (Ch 3/Ch 4): the lumped id is a *-joined concatenation of the
member ids and gene tags, easily exceeding a few hundred characters. To stay within solver name-length
limits, names longer than MAX_NAME_LEN = 230 are hashed:
# networktools.py:1001,1012–1014
MAX_NAME_LEN = 230
def truncate(id):
h = hashlib.sha256(id.encode()).hexdigest()[:20]
return id[0:MAX_NAME_LEN - 21] + "_" + h
The crucial detail is the guard: every truncation site fires only for solver in {GUROBI, GLPK}
. CPLEX is exempt. The consequence is that the same
input model produces different reaction/metabolite identifiers depending on which solver is selected: a
long name is preserved verbatim under CPLEX but replaced by <prefix>_<sha256[:20]> under Gurobi/GLPK.
That changes reaction/metabolite identity in logs and in any downstream lookup keyed by name — which is why
it is #43-adjacent: a name-keyed gene/reaction lookup that works on CPLEX can miss on Gurobi because the
key was hashed out from under it, and the reporter of #43 saw exactly the truncation warning. It also means
solver-to-solver diffs of the extended model are not name-comparable without accounting for truncation.
Ids, being short, never hit MAX_NAME_LEN, so id-keyed workflows are immune — a second reason the #43
signature is “names break, ids work”.
10.6 Gotcha (c) — solver numeric-status robustness (Gurobi 12 NUMERIC; CPLEX 5/6 unscaled-infeasibilities)
Genome-scale MCS MILPs are numerically nasty, and both solvers can return an “I finished but I’m not sure”
status that older straindesign treated as an unhandled case and crashed on. Both are now handled
gracefully.
Why these MILPs hit the numeric statuses. The SUPPRESS blocks are Farkas infeasibility certificates
(Ch 6) whose dual variables are unbounded by nature and are anchored only by a normalization row, and the
z-linking mixes big-M rows with indicator rows (Ch 7). Big-M constants derived from bounding LPs on an
ill-conditioned genome-scale network can span many orders of magnitude (the MILP-conditioning workstream
measured a ~9-order big-M range), giving the LP relaxation a badly scaled constraint matrix. Under such
scaling the simplex/barrier can reach a point it believes optimal or feasible but whose unscaled
residuals exceed tolerance — that is precisely CPLEX status 5/6 (“optimal/best with unscaled
infeasibilities”) and Gurobi status 12 (NUMERIC). These are not logic bugs; they are the expected
failure surface of a large, poorly-conditioned MILP, and they show up specifically on the big reaction-KO
problems (e.g. ko_cost on ~1600 reactions) rather than on small models.
How they are handled now.
Gurobi (
gurobi_interface.py): ongstatus.NUMERIC, the solver retries once withNumericFocus = 3, restoring the previous value afterward. If the retry yields a solution it is accepted asOPTIMAL; if it yields an incumbent under time-limit-like status it is returned asTIME_LIMIT_W_SOL; otherwise it reports no solution (TIME_LIMIT) — never a crash.CPLEX (
cplex_interface.py, andslim_solveat 250–255): status5/6is accepted with a warning and mapped toTIME_LIMIT_W_SOL(the solution is used but flagged), rather than raising.
The philosophy is degrade, don’t crash: a numerically-imperfect incumbent is far more useful to an enumeration loop than an exception that discards the whole run. Note one residual rough edge: the SCIP/GLPK interfaces were flagged as likely to have analogous unhandled-status gaps that have not all been audited. Also relevant to #38: an accepted “unscaled infeasibilities” solution is slightly imprecise, and that imprecision at the growth-coupling boundary is part of why the genuine non-minimal residual exists (§10.2) — the robustness fix trades a crash for occasionally accepting a marginally non-minimal design.
10.7 Other footguns
Hard-coded essentiality tolerance
1e-10. Both essential-reaction FVA passes classify a reaction as essential withnp.min(abs(limits)) > 1e-10 and np.prod(np.sign(limits)) > 0(compute_strain_designs.pyand:465) — the flux range must exclude zero by more than1e-10with a fixed sign. This absolute threshold has no relation to model scaling: a reaction that is biologically essential but whose minimal required flux is below1e-10will be missed (and remain wrongly knockable), while the ~4e-7growth-coupling boundary of §10.2 sits above the threshold and is thus treated as nonzero — feeding the numerical-boundary non-minimality. If you rescale a model or work with unusually small fluxes, this constant is a place to check first.Size-1 MCS extraction is classical-MCS-only and knockable-scoped. Reactions essential for a SUPPRESS module but not for any PROTECT module are pulled out as size-1 MCS and re-injected at decompression (
compute_strain_designs.py), but only whenis_classical_mcsholds and only for reactions still incmp_ko_cost. This is correct, but it means the set of designs the MILP enumerates is not the full set — anything relying on inspecting the raw compressed solutions must account for the re-injected size-1 MCS (Ch 9), or it will under-count.Licensing environment. Gurobi’s license model (node-locked vs. WLS/web) can differ across machines: a node-locked license may validate only on specific hosts, and WLS/web licenses carry overage risk under heavy parallelism (mitigated here by the shared module-level
gp.Env, PR #52). A benchmark or CI job that assumes Gurobi is available everywhere, or that spins up many parallel Gurobi environments, can fail or silently fall back to another solver for reasons that have nothing to do with the algorithm. Because Gurobi is ≈4× faster than CPLEX on the canonical iML1515 run, “the algorithm got slow” and “it silently fell back to CPLEX because Gurobi wasn’t licensed here” look identical from the outside — check which solver was actually selected before trusting a timing.Comparing designs by the raw attribute vs. the accessor. Reiterating the #38 lesson as a general rule:
sols.reaction_sd/sols.gene_sdare unstripped (carry value-0 KI markers);get_reaction_sd/get_gene_sdare stripped. Any dedup, minimality check, or set-containment test must use the stripped accessor, neverstr(sols.reaction_sd). This one habit prevents an entire class of phantom subset/superset reports.
11. Performance, benchmarking & roadmap
This chapter is forward-facing. The rest of StrainDesign Internals explains how the pipeline works; this one is a map for the developer who wants to make it faster without making it wrong. It does three things: (1) pins down where wall-time actually goes at genome scale, with numbers, so that optimization effort lands on real bottlenecks and not folklore; (2) enumerates the performance levers, each grounded in that profile and in the mathematics of the formulation (see Ch 6, Ch 7); and (3) lays out the benchmarking discipline and the roadmap. Throughout, the governing constraint is completeness — a Minimal Cut Set (MCS) computation must never silently drop a valid design (Ch 8, Ch 9), so every speedup is a claim that has to be gated against a known answer.
Two numbers to keep in your head, both measured on the canonical iML1515 gene-MCS run
(SUPPRESS BIOMASS_Ec_iML1515_core_75p37M ≥ 0.001, POPULATE, max_cost=3, gene_kos=True):
CPLEX 1241 s, Gurobi 280 s, both returning the identical 393 MCS. That ≈4.4× solver gap, and the
internal split of those seconds, is the spine of everything below.
11.1 The verified bottleneck profile
All timings here were measured against the real solver APIs (package v1.18, CPLEX 22.1.2 / Gurobi 13.0.1) on the canonical iML1515 393-MCS problem. State them as given; re-measure before trusting anything not on this list.
11.1.1 Where the seconds go (canonical iML1515, CPLEX)
Phase |
What it is |
Time |
|---|---|---|
Prepare/parse |
modules, solver, costs, seed |
~7 s |
COMPRESS #1 |
2712 → 1237 reactions (parallel + coupled, 5 iters) |
3.4 s |
GPR preprocessing |
1516 genes → |
~1 s |
COMPRESS #2 |
after GPR extension, 3448 → 2152 reactions |
4.3 s |
|
whole-model bound-classifying FVA (the ~4300-LP sweep) |
117.4 s |
FVA essential + size-1 MCS |
88 size-1 MCS extracted via SUPPRESS-scoped FVA |
3.5 s |
MILP build |
Farkas dual assembly 2.7 s + |
3.7 s |
Solve (POPULATE) |
pool search → 84 compressed solutions |
1101 s |
Decompress |
|
~1 s |
Total |
1241 s |
Three facts fall straight out of this table, and each one redirects a class of optimization effort:
The two costs that matter at genome scale are the preprocessing FVA (~117 s) and the solve/pool search (~1101 s). Together they are 98% of wall-time. Everything else — parse, both compressions, GPR extension, size-1 MCS extraction, decompression — is single-digit seconds. Optimize the two big phases; leave the rest alone unless it becomes structurally coupled to them.
MILP construction is now cheap (~4 s). This was not always true: before PR #55 the build was ~70 s, dominated by a scalar-loop
prevent_boundary_knockouts(~51 s) and a non-deduplicatedlink_z(~16 s). Vectorizingprevent_boundary_knockoutsand hashing thelink_zbounding-LP dedup collapsed it to ~7 s, byte-identical output, and the exact-nullspace/build refinements since have trimmed it further. The lesson for the next optimizer: the build phase has already been wrung out; do not spend effort shaving milliseconds off matrix assembly. The money is in FVA and the solve.The 117 s FVA is a genuinely preprocessing cost, not a solve cost — it is the whole-model
bound_blocked_or_irrevers_fvacall (see Ch 5, §3.3), roughly2nsingle-reaction LPs with noreaction_listscoping and no extra constraints. That structure is what makes it CPLEX’s per-LP overhead multiplied by ~4300, and it is why it is separately attackable from the pool search.
11.1.2 The CPLEX-vs-Gurobi ≈4.4× gap and its true causes
The same 393-MCS problem runs in CPLEX 1241 s vs Gurobi 279.8 s. Decomposing both runs by phase localizes the entire gap to exactly two places:
Preprocessing FVA: ~117 s on CPLEX. This is CPLEX’s per-LP construction/solve overhead paid ~4300 times over. Gurobi’s per-LP overhead on the same sweep is materially lower. This is a fixed tax per LP, so the fix is architectural (fewer LPs, parallelism, cheaper backend for the sweep — §11.2.5), not a solver-parameter tweak.
Pool search (POPULATE): ~1101 s on CPLEX vs a small fraction of that on Gurobi. CPLEX’s solution-pool enumeration runs ~4–7× slower than Gurobi’s on this MILP. This is the dominant term and the dominant contribution to the 4.4×.
Everything else — the branch-and-bound on the incumbent-finding solves, the MILP build — is at rough parity between the two solvers. So the correct one-sentence statement of the gap is: the CPLEX disadvantage is per-LP preprocessing overhead plus pool-search speed, and nothing else.
Three things the gap is emphatically NOT, each of which cost prior investigation time and is now closed:
NOT the indicator constraints. Under the default
M = inf, SUPPRESS’s Farkas-dual rows become indicator constraints and PROTECT’s finite-flux primal rows become big-M rows — but this is emergent from the bound structure via theself.M/bounding-LP fork inlink_z(strainDesignProblem.py, the finite-vs-infmax_Axtest around line ~853), not a per-module-type switch (Ch 7, §3.2). Both solvers get the same formulation with the same indicators, and both handle those indicators fine. The indicators are not the gap.NOT the pool parameters. CPLEX sets
mip.pool.absgap=0,mip.pool.relgap=0,mip.pool.intensity=4at solver construction (cplex_interface.py), and Gurobi setsPoolGap=PoolGapAbs=1e-9(gurobi_interface.py). These have been dated bygit blameto 2022 (CPLEX lineb87d49c1, 2022-04-18 — not a recent regression) and, more importantly, verified inert for singlesolve: after a feasibility solve atintensity=4,pool.get_num==0, identical tointensity=0. CPLEX does not populate the pool during a plainoptimize; the pool params only bite insidepopulate(POPULATE). They are architecturally misplaced (they belong insidepopulate), but they are not a performance bug for ANY/BEST. Do not re-derive this — it was tested three ways.NOT a big-M conditioning catastrophe. A discredited earlier reading claimed “CPLEX 400 s / indicators catastrophic / use big-M.” That number came from calling
backend.solveon the MILP’s construction objective — a global optimization that no production path ever runs — on a self-made iML1515/1,4-BDO/max_cost=40dump with 2228 indicators and a loose cardinality bound. It is not representative of any real run and has been thrown out. The dead-end to remember: there is no 9.4-order big-M range in the built MILP to fix. As the MILP roadmap verified (§0–§1), the shipped formulation carries only a few dozen big-M rows, all at the loose default ±1000 (e.g. iMLcore: 34 big-M / 388 indicators), because the wide-flux-span reactions all relax to ±inf bounds and become indicators, not tiny big-M’s. Equilibration of a big-M range that does not exist is moot.
The practical upshot: do not chase the solver gap through solver knobs or the indicator/big-M dichotomy. The gap lives in the number of LPs in preprocessing and in pool-search throughput. Fix those structurally.
11.2 The performance levers
The levers below are grouped and ordered to match the profile: compression (cuts the problem before it is built), formulation/conditioning (shapes the MILP the solver sees), skipping hopeless work, the Farkas-dual pre-bounding problem, the preprocessing FVA, and the enumeration strategy. This list reflects informed intuition, not a ranked plan — argue with it, and measure before committing effort. Phil’s standing prior: the biggest suspected structural win is a better MILP formulation/conditioning (group 2), solver parameters (group 4-adjacent) are a fragile secondary bet, and the “good compression ≈ MCS2” insight (group 1) is a hypothesis to verify, not a foundation to build on.
11.2.1 Compression depth = rank / z-count reduction (the structural lever)
The binary variable count num_z = numr — one z per compressed reaction (strainDesignProblem.py
__init__, num_z set around line ~144) — is the dominant complexity driver of the MILP. Branch and
bound over z is combinatorial; halving numr is worth far more than any constant-factor solver tune.
Network compression (Ch 3) is the mechanism that reduces numr losslessly and exactly, and it is
therefore the single largest structural lever available.
The reasoning is that compression is a rank/dimension reduction of the flux system done for free:
parallel merge, coupled/flux-coupled merge, conservation-relation (row) removal, and blocked/zero-flux
removal each shrink S while preserving the exact set of steady-state flux distributions (the exact
integer/rational nullspace guarantees this — never float; see Ch 3 and the hard constraint). Every
reaction removed is a z never created, an LP row never linked, a branch never taken. On the canonical
run, COMPRESS #1 takes 2712 → 1237 and COMPRESS #2 takes 3448 → 2152 (after GPR extension inflates the
count); pushing either merge closer to a true fixpoint directly removes binaries.
Concrete sub-levers, in decreasing certainty:
Scaled-parallel merging (shipped, PR #54): merge reactions whose stoichiometry is identical up to any rational scalar and that share reversibility/bound topology. This is strictly more merging than exact-equality parallel detection, and it is exact (the merge factor is a flux-split share).
Push the coupled+parallel alternation to a genuine fixpoint. The compression loop alternates parallel-merge → conservation-removal → coupled-merge until a step stops reducing (Ch 3). Confirming we reach maximal exact reduction — that no additional pass would remove one more reaction — is the cleanest way to guarantee the
z-count is minimal for a given model.Order interactions between blocked/dead-end removal, conservation-relation removal, and coupling: removing dead ends first can expose new couplings and vice versa; the order the fixpoint visits them affects how quickly it converges and, at the margin, what it finds.
The deeper claim attached to this lever is the “good compression ≈ MCS2” hypothesis (Phil). MCS2 (doi:10.1093/bioinformatics/btz393) computes minimal coordinated supports over the nullspace; its structural benefit is essentially working in a full-rank coordinate system. The hypothesis is that a sufficiently good compression already reduces the MILP to (near) full rank, producing a problem almost identical to MCS2’s — so maximizing exact compression captures most of the MCS2 advantage without importing MCS2’s method. Two pieces of evidence bear on it: a standalone MCS2-style nullspace approach was tried and gave no speedup (solid compression already captured the structural benefit), and the exact-nullspace PR #60 lifted compression ~1.6× and made yeast-GEM compress at all. But this remains a hypothesis, not a fact, and the way to settle it is stated in §11.3: complete-enumerate (ALL, not BEST/ANY) reaction MCS up to ~6 KOs on a couple of genome-scale models and compare head-to-head with MCS2. If the hypothesis holds, compression depth is the whole game for competitiveness and the MILP-formulation work is secondary; if it fails, the reverse.
11.2.2 MILP formulation & conditioning
Compression decides how many binaries; formulation decides how hard the solver’s job is per binary.
The relevant machinery is link_z (Ch 7), which wires each binary z to the continuous rows either as
a native indicator constraint or as a big-M row, choosing per-row on the sign of a bounding-LP maximum
max_Ax (finite ⇒ big-M with that constant; inf ⇒ indicator). The levers:
Prefer native indicators; use big-M only where forced. Gurobi, CPLEX, and SCIP all support native indicator constraints; only GLPK forces everything to big-M (its
self.Mis a finite cobra bound). A loose big-M gives a weak LP relaxation, and a weak relaxation hurts CPLEX more than Gurobi. The shipped formulation already leans indicator-heavy by construction (536 indicators on the canonical run), which is why the indicator/big-M split was ruled out as the cause of the solver gap (§11.1.2). But the audit is still worth doing on new model classes: verify we never hand CPLEX a structurally weaker formulation than Gurobi on the same problem.Tighten every big-M to its smallest valid bound.
link_zalready computes a per-rowmax_Ax= max of the constraint over the LP-relaxed feasible region, which is the tightest valid M given the bounds (an LP-tight, not MILP-tight, heuristic — the true MILP-tight max-min is as hard as SUPPRESS itself). The gap here: the few dozen functional big-M rows that survive are written at the loose default ±1000, not at their tighter FVA maxima (MILP roadmap §0: iMLcore = 34 big-M all ≈1000). Tightening those 34 from 1000 to their FVA-computed maxima strengthens the relaxation. The honest caveat is that 34 ≪ 388 indicators, so the impact is likely small and must be measured across models before it earns effort.Cut the
zcount at the formulation boundary, not just in compression. Beyond compression (§11.2.1), drop structurally-non-knockable reactions and essential reactions before they becomezvariables: FVA #1 removes reactions essential to a desired/PROTECT module from the knockable set, and FVA #3 pulls size-1 MCS out entirely (re-injected at decompression so the MILP never enumerates their supersets; Ch 5, Ch 9). Every reaction kept out ofcmp_ko_costis one fewer binary.The trace-cofactor ill-conditioning and the 9.4-order big-M range — a note, now largely closed. The MILP roadmap initially diagnosed a chain: stoichiometry spanning 7.6 orders of magnitude → FVA flux spans of 9.4 orders → tiny big-M’s from trace-cofactor pathways (biotin flux ~1e-6, etc.). Following the actual pipeline showed that chain does not exist in the built MILP: the tiny-flux reactions relax to ±inf bounds and become indicators, never tiny big-M’s, so there is no 9.4-order big-M range to condition (§11.1.2). Exact row+col equilibration of the stoichiometry (7.6 → ~3.8–4.0 orders, exact via
D·N·v=0 ⟺ N·v=0) remains a possible lever on the primal/dual matrix conditioning that the SUPPRESS-indicator path sees — but whether stoich conditioning of 4.0 vs 7.6 orders changes the indicator solve at all is unproven and is the correct experiment to run, not an assumption. Combined stoich + big-M equilibration is a genuine conflict (s_j·M_jspans ~9.7 orders; one column scaling can fix stoich·α or big-M/α but not both whens·M ≉ 1), so it is off the table for the big-M range and only live for the (separate, unproven) stoich angle.
11.2.3 Skip hopeless big-M / dual work
The cheapest work is work not done. When a knockable constraint’s reaction is provably always-zero, or its bound provably never binds, the entire big-M/indicator machinery for that row can be skipped rather than computed and added. Two concrete pieces:
The
link_zsparse short-circuit (onhpc_benchmark): before running the bounding LP, inspect the row’s nonzero count.nnz==0⇒M=0directly;nnz==1(a plain reaction KO) ⇒ M is justcoeff·bound(∞ if that bound is ∞) — no LP needed, because a single-variable row’s maximum over a box is read straight off the bound. Onlynnz≥2rows (module/dual constraints) go to an actual LP (parallelized viaSDPoolabove ~1000 rows). This is what makes the build cheap; promote it and keep it. The corollary lever, from MILP roadmap §0, is thatmax_Axfor single-var KOs is redundant — it reproduces the boundbound_blocked_or_irrevers_fvajust set — so the LP pool can be restricted to multi-variable rows with no behavior change and a measurable preprocessing saving.Substituting out or removing binaries after a target is found is the uncertain end of this lever. Once a synthetic-lethal single (
DBTS) or a specific double (AOXSr2, DBTS) is identified, it is unclear whether anything beyond removing the binary variable helps — branch-and-bound may already prune those paths. This is problem-structure-dependent and may require a MILP rebuild; treat wins here as speculative until measured.
11.2.4 The Farkas-dual pre-bounding problem (the known hard lever)
This is the deepest formulation lever and the one with the most headroom, because it is the one the current architecture cannot address with its existing tools.
The asymmetry: PROTECT modules embed the raw primal (the desired flux state must stay feasible), so
their reaction variables carry finite flux bounds that FVA can pre-bound and tighten. SUPPRESS
modules instead build a Farkas infeasibility certificate: farkas_dualize (strainDesignProblem.py
~1141) dualizes the primal with a zero objective and appends the normalization row c_d·y ≤ −1
(verified: A_ineq_f = vstack(A_ineq_d, c_d), b_ineq_f = b_ineq_d + [-1]), which encodes “the
undesired flux state is infeasible after the knockouts” (Ch 6). The knockouts act on dual variables,
and those duals are unbounded by nature — one-sided [0,∞) for inequality duals or free for
equality duals — pinned only by the ≤ −1 anchor. There is no finite flux bound to read off, so
FVA pre-bounding does not help the SUPPRESS rows at all. This is why they fall to inf max_Ax
and become indicators (§11.1.2): not a design choice, a mathematical fact about Farkas rays.
Because SUPPRESS is the “cannot” half of every classical MCS problem, this is not a corner case — it is the core. Three redesign options, in increasing ambition, each a different exact encoding of the same problem (Ch 6 owns the dual math; these are pointers for the optimizer):
Split the compressed network into forward/reverse before Farkas construction. Constructing the certificate over a sign-definite (fwd/rev-split) network changes which dual components are free vs one-sided and can expose bounds that the un-split formulation hides. This is the lowest-risk of the three because it operates on the network before dualization.
Slack variables tied to global binaries. Replace the pure dual-ray encoding with slacks that are directly linked to the intervention binaries, so the “infeasibility after KO” condition is carried by bounded slacks rather than unbounded duals — giving FVA something finite to bound.
Branch on the indicator constraints directly rather than routing through the dual ray at all.
A related, concrete M-dimensioning idea for the Farkas certificate (MILP roadmap R2, untested): run FVA
at all combinatorial cases of the few inhomogeneous bounds (PROTECT biomass, glucose uptake, ATPM),
take the smallest nonzero flux a reaction can carry, and use 1/v_min as that reaction’s M in the
certificate (or 1000 if every case gives 0). This would give tight-but-valid Farkas M’s for the trace
reactions without the exponential max-min — but it must be prototyped and checked for completeness
(no missed solutions) before it is trusted.
11.2.5 The whole-model preprocessing FVA
bound_blocked_or_irrevers_fva (Ch 5, networktools.py) is ~117 s and the entire preprocessing
bottleneck. It runs one whole-model FVA — passing no reaction_list and no extra constraints, so it
does the full 2n objectives — and then classifies each reaction’s bounds: redundant bound (FVA never
reaches it) → ±inf; min≥0 → irreversible-forward (lb=0); max≤0 → blocked/reverse (ub=0); and it
mutates _lower_bound/_upper_bound in place. It needs every bound to do the classification, so it
genuinely cannot be scoped to knockable reactions only. The levers are therefore about the cost of the
sweep, not its scope:
Parallelize the Phase-2 residual.
speedy_fva(Ch 5,speedy_fva.py) already avoids most of the2nLPs via av=0-feasibility pass, amin Σ|x|scan, and iterative warm-started push-to-bounds, falling to individual LPs only for the residual reactions Phase-1 did not resolve. The likely win: on this whole-model call Phase-1 resolves so much that the Phase-2 residual drops below the ~1000-LP parallelization threshold and runs serially — so it pays CPLEX’s per-LP tax one reaction at a time. Forcing the residual to parallelize (or lowering the threshold for this call) directly attacks the 117 s.A cheaper backend for the LP sweep. The 117 s is dominated by CPLEX’s ~2 s/LP construction overhead × ~4300 LPs. Nothing about a bound-classification FVA needs CPLEX specifically; running the sweep on a lighter LP backend (or
slim_fba/slim_solve-style reduced solves) sidesteps the per-LP tax that is the whole cost.Amortize across seeds.
dump_preprocessed+compute_strain_designs_from_preprocessed(shipped) lets one preprocessing run feed many seeded solves — essential for the multi-seed benchmarking below, since it turns a per-seed 117 s tax into a one-time cost.FVA relocation (on
hpc_benchmark): moving/reordering the FVA relative to COMPRESS #2 and snapshottingpre_fva_boundsis prototyped; its real speedup must be measured rigorously head-to-head, not assumed.
11.2.6 Enumeration & pooling strategy
The ~1101 s pool search is the largest single term, and it is the one place where the enumeration
strategy (as opposed to the formulation) is the lever. The solve loop rebuilds and re-solves,
excluding each found design with add_exclusion_constraints (integer cuts that exclude a design and
its supersets; Ch 8). Levers:
Integer cuts as lazy constraints. Adding the exclusion constraints as solver-native lazy constraints, and reusing the branch-and-bound tree / basis across iterations, avoids rebuilding the model for every solution found. This is the natural fit for the iterative enumerate loop and is where a warm-started, incremental architecture would pay off most against the 1101 s.
Warm starts. Reuse the previous solve’s basis and incumbent when adding the next cut, rather than cold-starting each populate iteration.
A cross-solution minimality/dedup pass on pooled
sd.ANYresults — removes the residual ~2% non-minimal supersets (issue #38) that arise from value-0 KI markers and from pooling many seeds, and is cheap relative to the search itself.
Solver-parameter tuning of the pool (CPLEX emphasis/numeric-emphasis, indicator-API usage) is a fragile bet and belongs strictly after the formulation is confirmed identical across solvers: leaning on parameter defaults makes the package vulnerable to solver-version updates that change those defaults or add better internal routes. Confirm the formulation first, tune params only to confirm a hypothesis, never to carry one.
11.3 Benchmarking discipline
Speed claims about a branch-and-bound MILP are worthless without discipline, because B&B is chaotic in ways that a naive timing hides. Four rules.
Multi-seed distributions — single-seed timing is meaningless. The seed is fully plumbed
(compute_strain_designs(seed=) → kwargs_milp[SEED] → the backend constructor → CPLEX
parameters.randomseed / Gurobi Params.Seed). The B&B tree shape is seed-dependent: the order in
which the solver branches, and therefore how quickly it finds and proves solutions, changes with the
seed. A single-seed run is one sample from a wide distribution, and comparing two configurations on one
seed each can invert the true ordering. Every speed comparison — ANY, BEST, and POPULATE alike — needs
≥5 seeds and is reported as a distribution (median + spread), never a single point. This is why the
dump_preprocessed amortization (§11.2.5) matters operationally: it makes a 5-seed sweep affordable by
paying the 117 s preprocessing once.
Known-answer gates — completeness is the gate, not a nicety. Two canonical counts are the regression oracle: e_coli_core = 455 MCS (CPLEX ~1.2 s) and iML1515 = 393 gene-MCS (the canonical run above). No MIP optimality gap is ever set, so both solvers run at their default 1e-4 relative gap, which for integer intervention-cost objectives is effectively exact. Any change to bounds, big-M values, Farkas M-dimensioning, compression depth, or enumeration strategy must reproduce these counts exactly. A speedup that returns 392 MCS is not a speedup; it is a correctness regression. The non-negotiable phrasing from the MILP roadmap: any M/bound change must not drop a valid MCS, and every experiment must re-verify the known-answer counts. The test class that enforces this — re-evaluating every returned design against all PROTECT modules on the original model — is precisely the gate that would catch a completeness regression (and would have caught the historical #44).
Head-to-head against the real competitors, on both solvers. The target is competitiveness with
MCS2 (doi:10.1093/bioinformatics/btz393, code at github.com/RezaMash/MCS) and gMCSpy
(doi:10.1093/bioinformatics/btae318, code + benchmark at github.com/PlanesLab/gMCSpy), measured on
both Gurobi and CPLEX — because the whole point of the Direction-A work is that Gurobi is currently
much faster than CPLEX on the same straindesign problem, and a fair comparison must not hide behind one
solver. The benchmark set is iML1515 / Yeast-GEM 8.7 / Human-GEM 1.16. The harness lives locally on the
hpc_benchmark branch (gitignored), with benchmarks/tools/MCS2/ reconstructed and its MEX
Octave-recompiled. A caution learned the hard way: prior bound-config experiments (the P-A/B/C, F-A–E
configs in bench_bound_configs.py) produced almost no actual MILP change and insignificant perf
differences — the amount of real headroom is unknown, so measure before committing effort, and do
not mine old JSON in place of a fresh, correctly-distinct experiment.
Never drop a valid MCS. Restated because it is the one rule that overrides all others: completeness is not traded for speed. The complete-enumeration (ALL, not BEST/ANY) runs up to ~6 KOs that would settle the “good compression ≈ MCS2” hypothesis (§11.2.1) are themselves the strongest completeness test, because they force the machinery to produce every MCS in a size band and expose any silent drop.
11.4 Roadmap & directions
Direction A — compute performance & MCS2/gMCSpy competitiveness (the live thrust). This is the
active work. Shipped so far: MILP build cut ~70 s → ~7 s (PR #55) and the CPLEX-populate configuration
win. The measured gap stands at CPLEX 1241 s vs Gurobi 280 s ≈ 4.4× on the canonical
iML1515 393, split into preprocessing FVA ~117 s and pool search ~1101 s — so the two real levers are
the whole-model bound FVA (§11.2.5) and the pool-enumeration strategy (§11.2.6), not indicators and
not the pool params (both verified inert). The near-term milestones are: (1) MCS2/gMCSpy
head-to-heads on iML1515 / Yeast-GEM 8.7 / Human-GEM 1.16; (2) push compression depth to a true fixpoint
(§11.2.1) and settle the “good compression ≈ MCS2” hypothesis by complete enumeration; (3) redesign the
Farkas-dual pre-bounding (§11.2.4); (4) clean up the solver-agnostic internal_other remnant. Hexaly is
an optional extra backend target.
The exact-nullspace compression thread. The exactness constraint is upstream and settled: the
nullspace/compression stays integer/rational (never float — small numeric deviations introduce
irreparable compression errors), and PR #60 folded the exact integer/rational sparse nullspace into
compression.py as public straindesign.nullspace/sparse_nullspace, delivering ~1.6× compression on
iML1515/Human-GEM and making yeast-GEM compress at all (it previously crashed on scipy’s int64
ceiling; the fix routes >64-bit coefficients through a dict-of-Fractions mode + ExactCOO). This is the
shared building block under the compression-depth lever: better exact compression is more z-count
reduction, which §11.2.1 argues is the largest structural win.
Adjacent efforts (pointers only). Two prototypes share the exact-nullspace core but are not part of
the straindesign performance work: SENUS (VonAlphaBisZulu/SENUS) is the standalone exact
integer/rational sparse nullspace lifted out of compression.py — a longer-shot Direction-B play whose
next speedup is a Bareiss fraction-free elimination to bound coefficient growth; and Kimonu
(VonAlphaBisZulu/Kimonu.py) is an independent kinetic-module (COCOA-style) analyzer that reuses the
same nullspace core but is not a straindesign component. Both are mentioned here only so a reader tracing
the nullspace code across repos knows where it went; neither is on the straindesign performance critical
path.
12. Model surgery & constraint parsing
Every module in this codebase eventually reduces the user’s intent to rows of a matrix: a stoichiometry
S, a stack of inequality rows A_ineq·x ≤ b_ineq, a stack of equality rows A_eq·x = b_eq, and cost
dicts that say which columns are knockable. Between the user’s Python call and that matrix sits a thin
utility layer — a handful of functions in networktools.py, one edit routine in compression.py,
and the whole of parse_constr.py — that edits the model in place and turns human-written strings
into sparse rows. This chapter documents that layer.
None of it is the mathematical heart of strain design (that is dualization, Ch 6, and MILP assembly,
Ch 7). It is the glue: the code that makes the model clean enough to compress (Ch 3), that encodes a
regulatory bound as extra stoichiometry, that translates a gene knockout into a flux constraint, that
keeps modules and cost vectors consistent with the ever-shifting compressed reaction index, and that
lets a user write "2 r1 - r2 <= 5" instead of hand-assembling a scipy.sparse row. Glue is where
off-by-one bugs, index drift, and in-place-mutation footguns live, so it is worth the same care as the
core.
The functions appear in compute_strain_designs (the orchestrator, compute_strain_designs.py) in a
specific order, and the ordering is load-bearing. The map for this chapter, keyed to the preprocessing
block (compute_strain_designs.py):
step |
function |
file:line |
when |
|---|---|---|---|
clean the model |
|
|
first, before any compression |
parse a constraint string |
|
|
wherever a string enters |
encode a regulatory bound |
|
|
reaction-based now; gene-based after GPR |
gene KO → flux constraint |
|
|
in |
remap modules to compressed space |
|
|
after each |
remap costs to compressed space |
|
|
after each |
We take them roughly in pipeline order, but front-load the constraint parser because everything else consumes its output.
12.1 remove_ext_mets — deleting the boundary layer before compression
remove_ext_mets(model) (compression.py) is three statements:
def remove_ext_mets(model) -> None:
external_mets = [m for m in model.metabolites if m.compartment == 'External_Species']
model.remove_metabolites(external_mets)
stoich_mat = create_stoichiometric_matrix(model)
obsolete_reacs = [r for r, has_nonzero in zip(model.reactions, np.any(stoich_mat, 0)) if not has_nonzero]
model.remove_reactions(obsolete_reacs)
What an “external metabolite” is. In constraint-based models the network’s interface with its
surroundings is drawn one of two ways. Either the boundary is an exchange reaction — a reaction with
a single metabolite and one open bound, e.g. glc__D_e --> with -10 ≤ v ≤ 1000, representing “glucose
may leave/enter the system” — or the boundary metabolite is placed in a dedicated external
compartment and given its own balance row in S. The two conventions are not interchangeable. In the
exchange-reaction convention, the extracellular species glc__D_e still has a steady-state balance
Σ Sᵢⱼ vⱼ = 0 like any internal metabolite, and the exchange reaction is what closes that balance. In
the external-compartment convention, some model authors additionally add a species row for the
truly-external pool (compartment tag External_Species here) whose only purpose is bookkeeping — it is
not a mass-balanced internal pool, it is the “outside world.”
Why they must go before compression. Compression (Ch 3) rests on two exact linear-algebra facts about
the stoichiometric matrix S ∈ ℝ^{m×n}:
Conservation-relation (row) removal deletes metabolite rows that are linearly dependent — a left nullspace vector
yᵀS = 0means that combination of metabolites is conserved and its row is redundant.Coupled/parallel merging looks at the right nullspace of
Sto find reactions whose fluxes are forced proportional in every steady state.
An External_Species row is a fake balance. It is not a real conservation law of the metabolic
network; it is an artifact of how the author drew the boundary. Left in place, it does two damaging
things. First, it manufactures spurious conservation relations: the external pool row plus the
internal pool rows of the same metabolite are linearly dependent by construction, so the row-removal step
either wastes work eliminating a redundancy that is not chemistry, or (worse) the extra row perturbs the
rank count that governs how many conservation relations exist. Second, it manufactures spurious
exchange structure: a species that participates in exactly one reaction (a dangling boundary node)
creates a degenerate column/row pattern that the coupling analysis can misread as a forced flux
relationship. Removing the External_Species rows first means the nullspace math sees only genuine
internal mass balances, so every conservation relation it removes and every coupling it finds is real.
The obsolete-reaction sweep. Deleting the external metabolites can strand reactions: an exchange
reaction whose only metabolite was the external species now has an all-zero column in S. Line 1712
recomputes S and drops any reaction whose column is entirely zero (np.any(stoich_mat, 0) is the
per-column “has a nonzero” test; has_nonzero == False marks a now-empty reaction). These are reactions
that produce/consume nothing after the boundary layer is gone; keeping them would leave free variables
with no stoichiometric effect — pure noise for both FVA and the MILP. The order matters: metabolites
first, then recompute S, then reactions — you cannot know a column is empty until the rows are gone.
This runs exactly once, at compute_strain_designs.py, on the working copy cmp_model, immediately
before regulatory extension and COMPRESS #1. It is deliberately the very first surgery: it is the only
step that changes what “a genuine conservation relation” means, so it must precede everything that
reasons about the nullspace.
12.2 parse_constr.py — strings into A·x {≤,=,≥} b
This module is the input→matrix surface every other part of the package sits on. A user (and several
internal callers, including extend_model_regulatory below) may express a linear constraint as an
ordinary string, "2 r1 - r2 <= 5". parse_constr.py turns that into the sparse rows the LP/MILP layer
consumes. There are two output shapes and the module offers both:
the list format
[{r1: 2.0, r2: -1.0}, "<=", 5.0]— a dict of coefficients, a sign token, a float right-hand side — produced byparse_constraints/lineq2list. Modules store constraints in this format (it survives compression remapping, §12.5, cleanly because it is keyed by reaction id, not by column index).the matrix format
A_ineq, b_ineq, A_eq, b_eq— the actual sparse rows — produced bylineq2mat(string → matrix directly) orlineqlist2mat/linexprdict2mat(list format → matrix).
12.2.1 The scanner: linexpr2dict / linexpr2mat
The atom of parsing is a single linear expression (a left-hand side, no sign, no rhs), handled by
linexpr2dict (parse_constr.py) and its twin linexpr2mat (parse_constr.py). They differ
only in output — a dict vs. a one-row csr_matrix — and run the identical scan. Take
expr = "2 R3 - R1", reaction_ids = ["R1","R2","R3","R4"].
Tokenize and strip. Split on whitespace, then strip leading/trailing sign, space, and parenthesis characters from each token (
re.sub(r"^(\s|-|\+|\*|(\s|-|\+|\))*$", "", part))."2 R3 - R1"→ tokens["2", "R3", "R1"](the lone-is stripped away; its sign information is recovered later from the raw string, not from this token list).Identify variables.
ridx = [r for r in expr_parts if r in reaction_ids]keeps only tokens that are known reaction ids →["R3", "R1"]. Membership is by exact string equality against the model’s reaction id list, which is why reaction ids must be passed in and why digit-leading gene names are renamed upstream (Ch 1) — a bare number token would be misread as a coefficient.Validate syntax. Three rules, each raising a descriptive
Exception:no two numeric tokens in a row (
last_was_numberguard) —"2 3 R1"is rejected;no leftover token that is neither a number nor a known reaction id —
"2 Rx"with unknownRxraisesUnknown identifier Rx;no reaction id may appear twice (
len(ridx) == len(set(ridx))) —"R1 + R1"is rejected, because a single sparse cell cannot hold two independent coefficients.
Extract each coefficient. For every reaction id
rid, a regex captures the run of sign/digit/dot characters immediately preceding that id in the raw string:coeff = re.search(r"(\s|^)(\s|\d|-|\+|\.)*?(?=" + re.escape(rid) + r"(\s|$))", expr)[0] coeff = re.sub(r"\s", "", coeff) # drop spaces → "" or "+" or "-" or "2" or "-3.5" if coeff in ["", "+"]: coeff = 1.0 elif coeff == "-": coeff = -1.0 else: coeff = float(coeff)
The lookahead
(?=…rid(\s|$))anchors the capture to the id as a whole token (followed by space or end), soR1does not accidentally match insideR12. An empty or"+"prefix means an implicit+1; a lone"-"means-1; anything else is parsed as a float. For our example:R3is preceded by"2 "→2.0;R1is preceded by" - "→"-"→-1.0. Result:{"R1": -1.0, "R3": 2.0}(dict form) or the row[-1, 0, 2, 0](matrix form).A minor implementation wart:
linexpr2matwrites the same logic with a plainif … if … else(lines 294–299) rather thanlinexpr2dict’sif … elif … else. The""/"+"branch setscoeff = 1.0first, and the subsequentif coeff == "-"is then false so theelserunsfloat(1.0). It produces the identical result, but the two copies of the coefficient logic are a maintenance hazard — a future fix to one can silently miss the other.
12.2.2 The (in)equality splitters: lineq2mat, lineq2list
A full (in)equality adds a sign and a right-hand side. lineq2mat (parse_constr.py) is the
one-shot string→matrix path:
lhs, rhs = re.split(r"<=|=|>=", equation)
eq_sign = re.search(r"<=|>=|=", equation)[0]
rhs = float(rhs)
A = linexpr2mat(lhs, reaction_ids)
The split isolates the left expression (scanned by linexpr2mat) from the rhs (which must parse as a
single float — the except clause rejects anything else with “Right hand side must be a
float number”). Then the sign decides which matrix the row joins, and here is the one genuine piece of
math in the parser — canonicalizing every inequality to ≤:
=→ appendAtoA_eq,rhstob_eq.<=→ appendAtoA_ineq,rhstob_ineq.>=→ append-AtoA_ineq,-rhstob_ineq.
The ≥ case uses the elementary equivalence a·x ≥ b ⇔ -a·x ≤ -b: negate both sides of the row.
Worked example, the docstring’s own case (lineq2mat docstring):
equations = ["2*c - b +3*a <= 2", "c - b = 0", "2*b -a >=-2"]
reaction_ids = ["a","b","c"]
"2*c - b + 3*a <= 2"→ row[3, -1, 2](ordered byreaction_ids), sign<=, rhs2→ intoA_ineq/b_ineq."c - b = 0"→ row[0, -1, 1], sign=, rhs0→ intoA_eq/b_eq."2*b - a >= -2"→ row[-1, 2, 0]; because the sign is>=, it is stored negated as[1, -2, 0]with rhs+2→ intoA_ineq/b_ineq.
Final:
A_ineq = [[ 3, -1, 2], b_ineq = [ 2,
[ 1, -2, 0]] 2]
A_eq = [[ 0, -1, 1]] b_eq = [ 0]
Note the sign is * -optional: "2*c" and "2 c" both work because linexpr2mat’s tokenizer strips
* implicitly (it is neither a variable nor a number token and is not part of the coefficient run — the
regex character class does not include *, so "2*c" tokenizes with the * swallowed by the split on
whitespace only if written 2 * c; written 2*c the whole token is "2*c", which is not a reaction
id and not a pure-number token, so it would raise Unknown identifier). In practice callers use a
space or the tokenizer path that tolerates it; the safe, always-correct spelling is spaces:
"2 c - b + 3 a <= 2". This is an easy place to trip, so upstream code that generates constraint strings
(including extend_model_regulatory, §12.3) writes coefficients with explicit spaces.¹
lineq2list (parse_constr.py) is the same split but emits list format
(linexpr2dict(lhs), eq_sign, rhs) instead of matrix rows, and — unlike lineq2mat — it does not
negate ≥ rows (the sign token is preserved verbatim), because list format records the relation
symbolically for later. Skipping empty strings lets it tolerate trailing commas/newlines in a
multi-constraint string.
12.2.3 The dispatcher: parse_constraints
parse_constraints (parse_constr.py) is the public front door that normalizes the many shapes a
user might pass into one uniform list-of-lists. It handles: falsy input → []; a single string
possibly holding several constraints separated by , or \n (split); a single constraint vs.
a list of constraints (the type(constr[0]) is dict test distinguishes a lone list-format constraint
from a list of them); tuples coerced to lists; and finally, if the entries are still
strings, delegating to lineq2list to scan them. The result is always the list format
[[{…}, sign, rhs], …]. This is what modules carry, and it is the input to the compression remapping in
§12.5. parse_linexpr is the sign-less analogue for bare expressions (objectives, production
ids).
12.3 extend_model_regulatory — a bound as an intervention
A regulatory constraint is an inequality (or equality) on flux that is not part of the base
stoichiometric model — for example “the combined flux through PDH and PFL must not exceed 5,”
1 PDH + 1 PFL <= 5, or “oxygen uptake is limited,” -EX_o2_e <= 2. Two distinct uses:
Permanent: the constraint always holds. Just an extra row of the flux polytope.
Toggleable / a regulatory intervention: the constraint is itself something the algorithm may choose to impose at a cost. In strain design, this models a regulatory edit (“engineer the cell so that PDH+PFL ≤ 5”) on the same footing as a reaction knockout: it has a binary decision and a cost, and the MILP decides whether to buy it.
extend_model_regulatory(model, reg_itv) (networktools.py) encodes either kind as extra
stoichiometry, so that the downstream LP/MILP machinery — which only understands Sv = 0 plus bounds —
enforces it without any new constraint type.
12.3.1 The encoding math
Take the toggleable constraint 2 r1 + 3 r2 ≤ 4. The routine adds one pseudometabolite m and up to two
pseudoreactions:
For each reaction
rᵢin the constraint, giverᵢa stoichiometric coefficientwᵢform(sor1now produces 2m,r2produces 3m) —r.add_metabolites({m: w}).Add a bound reaction
r_bnd:m -->(consumesm), with bounds chosen from the sign (lines 1288–1297). For≤ rhs:-inf ≤ v_bnd ≤ rhs.For the toggleable case only, add a control reaction
r_ctl:--> m(producesm), fully unbounded-inf ≤ v_ctl ≤ inf.
The steady-state balance of the new pseudometabolite m is the whole trick. With all pieces present:
dm/dt = 2·v_r1 + 3·v_r2 − v_bnd + v_ctl = 0
⇒ v_bnd = 2·v_r1 + 3·v_r2 + v_ctl
Now read off the two regimes:
r_ctlactive (freev_ctl).v_bnd = 2v_r1 + 3v_r2 + v_ctlwithv_ctlfree meansv_bndcan be slid to any value regardless of the flux sum, so the boundv_bnd ≤ 4never actually constrains2v_r1 + 3v_r2. The regulatory constraint is off (non-binding, virtually absent).r_ctlknocked out (v_ctl = 0). Thenv_bnd = 2v_r1 + 3v_r2, and the boundv_bnd ≤ 4becomes exactly2v_r1 + 3v_r2 ≤ 4. The regulatory constraint is on.
So knocking out r_ctl = imposing the regulatory intervention. That inversion is deliberate: it lets
the identical KO machinery (binary z, integer cuts, cost accounting) drive regulatory edits with no
special case — the reaction r_ctl is simply added to ko_cost with the user’s cost v (
regcost.update({reg_name: v}}), and the orchestrator folds regcost into cmp_ko_cost
(compute_strain_designs.py for reaction-based). For a permanent constraint (cost np.nan),
r_ctl is simply omitted (the if not np.isnan(v) guard): with no control reaction there is
no +v_ctl term, v_bnd = 2v_r1 + 3v_r2 always, and the bound holds unconditionally.
The equality and ≥ cases set r_bnd’s bounds accordingly: = pins
v_bnd to rhs (the code sets lower and upper to rhs; note set upper then lower —
the intermediate -inf upper is immediately overwritten, so the net effect is v_bnd = rhs), and ≥
uses rhs ≤ v_bnd ≤ inf. The m --> / --> m directions never change; only the r_bnd bounds carry
the relation.
This is the same “encode a linear relation as pseudometabolite balance” idea that GPR integration uses to turn Boolean gene rules into flux structure (Ch 4) — here applied to a single user inequality rather than an AND/OR tree.
12.3.2 Name generation and the in-place dict mutation (footgun)
The generated reaction id for r_ctl (and the cost-dict key) is built from the parsed constraint (lines
1260–1273): each term contributes p/n (sign of coefficient) + the coefficient + _ + reaction id +
_; then le_/ge_/eq_ for the relation; then the rhs with -→n and .→p. So
2 r1 + 3 r2 <= 4 becomes something like p2.0_r1_p3.0_r2_le_4. These names are what appear in the
returned regcost dict and, after decompression, in the reported solution.
The sharp edge is that extend_model_regulatory mutates its reg_itv argument in place (lines
1274–1275):
reg_itv.pop(k) # remove the original string key
reg_itv.update({reg_name: {'str': k, 'cost': v}}) # replace with the generated name
It walks a .copy of the items but pops from and writes to the original dict. On return,
the caller’s dict no longer has the human-readable keys the caller passed in — they have been rewritten
to generated names, with the original string demoted to a 'str' field inside the value. Because the
orchestrator’s uncmp_reg_cost aliases the caller’s reg_cost (it is bound by reference, then
.clear/.update-ed at compute_strain_designs.py), a single compute_strain_designs
call silently empties and refills the caller’s reg_cost dict. Re-running with the same dict object then
mis-parses (the keys are now generated names, not constraint strings). The fix is to never reuse a
reg_cost dict across calls — pass a fresh one. This is catalogued as a known footgun in Ch 10; the
mechanism is exactly the in-place pop/update above.
12.3.3 Reaction-based (immediate) vs. gene-based (deferred)
A regulatory constraint may reference reactions (1 PDH + 1 PFL <= 5) or genes
(b0351 <= 2, limiting a gene’s activity). The distinction controls when the encoding can run, and the
orchestrator splits them at compute_strain_designs.py:
Reaction-based constraints parse successfully against the current reaction id set, so they are encoded immediately, before COMPRESS #1.
parse_constraints(k, _rxn_ids)succeeds → the constraint goes into_immediate_regandextend_model_regulatoryruns at once.Gene-based constraints reference identifiers that are not reaction ids yet — the gene has no pseudoreaction until GPR integration builds one (Ch 4). Trying to parse them against reaction ids throws, so they are routed to
_deferred_regand held. They are encoded only afterextend_model_gprhas created theg_<gene>pseudoreactions (compute_strain_designs.py), at which point the gene name is a reaction id and the sameextend_model_regulatorycall works.
The ordering is not cosmetic. A gene-regulatory bound g <= X is a bound on the gene pseudoreaction’s
flux, and that pseudoreaction does not exist before GPR extension; encoding it early would fail to find
the identifier. There is a second, subtler reason the orchestrator protects gene-controlled reactions
from COMPRESS #1: if a gene controls several reactions that get merged before GPR integration, the merged
reaction is hooked to the gene with a collapsed stoichiometry and the gene-regulatory bound would be
mis-scaled. The code therefore adds those reactions to no_coupled_compress_reacs/no_par_compress_reacs
so they survive to COMPRESS #2, where the g_gene metabolite already exists and the merge is correct
(compute_strain_designs.py). The gene-vs-reaction encoding split and its rationale belong to Ch
4; here the point is only that extend_model_regulatory is called twice in the pipeline, on two disjoint
sub-dicts, for exactly this reason.
12.4 gene_kos_to_constraints — a gene KO set as flux constraints
gene_kos_to_constraints(model, gene_kos) (networktools.py) answers a narrower question than the
MILP’s GPR machinery: given a concrete, fixed set of knocked-out genes, which reactions die, and what
constraints pin them off? It is used by the fba/fva helpers (Ch 2, Ch 5) when a caller wants to
evaluate a specific gene-KO scenario directly, not to search for interventions. (The search-time
encoding of gene KOs as intervention structure is extend_model_gpr, Ch 4 — a different mechanism.)
Mechanics:
Resolve identifiers. Each entry of
gene_kosmay be a gene id or a gene name; names are mapped to ids via{g.name: g.id}, ids checked directly, unknown identifiers silently dropped.Set gene states: every knocked gene →
False; every other gene is implicitlyNone(undetermined).Find candidate reactions: the union of reactions linked to any knocked gene (
gene_obj.reactions). Only these can change; no need to evaluate the rest.Evaluate each GPR with tri-state Boolean logic,
evaluate_gpr_ast(networktools.py). This walks the cobra GPR AST (ast.Nameleaves,ast.BoolOpAND/OR nodes) over{gene: True/False/None}:AND:
Falseif any child isFalse;Trueonly if all children areTrue; elseNone.OR:
Trueif any child isTrue;Falseonly if all children areFalse; elseNone.
The tri-state (three-valued Kleene) logic is what makes partial knockouts correct: with only some genes fixed to
Falseand the restNone, the evaluator returnsFalseonly when the knockouts alone force the rule false — an isozyme (geneA or geneB) with justgeneAknocked evaluates toNone(undetermined, becausegeneBcould carry it), notFalse. A reaction is declared dead only on a hardFalse.Emit constraints: for each reaction whose GPR evaluated to
False,[{r_id: 1}, '=', 0]— the list format meaning1·v = 0, i.e. pin the reaction to zero flux. Sorted for determinism.
The docstring records the SD grammar these constraints interoperate with: in solution vectors -1 = KO,
+1 = KI, 0 = non-added KI (Ch 9). When feeding gene constraints to fba/fva, both gene = 0.0 and
gene = -1.0 are treated as knockouts and gene = 1.0 (active) is ignored, so a raw SD solution vector
can be handed straight in. The output here, though, is reaction constraints in list format — the same
format parse_constraints and the modules use — so it slots directly into any constraints= argument.
12.5 Module & cost compression — keeping references consistent with a moving index
Compression (Ch 3) merges reactions, so after every compress_model call the reaction index space
changes: a constraint or objective that named reaction r7 may now have to name the lumped reaction
r7*r9, and a cost that applied to r7 and r9 separately must be re-expressed for the merged column.
Two functions repair this, both called right after each of the two compression rounds
(compute_strain_designs.py/363 for round 1, 436/437 for round 2).
Both operate on the compression map cmp_mapReac, a list of per-step dicts. The field that matters
here is reac_map_exp = { new_reac : { old_reac : factor, … } } — for each reaction produced by the
step, the pre-step reactions it stands for, each with a rational factor — plus a boolean parallel
flag (True = parallel merge, False = coupled/dependent merge). The map’s construction and the
factor semantics are Ch 3’s territory; Ch 9 documents the reverse walk. Here we need only the forward
relation the factor encodes.
12.5.1 The remapping math
For both merge kinds the factor obeys the same linear relation between an original reaction’s flux and the merged reaction’s flux:
Coupled merge: the merged reactions are flux-coupled,
v_oldₖ = factorₖ · v_new, wherefactorₖis the proportionality constant from the shared nullspace direction.Parallel merge:
v_newis the total fluxΣₖ v_oldₖ, split by fractions withΣₖ factorₖ = 1, so againv_oldₖ = factorₖ · v_new(compression.pybuilds these fractions from the stoichiometric scales).
Given that relation, remap a linear constraint Σₖ aₖ vₖ {≤,=} b when a subset L of its reactions
merges into new:
Σ_{k∈L} aₖ vₖ = Σ_{k∈L} aₖ (factorₖ · v_new) = ( Σ_{k∈L} aₖ·factorₖ ) · v_new
So the new coefficient on v_new is Σ_{k∈L} aₖ·factorₖ, and the right-hand side b is unchanged (the
transformation is a change of variables on the left only). This is exactly compress_modules
(networktools.py):
lumped_reacs = [k for k in c[0].keys() if k in old_reac_val]
c[0][new_reac] = np.sum([c[0].pop(k) * old_reac_val[k] for k in lumped_reacs])
c[0] is the coefficient dict; old_reac_val is {old: factor}; each merged term is popped and its
coefficient times its factor is accumulated onto new_reac. Objectives (INNER_OBJECTIVE,
OUTER_OBJECTIVE, PROD_ID) are linear expressions and get the identical treatment.
Coefficients are first converted to exact rationals (modules_coeff2rational) so the
factor multiply-and-sum stays exact — the same integer/rational discipline compression itself insists on
(Ch 3): never let a merge introduce float drift into a constraint that the MILP will treat as hard.
Worked micro-example. Module constraint 2 r7 - r9 <= 5, and a coupled step merges r7, r9 into
r7*r9 with v_r7 = 1·v_new, v_r9 = ½·v_new (i.e. factor_{r7}=1, factor_{r9}=½). New coefficient
= 2·1 + (−1)·½ = 3/2, so the compressed constraint is 1.5 (r7*r9) <= 5.
Why compress_modules skips parallel steps. Line 1340 guards the whole remap with if not parallel:
— it rewrites constraints/objectives only for coupled steps. This is safe, and necessary, because
reactions referenced in any module are protected from parallel merging in the first place:
_collect_no_par_compress_reacs (compute_strain_designs.py) gathers every reaction id named in a
module’s constraints/objectives and passes them as no_par_compress_reacs to compress_model
(compute_strain_designs.py, 433), which exempts them from the parallel compressor. A
module-referenced reaction therefore never appears on the old side of a parallel reac_map_exp, so
there is nothing to remap for those steps — and if the code did try, it would still be correct but
redundant. (Coupled merges are not exempted this way; a module reaction may be coupled-merged, which is
precisely why the coupled branch must run the remap.)
12.5.2 Cost remapping and the parallel/coupled asymmetry
compress_ki_ko_cost(kocost, kicost, cmp_mapReac) (networktools.py) does the analogous job for the
knockout- and knock-in-cost dicts, but here the merge kind genuinely changes the arithmetic, because a
cost is a property of “cutting/adding this reaction,” and what a cut of the merged reaction physically
means differs between the two merge types.
First, for provenance, each step records the cost dicts as they stood entering that step (
cmp.update({KOCOST: kocost, KICOST: kicost})) — this is the self-describing invariant the reverse
expansion in Ch 9 relies on. Then it rebuilds the dicts:
KO cost of a merged reaction:
coupled (and none of the group is a KI candidate):
minof the members’ KO costs. A coupled group fires together — knocking any one that carries the group’s flux kills the whole coupled flux — so the cheapest cut suffices, hence the minimum.parallel:
sumof the members’ KO costs. Parallel reactions are alternative routes carrying the same conversion; to actually knock the lumped capacity out you must cut all of them, so the costs add.
KI cost of a merged reaction — the mirror image:
coupled:
sumof the members’ KI costs (adding a coupled pathway means adding every reaction in the chain).parallel (and none of the group is a KO candidate):
min(adding one of several parallel routes restores the capacity, so the cheapest addition wins).
The guard conditions (not np.any([s in kicost …]) on the coupled-KO branch, and its mirror on the
parallel-KI branch) prevent a reaction that is simultaneously a KO and KI candidate from being collapsed
into the wrong category; such mixed groups fall through and are handled by expansion (Ch 9). The function
returns the rebuilt kocost, kicost and the annotated cmp_mapReac (now carrying the per-step
KOCOST/KICOST snapshots) — the third return value is what makes decompression able to walk the merge
backward and re-split a merged intervention into the right originals.
The min/sum asymmetry is the crux and is worth stating plainly: coupled ⇒ KO-min / KI-sum;
parallel ⇒ KO-sum / KI-min. Getting it backwards would report designs whose true intervention cost
violates the user’s max_cost budget, or would prune valid cheap designs.
12.6 Ordering, and why it is load-bearing
Reading the preprocessing block (compute_strain_designs.py) top to bottom, the utility calls
interleave with the heavy steps in a sequence that is not arbitrary:
remove_ext_mets(§12.1) first — it redefines what a genuine conservation relation is, so it must precede any nullspace reasoning (COMPRESS #1, all FVAs).Reaction-based
extend_model_regulatory(§12.3) next, still before COMPRESS #1, so its pseudometabolites/pseudoreactions are present when compression analyzes the network and itsr_ctlreactions enterko_costbefore FVA prunes essentials.COMPRESS #1, then immediately
compress_modules+compress_ki_ko_cost(§12.5) so modules and costs track the new index before the next step reads them. Skipping the remap here would leave modules naming reactions that no longer exist.GPR extension (Ch 4), then gene-based
extend_model_regulatory(§12.3.3) — deferred to exactly this point because the gene pseudoreactions it references do not exist earlier.COMPRESS #2, then
compress_modules+compress_ki_ko_costagain on the round-2 map; the two maps are concatenated (cmp_mapReac = cmp_mapReac_1 + cmp_mapReac_2) into the single history that decompression (Ch 9) later replays in reverse.
gene_kos_to_constraints (§12.4) sits outside this sequence — it is invoked on demand by the
fba/fva helpers whenever a caller evaluates a fixed gene-KO scenario — but it emits the same list
format and so composes cleanly with everything above. parse_constr.py (§12.2) is the substrate under
all of it: every constraint string, whether from a module, a regulatory intervention, or a direct
fva(constraints=…) call, becomes rows through the same scanner, guaranteeing one consistent
A·x {≤,=,≥} b convention across the whole package.
¹ Footnote on the * tokenization: the scanner splits only on whitespace, so "2*c" written without
spaces around * becomes a single token "2*c" that is neither a known reaction id nor a pure-number
match and therefore raises Unknown identifier 2*c. Coefficients must be space-separated ("3 r2"), which is the form the docstring examples now use. If you extend the parser,
either strip * in the tokenizer or document the space requirement — the current behavior is
inconsistent with the examples in its own docstrings.
13. The object model & result API
Two small Python classes bracket the entire computation and are the only StrainDesign
types most users ever hold in their hands. SDModule (strainDesignModule.py) is the
input object: it says what strain-design goal you want. SDSolutions
(strainDesignSolutions.py) is the output object: it holds the intervention sets that were
found and translates them between the internal, compressed representation and the
reaction/gene view a modeller reasons about. Between them sits a third, less obvious surface —
the preprocessed dump (dump_preprocessed / compute_strain_designs_from_preprocessed) —
which lets a developer freeze the expensive preprocessing once and replay the cheap MILP solve
many times. This chapter documents all three as an API contract: what each field means, what
each method returns, when to reach for which, and why the objects are shaped the way they are.
The mathematics of how a design is decompressed (composing the reverse compression maps),
how knock-ins are encoded as value-0/(nan,nan), and how strip_non_ki/expand_sd work are
owned by Ch 9; this chapter references those rules and instead documents the surface a
user touches. The dualization/z-linking that turns a module into MILP rows is Ch 6/7; here a
module is just a validated specification.
13.1 SDModule — the problem-specification object
13.1.1 What it is: a validated dict subclass
SDModule is declared as
class SDModule(Dict): # strainDesignModule.py:29
def __init__(self, model, module_type, *args, **kwargs):
i.e. it subclasses dict. An SDModule is a dictionary; every field is a key. After
construction, m[CONSTRAINTS], m[MODULE_TYPE], m[INNER_OBJECTIVE] etc. are ordinary
dictionary lookups (the constants are string keys defined in names.py, e.g.
MODULE_TYPE = 'module_type', CONSTRAINTS = 'constraints'). The rest of the pipeline never
uses attribute access — strainDesignProblem.py, compress_modules, the FVA scoping loop and
so on all read m[CONSTRAINTS], m[MODULE_TYPE]. The class is, in effect, a schema-checked
dict with a constructor that parses and validates.
Why a dict-subclass rather than a class with positional/attribute fields or a dataclass? Three concrete reasons, all visible in the code:
The set of meaningful fields is type-dependent and sparse. An
optknockmodule needsinner_objective+outer_objective; asuppressmodule needs neither (both optional); anoptcouplemodule needsinner_objective+prod_idbut forbidsouter_objective. A flat keyword bag with per-type validation expresses “these keys are relevant, those are not” far more naturally than a fixed positional signature would. Unused fields are simply set toNone(strainDesignModule.py), so every module carries the same key set and downstream code can blindly readm[OUTER_OBJECTIVE]withouthasattrguards.Modules must survive serialization and transformation as plain data. They are embedded verbatim into the
sd_setupdict stored on everySDSolutions(under theMODULESkey), aredeepcopy-ed repeatedly (setup construction,SDModule.copy), are JSON-dumpable when a setup is written to a.jsonfile (compute_strain_designsacceptsSETUPas a path andjson.loads it,compute_strain_designs.py), and are remapped through the compression map bycompress_modules(Ch 12), which walks the constraint dicts and rewrites reaction keys. A dict subclass is trivially all of these; a bespoke class would need custom__getstate__/to_dictglue.The constructor is the single validation gate. Because everything is keyed, one loop (
strainDesignModule.py) can enforce the whitelist:allowed_keys = {CONSTRAINTS, INNER_OBJECTIVE, INNER_OPT_SENSE, OUTER_OBJECTIVE, OUTER_OPT_SENSE, INNER_OPT_TOL, OUTER_OPT_TOL, PROD_ID, 'skip_checks', MIN_GCP, 'reac_ids'} for key, value in kwargs.items: if key in allowed_keys: self[key] = value else: raise Exception("Key " + key + " is not supported.")
A typo like
inner_objectiv=...raises immediately rather than being silently ignored (the failure mode of**kwargsbags and ofsetattr-based objects). This is the payoff of the design: fail loud at specification time, in the user’s own call, long before the MILP is built.
Note model and module_type are the only positional arguments; the first two lines of the
constructor set self[MODEL_ID] = model.id and self[MODULE_TYPE] = module_type. Everything
else is keyword-only in practice (*args is accepted but ignored).
13.1.2 The six module types
module_type must be one of six strings (strainDesignModule.py), all defined in
names.py:
Type |
|
Global objective it implies |
Mandatory fields (beyond model/type) |
|---|---|---|---|
PROTECT |
|
(none — cost-minimizing MCS) |
— ( |
SUPPRESS |
|
(none — cost-minimizing MCS) |
— ( |
OPTKNOCK |
|
maximize |
|
ROBUSTKNOCK |
|
max–min of |
|
OPTCOUPLE |
|
maximize growth-coupling potential |
|
DOUBLEOPT |
|
(bilevel, like OptKnock) |
|
A single computation may contain at most one of OPTKNOCK/ROBUSTKNOCK/OPTCOUPLE/DOUBLEOPT (they define the global objective), plus arbitrarily many PROTECT and SUPPRESS modules. When only PROTECT/SUPPRESS modules are present, the global objective is “minimize the number (cost) of interventions” — the classical MCS problem. The semantics of each type (SUPPRESS = make a flux region infeasible via a Farkas certificate; PROTECT = keep a region feasible; the bilevel types = nest an inner LP via strong duality) are the subject of Ch 1 and Ch 6; here they are just labels that select a validation branch.
Two documentation caveats worth flagging: DOUBLEOPT is a valid, accepted type in the code
(names.py, validated exactly like OptKnock/RobustKnock at
strainDesignModule.py) but is not described in the class docstring — the docstring
predates it. And names.py deliberately rebinds PROTECT/SUPPRESS: they are first
set to legacy internal strings 'mcs_lin'/'mcs_bilvl' and then immediately overwritten with
'protect'/'suppress', so only the latter two are live. The overwrite is intentional (the old
strings are kept in the module docstring for historical reference only).
13.1.3 Per-type validation, step by step
The constructor’s validation (strainDesignModule.py) runs in this order:
Type whitelist (
:245). Unknownmodule_type→ exception.Bilevel objective presence & senses (
:248-268).For OPTKNOCK/ROBUSTKNOCK/DOUBLEOPT: default
inner_opt_sense/outer_opt_sensetoMAXIMIZEif unset; both must be'minimize'or'maximize'; bothinner_objectiveandouter_objectivemust be non-None, else raise.For OPTCOUPLE: default
inner_opt_sensetoMAXIMIZE; defaultmin_gcpto0.0; requireinner_objectiveandprod_id. (Noouter_objective— the outer objective is implicitly the growth-coupling potential.)
MCS-with-inner-objective wrinkle (
:269-276). PROTECT/SUPPRESS normally take no outer objective, but if one is supplied, aninner_objectivebecomes mandatory andouter_opt_senseis defaulted/validated. This supports the “optimal-yield-at-max-growth” pattern the docstring describes.Optimality tolerances (
:277-282).inner_opt_tol/outer_opt_tol, if given, must lie in(0, 1]— a fraction of the optimum (1.0= exact,0.95= “within 95 % of optimal”). These feed the inner/outer LP as an ε-optimality band.reac_idsfallback (:284-285). If no explicit reaction-id list was passed, it is taken frommodel.reactions.list_attr('id'). This is why a dummy model works: passskip_checks=Trueandreac_ids=[...]and the constructor never touchesmodel.reactions(see the guard at:239-242, which errors only if bothreac_idsandmodel.reactionsare empty).Parsing to matrix/dict form (
:290-308). This is where free-form user input is normalized (all viaparse_constr.py, Ch 12):constraints→ a list of[coeff_dict, sign, rhs]triples viaparse_constraints. So'growth >= 0.1'becomes[[{'growth': 1.0}, '>=', 0.1]].Nonebecomes[].inner_objective,outer_objective,prod_id, if strings, → coefficient dicts vialinexpr2dict. So'BIOMASS - 0.05 EX_etoh_e'becomes{'BIOMASS': 1.0, 'EX_etoh_e': -0.05}. Passing a dict directly skips parsing.
Both string and dict forms are accepted for every expression field — a deliberate convenience so the same module can be written terse (strings) or programmatic (dicts).
Feasibility checks (
:311-339, skipped whenskip_checks=True):The constraints alone must leave the original model feasible:
fba(model, constraints=self[CONSTRAINTS]).status != INFEASIBLE. This catches contradictory or mistyped constraints at construction time.The zero-vector exclusion for SUPPRESS/PROTECT-with-inner-objective (
:316-320): the constructor pins every reaction to 0 ([[{k:1},'=',0] for k in reactions]) and checks that the constraint region is then infeasible. If the all-zero flux vector satisfies the module’s constraints, the module is ill-posed (an MCS can never exclude the trivial resting state) and it raises. This is a genuinely subtle correctness guard — it is why a suppress constraint is written'growth >= 0.01'(excludes 0) rather than'growth >= 0'(includes 0).Every reaction referenced in
inner_objective/outer_objective/prod_idmust exist inreac_ids(:322-331), andmin_gcpmust be numeric (int is coerced to float,:333-339).
skip_checks=True bypasses items 7 entirely — used internally when a module is reconstructed
from already-validated data (see SDModule.copy, :341-359, which rebuilds via a DummyModel
carrying only .id and passes skip_checks=True).
13.1.4 Construction examples
Classical gene/reaction MCS — “find minimal knockout sets that make growth ≥ 0.01 impossible while keeping the model otherwise feasible”. One SUPPRESS module suffices; PROTECT is implicit (an empty-constraint PROTECT just keeps the model feasible and is usually unnecessary):
from straindesign import SDModule
import cobra
model = cobra.io.load_model('e_coli_core')
# Undesired behaviour to eliminate: any growth at/above 0.01
suppress = SDModule(model, 'suppress',
constraints='BIOMASS_Ecoli_core_w_GAM >= 0.01')
Internally this becomes suppress[CONSTRAINTS] == [[{'BIOMASS_Ecoli_core_w_GAM': 1.0}, '>=', 0.01]], suppress[MODULE_TYPE] == 'suppress', all other fields None/[]. Passing it to
compute_strain_designs(model, sd_modules=suppress, ...) yields the classical MCS.
To also protect a minimum viable growth of a different, desired phenotype (the standard MCS pair), add a PROTECT module and pass the list:
protect = SDModule(model, 'protect',
constraints='BIOMASS_Ecoli_core_w_GAM >= 0.05')
compute_strain_designs(model, sd_modules=[suppress, protect], solution_approach='populate')
Bilevel — OptKnock for ethanol — “maximize ethanol export at the growth-optimal flux state, guaranteeing growth ≥ 0.2”:
optknock = SDModule(model, 'optknock',
inner_objective='BIOMASS_Ecoli_core_w_GAM', # cell optimizes growth
outer_objective='EX_etoh_e', # we optimize ethanol
constraints='BIOMASS_Ecoli_core_w_GAM >= 0.2')
Here inner_objective/outer_objective become coefficient dicts, inner_opt_sense and
outer_opt_sense default to 'maximize' (:250-252), and the constructor verifies that both
objectives reference real reactions and that the growth-≥-0.2 constraint is satisfiable. For
OptCouple you would instead pass inner_objective='BIOMASS...' and prod_id='EX_etoh_e' (no
outer objective), optionally with min_gcp=0.05.
13.2 SDSolutions — the result object
SDSolutions (strainDesignSolutions.py) is the return value of compute_strain_designs,
compute_strain_designs_from_preprocessed, and the lower-level SDMILP compute methods. Its
docstring is blunt: “Instances of this class are not meant to be created by StrainDesign
users.” The orchestrator builds it; the user reads it.
13.2.1 What a “design” is: the intervention dict
The atomic unit is an intervention set: a plain dict mapping a reaction/gene/regulatory
identifier to an integer-valued marker. The constructor docstring (:47-54) defines the
encoding, and _compute_costs_and_bounds (:246-281) turns it into bounds:
Value in dict |
Meaning |
Reaction bounds produced ( |
|---|---|---|
|
knock-out (KO) — remove the reaction/gene |
|
|
knock-in (KI) that was added |
the reaction’s own |
|
a candidate KI that was not added |
|
|
regulatory intervention active |
derived from the parsed reg. constraint |
|
regulatory intervention not added |
(omitted / no bound change) |
The (nan, nan) sentinel for “not-added KI” is the crux of the KI accounting (full derivation
in Ch 9): a value-0 entry is carried through the solution so that cost bookkeeping and
superset-comparison see the full candidate set, but it represents no actual edit — hence bounds
that are literally “not a number”. The -1/1/0 trichotomy exists precisely because a KI is
not simply the absence of a KO: the same reaction can be a KO candidate in one design and a
not-added KI candidate in another, and the object must distinguish them.
itv_bounds is computed once at construction (:246-281) and cached; get_reaction_sd_bnds
just returns it. For a KO you get (0,0); for an added KI you get the reaction’s real bounds
(so the caller can re-impose them on a model); regulatory True entries with a simple
single-reaction constraint are folded into a bound (:256-281), while complex multi-reaction
regulatory constraints set has_complex_regul_itv = True and are left as symbolic strings.
13.2.2 Internal storage
The fields set by __init__ (:72-105):
reaction_sd—list[dict], the designs at reaction level. Always present.gene_sd—list[dict], the designs at gene level. Present only when the computation used gene knockouts/knock-ins (i.e.GKOCOSTorGKICOSTinsd_setup); the flagis_gene_sdrecords this (:91-99). In gene mode, the raw solution dicts are gene-keyed, so the constructor calls_translate_genes_to_reactions(:134-201) to derivereaction_sdfromgene_sdvia cobra’s parsed GPR AST (reaction.gpr.eval, Ch 9 owns this translation). In reaction modereaction_sdis the raw input andgene_sddoes not exist.sd_cost—list[float], one total cost per design, summed over the applicable cost dictionaries (KOCOST/KICOST/GKOCOST/GKICOST/REGCOST) in_compute_costs_and_bounds(:217-243). An entry contributes its cost only when present and non-zero in the design (if k in s and s[k] != 0), so a not-added KI (value 0) costs nothing — consistent with the bounds table above.itv_bounds—list[dict], the per-design bound overrides described in 13.2.1.status— the solver/computation status string ('optimal','infeasible','time_limit_w_sols', …; fromnames.py, ultimately optlang’soptlang.interfaceconstants).sd_setup— the self-describing setup dict (MODEL_ID,MODULES, the cost dictionaries,MAX_COST,SOLVER, …): the reproducibility record embedded in the object. It is a first-class object with its own dual input/output role — see §13.3.Compression bookkeeping (set after construction by the orchestrator, not in
__init__):compressed_sd(the designs in the compressed model’s reaction space),compression_map(cmp_mapReac, the list of reverse-compression steps, Ch 3/9), andgroup_map(a parallel list mapping each expanded design index → the index of the compressed design it came from). These enable the group/representative API in 13.2.4.
13.2.3 The public accessor contract
The methods differ along two axes: level (reaction vs gene) and whether not-added KIs are
shown. The rule for the “clean” accessors is strip_non_ki (:768-770):
def strip_non_ki(sd):
return {k: v for k, v in sd.items() if v not in (0.0, False)}
— it drops the value-0 KI markers and the False regulatory markers, leaving only actual
interventions (KO -1, added KI 1, active reg True).
Method |
Returns |
Strips non-added KIs? |
When to use |
|---|---|---|---|
|
reaction-level design(s) |
yes |
The default “what do I actually change” view. Reaction KOs/KIs only. |
|
gene-level design(s) |
yes |
The genetic engineering deliverable (which genes to KO/KI). Raises if |
|
list of |
n/a |
When you want to apply a design to a cobra model (set bounds) or inspect KO as |
|
gene_sd if gene mode else reaction_sd |
yes |
Level-agnostic: “give me the designs in their native level.” |
|
cost float(s) |
n/a |
Rank/filter designs by intervention cost. |
|
reaction-level, raw |
no |
Analysis that must see the candidate KIs that were declined (value 0). |
|
gene-level, raw |
no |
Same, at gene level. |
|
|
yes |
Map the (often n:1) many-genes-→-one-reaction-phenotype relationship. |
|
int |
— |
Count; returns the estimated total in lazy mode (see 13.2.4). |
i may be None (all designs), a single int, or a list of indices; a bare int is wrapped
to [i] internally. Two contract subtleties to note:
get_reaction_sdvsget_reaction_sd_mark_no_ki. They differ only bystrip_non_ki. If you are presenting an engineering result, use the strippedget_reaction_sd. If you are reasoning about why a KI was or wasn’t chosen (superset logic, cost accounting), use..._mark_no_kiso the value-0 entries remain visible.The attributes are public too.
sol.reaction_sd,sol.gene_sd,sol.itv_bounds,sol.sd_cost,sol.statusare documented fields, not just internals. Reading them directly gives you the raw (unstripped) lists; theget_*methods are the curated view.itv_boundshas no stripping variant —get_reaction_sd_bndsreturns it as-is.
get_gene_reac_sd_assoc (:366-388) deserves a note: gene-level designs are frequently
degenerate — several distinct gene-knockout sets collapse to the same reaction-level
phenotype (because different genes gate the same reactions through the GPR). This method
deduplicates the reaction-level designs by hashing json.dumps(s, sort_keys=True) and returns
(unique_reaction_designs, association_indices, gene_designs) so a caller can display “these 4
gene strategies all realize reaction phenotype #2.”
13.2.4 Lazy expansion and representatives (PR #40)
Decompression can be combinatorially explosive: one compressed design, when its merged
reactions are expanded back to originals, can fan out into an enormous number of equivalent
full-model designs. Materializing all of them is often pointless (they are interchangeable) and
can exhaust memory or hang save (issue #47). PR #40 introduced lazy expansion to defer
that fan-out.
The mechanism lives across _decompress_solutions (compute_strain_designs.py) and
SDSolutions. When the orchestrator’s estimate_expansion_size exceeds
LAZY_EXPANSION_THRESHOLD (= 100_000, compute_strain_designs.py), it builds one
representative expanded design per compressed group via _build_lazy_representatives
(:721-756, taking expanded[0], the cheapest, per group) and constructs the solution with a
_lazy_init payload:
sd_solutions = SDSolutions(orig_model, sd, status, setup, _lazy_init=lazy_meta)
lazy_meta (:667-676) carries everything needed to expand a group on demand later:
compressed_sd, compression_map, the uncompressed cost dicts, max_cost, the live model,
and estimated_total. In lazy mode (self._lazy == True, :75):
get_num_solsreturnsself._estimated_total(the estimated full count), not the number materialized (:284-288).get_num_materializedreturns the actual count inreaction_sd.get_representative_sd(:431-444) returns one stripped design per compressed group — the cheap, canonical answer. If there is nogroup_mapit falls back toget_reaction_sd.get_group(i)/get_num_groups(:414-429) expose the group structure: which materialized indices share a compressed origin, and how many distinct compressed designs exist.expand_group(grp_idx)(:446-518) does the on-demand work: it callsexpand_sd+filter_sd_maxcost(Ch 9) for that one group, re-runs the regulatory post-processing and the GPR translation + cost/bounds computation, then splices the results intoreaction_sd,sd_cost,itv_bounds,group_map(andgene_sd) in place, replacing the single representative. It requires a liveself._model— if the object was loaded without one it raises with an actionable message pointing atload(..., model=True)orattach_model.expand_all(n_per_group=None)(:520-542) expands every not-yet-expanded group, optionally capping ton_per_groupdesigns per group, then clearsself._lazy.
The design contract for a developer: treat a fresh SDSolutions as possibly lazy. Call
is_lazy / get_num_materialized / get_num_sols to see the state; iterate representatives
for a summary; call expand_group/expand_all only when you truly need the full fan-out — and
only while a model is attached.
13.2.5 Save / load and model embedding
SDSolutions is designed to be a self-contained, portable record of a computation
(save/load, :553-687). The pickled state already includes the full problem specification
via sd_setup (§13.3); embedding a model snapshot closes the remaining gap. The central
complication is that the live cobra model carries an
un-picklable solver interface (and would tie the pickle to specific cobra/optlang/solver
versions), so the model is never pickled live. Instead:
__getstate__(:107-120) strips_model,_cmp_model, and themodelentry inside the lazy_expansion_metabefore pickling.save(filename, embed_model=True)(:553-612) embeds portable, solver-less snapshots of both the full model and the compressed (GPR-extended) model, produced by StrainDesign’s rational-safenetworktools.model_to_dict. Rational-safety matters: the compressed model’s bounds/coefficients are exact rationals (Ch 3), and a naive float round-trip would corrupt them. The two snapshots (_embedded_model_dict,_embedded_cmp_model_dict) are written only for this pickle and then restored off the live object so a subsequentembed_model=Falsesave stays lean (:597-612).savedoes not force expansion of lazy/compressed results (:565-571) — it pickles them as-is, precisely to avoid the memory blow-up of issue #47. To persist a fully-expanded set, callexpand_allfirst.load(filename, model=None, cmp_model=None)(:638-687) rebuilds models only on request:Noneattaches nothing,Truerebuilds the embedded snapshot viamodel_from_dict, and a passedcobra.Modelattaches that object directly._resolve(:678-683) implements this three-way choice independently for the full and compressed model.get_model/get_compressed_model/attach_model(:614-636) are the retrieval/attachment accessors. The compressed model is offered separately because analysingcompressed_sdin the small compressed model is far faster than in the full one.
Finally, SDSolutions supports merging (__iadd__/__add__, :704-765): two result sets
over the same model can be combined, deduplicating at the compressed-design level (via
frozenset(s.items)) when compression info is present, or at the expanded level otherwise,
with OPTIMAL status winning. _check_merge_compatible (:689-702) refuses to merge across
different models, across gene/reaction levels, or across incompatible compression maps. This is
what lets the benchmarking harness stitch together the outputs of several seed runs into one
solution set.
13.3 The sd_setup object — one bundle, two roles
sd_setup (the string key SETUP = 'sd_setup', names.py) is the single serializable
dictionary that fully describes a strain-design problem — modules, cost model, solver, and
solve-control parameters, all in plain-data form. It is deliberately not a class: it is a bare
dict of string keys → JSON-friendly values, precisely so it can be written to disk, version
controlled, diffed, and handed between processes without any StrainDesign type machinery. (The
modules it contains are SDModules, which — being dict-subclasses, §13.1.1 — are themselves
plain data.) The same object plays two roles at opposite ends of the pipeline: it is the input
that specifies a computation, and it is the record that travels out with the results.
13.3.1 The key set
The keys are the names.py constants; the value types are:
Key ( |
String |
Value type |
Meaning |
|---|---|---|---|
|
|
str |
|
|
|
|
the problem specification (§13.1) |
|
|
|
per-reaction KO / KI costs |
|
|
|
per-gene KO / KI costs (present ⇒ gene mode) |
|
|
|
regulatory-intervention costs |
|
|
float |
cost cap for a design |
|
|
str |
|
|
|
int/float |
MILP solution cap |
|
|
str |
|
|
|
int/float |
solver time limit (s) |
|
|
int |
MILP seed (solver B&B) |
|
— |
int/None |
big-M value (None ⇒ indicator constraints) |
|
— |
bool/int |
compression toggle, gene-KO mode, threads, CNApy dummies |
These are exactly the allowed_keys that compute_strain_designs accepts as top-level kwargs
(compute_strain_designs.py) — which is the whole point: sd_setup is a frozen copy
of the keyword arguments of a compute_strain_designs call. The two views (a bag of kwargs, or
one sd_setup dict) are interchangeable descriptions of the same problem.
Note that the sd_setup stored on a result object is not byte-identical to the input one: the
orchestrator rebuilds it from the original (uncompressed) modules and cost dictionaries at
decompression time (:606-609, :837-840) so that the record refers to the user’s model, not
the internal compressed one (see §13.3.3).
13.3.2 Role 1 — sd_setup as INPUT
compute_strain_designs(model, **kwargs) lets a caller pass the entire configuration as one
sd_setup= argument instead of spelling out every parameter (docstring :75-78). The handling
is at compute_strain_designs.py:
if SETUP in kwargs:
if type(kwargs[SETUP]) is str:
with open(kwargs[SETUP], 'r') as fs:
kwargs = json.load(fs) # a path to a JSON file
else:
kwargs = kwargs[SETUP] # an in-memory dict
Two accepted forms: the value may be an in-memory dict, or a path to a JSON file — the
latter is how CNApy stores problems as .sd files (docstring :63-65), which are then loadable
and re-runnable from Python. Either way the setup becomes the working kwargs for the rest of
the function.
Merge semantics — a correctness caveat. The code does not merge sd_setup with the other
keyword arguments; the else branch replaces kwargs wholesale with the setup dict, so any
explicit kwargs passed alongside sd_setup (other than model, which is a separate positional)
are silently discarded. The docstring states this as a hard rule: “sd_setup and other arguments
(except for model) must not be used together” (:77-78). So the contract is “all-or-nothing,”
not “defaults-plus-overrides”: use either individual kwargs or one sd_setup, never both.
(This is unlike compute_strain_designs_from_preprocessed, §13.4.2, whose keyword arguments
genuinely override the dumped configuration.)
Why this exists. A single sd_setup dict/JSON is a portable, version-controllable,
reproducible problem specification. It can be committed to a repository, attached to a paper,
diffed across experiments, generated programmatically by a GUI (CNApy), or shipped between
machines — and it re-poses the exact same computation with one call. It collapses a
ten-argument invocation into one auditable artifact.
13.3.3 Role 2 — sd_setup as OUTPUT / reproducibility record
Every SDSolutions stores the setup it was produced under: self.sd_setup = sd_setup
(strainDesignSolutions.py). This is what makes a result self-describing — the object
carries not just the answers but the full question. The orchestrator builds this record from the
original model/modules/costs right before constructing the solution: it deepcopys the setup
returned by the MILP layer and overwrites the module/cost keys with the uncompressed originals
(compute_strain_designs.py in the normal path, :837-840 in the from-preprocessed
path, and :570-573 in the dump early-return), adding GKOCOST/GKICOST when in gene mode. The
deepcopy is deliberate: the record must be an immutable snapshot, decoupled from any later
mutation of the live cost dictionaries.
13.3.4 Downstream uses — why a self-contained setup pays off
Carrying the full setup on the result is what lets the object be re-processed with no reference to the original call site:
Re-costing.
_compute_costs_and_bounds(strainDesignSolutions.py) readsKOCOST/KICOST/GKOCOST/GKICOST/REGCOSTstraight out ofsd_setupto total each design’s cost. Because the cost model lives in the record,sd_costcan be recomputed for any (e.g. lazily expanded, §13.2.4) design without the caller re-supplying the cost dictionaries —expand_group(:493-494) does exactly this, passingself.sd_setupback into_compute_costs_and_bounds.Re-expansion. The same setup drives on-demand decompression of compressed groups; the gene-vs-reaction branch and the cost lookups both key off it.
Re-running. Because
sd_setupis a validcompute_strain_designskwarg bundle (§13.3.1),compute_strain_designs(model, sd_setup=sols.sd_setup)re-runs the identical problem. Combined with the embedded model snapshot (§13.2.5), a saved solution file is a complete, machine-portable capsule: model + problem + answers, re-runnable and re-analysable on another host without the original script.
This is the deeper reason the save/load machinery (§13.2.5) embeds a model snapshot: the setup already pins everything except the model object, so embedding the model closes the last gap and makes the pickle a fully self-contained, reproducible record.
13.4 The preprocessed-dump workflow
The single most expensive part of a strain-design run is preprocessing, not the MILP solve:
the compression passes and — dominantly — the blocked/irreversible FVA. On the canonical
iML1515 gene-MCS problem the preprocessing FVA alone is ~117 s, while MILP construction is
~4 s (Ch 11). If you want to sweep the MILP solve across many configurations — different random
seeds, different solvers, different solution approaches, different pre-FVA bound settings — you
should pay the ~117 s once and replay the cheap part. That is exactly what dump_preprocessed
compute_strain_designs_from_preprocessedprovide. This is the workhorse of the benchmarking harness.
13.4.1 Dumping: dump_preprocessed
dump_preprocessed is a kwarg to compute_strain_designs (whitelisted at
compute_strain_designs.py); its value is a path. The orchestrator runs the entire
preprocessing pipeline normally — compression #1/#2, GPR integration, all three FVA phases,
size-1 MCS extraction, essential-reaction removal, and MILP-kwarg assembly — and then, just
before it would solve the MILP (:534-592), if dump_preprocessed is set it pickles a
dictionary and returns early (with any size-1 MCS already found, but without running the
MILP). The dumped dict (:540-562) contains:
Key |
What it is |
Why it’s needed on replay |
|---|---|---|
|
the compressed, GPR-extended cobra model (exact-rational bounds) |
the model the MILP is built on — the expensive artifact |
|
the modules remapped to compressed reaction space |
|
|
solver, |
the exact MILP-build arguments |
|
|
passed to |
|
|
which solve method to call |
|
the compression map |
needed to decompress the eventual solutions |
|
uncompressed cost dicts |
decompression + |
|
the pristine originals |
building |
|
bool flag |
selects gene vs reaction decompression |
|
cost cap and the size-1 MCS found in preprocessing |
decompression/filtering |
|
|
lets you re-run the bound-relaxation with a different config, or study its effect, without recompressing |
pre_fva_bounds (captured at :449, immediately before bound_blocked_or_irrevers_fva) is the
key enabler of bound-configuration experiments: the compressed model is snapshotted with its
bounds as they were before the redundant-bound relaxation, so a downstream experiment can
apply a different bound policy to the already-compressed model rather than re-deriving the whole
compression. The dump thus amortizes not just the FVA but the entire compression + GPR chain.
On dump the function logs a copy-pasteable resume line and returns an SDSolutions holding only
the size-1 MCS (or infeasible/empty), with compressed_sd/compression_map/group_map and
_cmp_model populated (:568-592).
13.4.2 Replaying: compute_strain_designs_from_preprocessed
compute_strain_designs_from_preprocessed(dump, seed=None, solver=None, solution_approach=None, max_solutions=None, time_limit=None) (:759-851) is the cheap replay. Its signature is the
sweep interface: every keyword is an override applied on top of the dumped configuration.
dumpmay be a path (unpickled) or the dict itself (:776-781) — the latter lets you unpickle once, mutate the dict in a loop (e.g. rewritecmp_modelbounds usingpre_fva_bounds, or swapsd_modules), and feed each variant in without touching disk.Overrides (
:803-813):seed→kwargs_milp[SEED];solver→kwargs_milp[SOLVER](viaselect_solver);max_solutions/time_limit→kwargs_computation;solution_approachreplaces the dumped approach.The compressed model was pickled while its LP/solver was suppressed (its solver is a stub), so the replay re-enters
suppress_lp_context(cmp_model)(:817-818) before building theSDMILP, so thatSDMILPcan safely touch variables without triggering a solver build.It then rebuilds the MILP (
SDMILP(cmp_model, sd_modules, **kwargs_milp),:824), solves via the chosen approach, and — crucially — runs the identical_decompress_solutionspath (:842-845) as the normal orchestrator, so the returnedSDSolutions(lazy expansion, costs, bounds, gene translation,_cmp_model) is indistinguishable from one produced end-to-end.
13.4.3 The developer workflow
The typical benchmarking loop:
from straindesign import (compute_strain_designs,
compute_strain_designs_from_preprocessed)
# 1. Pay preprocessing ONCE (~117 s on iML1515). Returns early; writes the dump.
compute_strain_designs(model, sd_modules=[suppress],
gene_kos=True, max_cost=3,
solution_approach='populate',
dump_preprocessed='iml1515_gmcs.pkl')
# 2. Sweep the cheap MILP solve — e.g. a seed sweep for solver-variance study:
results = []
for s in range(10):
sol = compute_strain_designs_from_preprocessed('iml1515_gmcs.pkl', seed=s)
results.append(sol)
# 3. Or a solver comparison (the CPLEX-vs-Gurobi story, Ch 11):
gu = compute_strain_designs_from_preprocessed('iml1515_gmcs.pkl', solver='gurobi')
cp = compute_strain_designs_from_preprocessed('iml1515_gmcs.pkl', solver='cplex')
# 4. Or a bound-config experiment using the in-memory dict form:
import pickle
d = pickle.load(open('iml1515_gmcs.pkl', 'rb'))
for cfg in bound_configs:
apply_bounds(d['cmp_model'], d['pre_fva_bounds'], cfg) # mutate compressed model
results.append(compute_strain_designs_from_preprocessed(d)) # pass the dict
Because each replay reuses the same compressed model, module remapping and cost translation, the
only variable across runs is the MILP itself — which is precisely the isolation a benchmark
wants. And because the returned SDSolutions objects are merge-compatible (same model, same
compression map), a seed or solver sweep can be folded into a single deduplicated solution set
with sum(results, results[0])-style __iadd__ (13.2.5). This is the object-level plumbing
that makes the benchmarking harness (Ch 11) fast and reproducible.
14. The solver-interface layer (MILP_LP + backends)
Every LP and MILP that straindesign ever solves — the three preprocessing FVA sweeps, the
size-1 MCS probes, the bounding LPs that compute big-M values, and the central strain-design
MILP with its integer-cut enumeration — passes through a single class, MILP_LP in
solver_interface.py. MILP_LP is a thin, uniform façade over four numerically and API-wise
very different solvers (CPLEX, Gurobi, SCIP/SoPlex, GLPK). This chapter is about the physical
handoff: how the abstract problem (c, A_ineq, b_ineq, A_eq, b_eq, lb, ub, vtype, indic_constr, M)
built upstream (Ch 7) becomes a live solver object, how solve / slim_solve / populate map onto
each backend’s very different notion of “solve,” how indicator constraints are handed over natively
or reduced to big-M, how each solver’s status codes are collapsed into one canonical vocabulary,
and where — physically — the ~4.4× CPLEX-vs-Gurobi runtime gap on the canonical iML1515 gene-MCS
benchmark lives.
Boundaries: Ch 7 owns the decision of which continuous rows get a big-M encoding versus a
native indicator constraint (the link_z fork) and the mathematics of a valid/tight M. Ch 8
owns the solve loop — the ANY / BEST / POPULATE objective setups and the integer-cut enumeration
that repeatedly calls the methods described here. This chapter owns only the layer in between: the
abstraction and the four backend translations.
14.1 Why an abstraction layer exists
The four solvers do not agree on almost anything at the API level:
Problem construction. CPLEX wants triplets fed into
variables.add/linear_constraints.add/set_coefficients; Gurobi wants anMVarand matrix constraints (addMVar,addMConstr); pyscipopt wants variables andExprobjects assembled term by term; GLPK (viaswiglpk) wants raw C arrays with 1-based indexing.Infinity.
numpy.infmust be rewritten tocplex.infinity,gurobipy.GRB.INFINITY,SCIP.infinity, or GLPK’s free-bound sentinels — each different.Indicator constraints. CPLEX and Gurobi support them natively (with opposite conventions for how you say “active when the binary is 0”); SCIP supports only the indicator = 1 case natively and needs an auxiliary variable for indicator = 0; GLPK has no concept of them at all.
Status codes. CPLEX returns small integers whose meaning depends on whether the problem is an LP or a MIP (e.g. unbounded is
2/4for LP but118/119for MIP); Gurobi returns its own enum; SCIP returns strings ('optimal','timelimit','unknown', …); GLPK returns yet another integer set (GLP_OPT,GLP_NOFEAS, …).The solution pool. CPLEX and Gurobi have native pools; SCIP and GLPK have none, so
populatemust be emulated by an outer solve-and-exclude loop.
Rather than sprinkle solver-specific branches through SDMILP, the whole pipeline is written once
against the MILP_LP API — solve, slim_solve, populate, set_objective(_idx), set_ub,
set_time_limit, add_ineq_constraints, set_ineq_constraint, set_lp_method, get/set_basis —
and each backend implements exactly that surface with identical semantics. The invariant that makes
this correct is that every backend presents the same canonical minimization problem and returns
the same canonical statuses, regardless of how its underlying solver phrases them. SDMILP
itself is defined as class SDMILP(SDProblem, MILP_LP) (strainDesignMILP.py) and simply calls
MILP_LP.__init__ with the matrices SDProblem assembled (strainDesignMILP.py), so the strain-
design MILP is an MILP_LP — the abstraction is not a wrapper the caller holds, it is a base class
the problem inherits.
14.2 The canonical problem and the MILP_LP constructor
MILP_LP accepts one problem shape (solver_interface.py):
minimize cᵀx
subject to A_ineq · x ≤ b_ineq
A_eq · x = b_eq
lb ≤ x ≤ ub
x_i ∈ {C, B, I} (continuous / binary / integer)
indicator constraints:
x_j = [0|1] → a · x [≤|=|≥] b
with A_ineq, A_eq ∈ scipy.sparse of width n = #variables, c, lb, ub, vtype of length n, and
indic_constr an IndicatorConstraints object (Section 14.4). The sense is always
minimization; the enumeration and dualization layers arrange their objectives accordingly (Ch 8).
The constructor (solver_interface.py) does four jobs before touching a solver:
Keyword plumbing and defaults. It accepts exactly the keys
{c, A_ineq, b_ineq, A_eq, b_eq, lb, ub, vtype, indic_constr, M, solver, skip_checks, tlim, seed, milp_threads}; any other key raises. Missing pieces are defaulted so a caller may pass only a constraint matrix:c → 0, emptyA_eq/A_ineqbecome(0, n)sparse matrices,lb → -inf,ub → +inf,vtype → 'C'·n(solver_interface.py). Infinities/NaNs flow through here asnumpyvalues; each backend rewrites them to its own sentinel.Solver selection. If no
solveris given, the first entry of the module-levelavail_solversset is used (solver_interface.py).avail_solversis populated at import in__init__.pyin the order GLPK, CPLEX, Gurobi, SCIP as each import succeeds; because it is aset, “first” is not a guaranteed priority order — for reproducible backend choice the caller should passsolver=explicitly (the orchestrator does). An explicit solver that is not installed raises immediately (solver_interface.py).Dimension checks (unless
skip_checks=True). Row counts ofA_ineq/b_ineqandA_eq/b_eqmust match, all widths must equaln, and the indicator block’s dimensions must be internally consistent (solver_interface.py). These checks are the single most useful guard against a malformed dualization silently producing a wrong-shaped MILP;skip_checks=Trueis used only on hot paths where the shape is known (e.g. theverify_sdsub-LPs).Type casting and the big-M warning. All matrices are cast to
float; then, if the solver is not GLPK but a finiteMwas supplied alongside indicator constraints, a warning fires thatMwill be ignored (solver_interface.py) — because only GLPK consumesM. GLPK withmilp_threadsset raises, since GLPK is single-threaded (solver_interface.py).
Then it instantiates the backend (solver_interface.py), passing the full tuple plus
seed and milp_threads (and, for GLPK only, M). SCIP is special-cased: if the problem is a pure
LP (all vtype == 'C' and no indicator constraints) it routes to the SoPlex LP object SCIP_LP
and returns early; otherwise to the MILP object SCIP_MILP (solver_interface.py). Finally
it applies the time limit (inf if none), floored at 1 ms (Section 14.6).
Note that MILP_LP keeps its own copies of c, A_ineq, b_ineq, … in sync with the backend: e.g.
add_ineq_constraints both vstacks onto self.A_ineq and forwards to self.backend
(solver_interface.py). This shadow copy is what lets the enumeration layer read back the
current constraint system (for integer cuts) without a solver round-trip.
14.3 The three solve entry points and result normalization
MILP_LP exposes three ways to solve, each forwarding to the backend and each with a distinct
contract used by different parts of Ch 8’s loop:
solve → (x, opt, status)(solver_interface.py). The full solve: returns the solution vector, objective value, and canonical status. Crucially,MILP_LP.solvepost-processes the backend’s rawx: when a solution exists (status not in{INFEASIBLE, UNBOUNDED, TIME_LIMIT}) it rounds integer/binary variables to the nearest integer and casts, leaving continuous variables untouched (solver_interface.py). This is where solver integrality tolerances (az_jcoming back as0.9999999997) are cleaned into exact0/1so that the downstream integer-cut math and cost accounting are exact.slim_solve → opt(solver_interface.py). Returns only the optimal objective value, no solution vector. This is the workhorse of preprocessing: FVA minimizes/maximizes each flux, and the MILP verification LPs (strainDesignMILP.py,valid[i] = not isnan(lp.slim_solve)) only ask “is this feasible / what is the bound,” never needingx. Skipping solution-vector extraction matters because, across genome-scale FVA, that extraction cost is paid thousands of times.populate(n) → (X, opt, status)(solver_interface.py). Returns a list of solution vectors — the solution pool — used by POPULATE enumeration to harvest many equally-optimal designs in one solver invocation. Only CPLEX and Gurobi implement this natively; SCIP and GLPK emulate it (Section 14.7).
The remaining API is manipulation used between solves by the enumeration loop: set_objective /
set_objective_idx (swap the objective vector, e.g. ANY’s zero objective vs BEST’s cost objective),
set_ub (fix a z_j by dropping its upper bound to 0), add_ineq_constraints (append an integer
cut), set_ineq_constraint (rewrite a row in place), and set_time_limit (Section 14.6). Warm-start
support (get_basis / set_basis, set_lp_method) exists for LP-heavy phases; barrier is
unavailable on GLPK and SoPlex and degrades gracefully (Section 14.6).
14.4 Indicator constraints vs big-M, per backend
An IndicatorConstraints object (indicatorConstraints.py) stores a batch of implications
x_{binv[i]} = indicval[i] → A[i]·x [sense[i]] b[i], with binv the indicator variable indices,
A a sparse matrix (one row per constraint), b the right-hand sides, sense ∈ {'L','E','G'}, and
indicval ∈ {0,1}. This is a solver-neutral container; each backend translates it.
Recall the Ch 7 result stated as given in CONTEXT: under the default M = inf, link_z emits the
SUPPRESS Farkas-dual rows as indicator constraints (their fluxes are unbounded, so no finite M
exists) and the PROTECT finite-flux primal rows as big-M rows already baked into A_ineq. This
split is emergent from bound structure, not a per-module switch. Consequently, by the time a problem
reaches this layer, the big-M rows are ordinary inequality rows — no backend does anything special
with them — and the indic_constr block carries only the genuinely indicator-encoded implications.
The one exception is GLPK, which cannot represent indicators and must convert that block to big-M
here, using the M value the abstraction passed it.
CPLEX (cplex_interface.py). The batch is reshaped to CPLEX’s format — each row becomes
[[col indices],[coeffs]] — and handed to self.indicator_constraints.add_batch with
indvar=binv, sense, rhs=b, and complemented = 1 - indicval. CPLEX’s native convention is
“constraint active when indvar = 1,” so an indicval = 0 implication is passed as
complemented = 1. This is a single native call; CPLEX handles the linkage internally with no big-M
and no auxiliary variables, giving a tighter LP relaxation.
Gurobi (gurobi_interface.py). Each row i is turned into a linear expression
Σ vals·x[cols] and registered with
addGenConstrIndicator(x[binv[i]], bool(indicval[i]), lhs, sense, b[i]), where the sense is '='
for 'E' and '<' otherwise. Gurobi’s API takes the active value directly as its second
argument, so no complementation arithmetic is needed. The constructor records
self._has_indicator_constr because Gurobi 13’s presolve has a known bug with indicators that the
solve path must guard against (Section 14.8).
SCIP (scip_interface.py). SCIP supports indicator constraints (addConsIndicator) but
with two limitations the code works around:
Only
indicval = 1is native. For anindicval = 0implication the interface adds an auxiliary binaryzand an XOR/complement equalityx_{binv} + z = 1, then indicates onz(scip_interface.py). So “active when the original binary is 0” becomes “active when the freshzis 1.”Only
≤(and=split) senses. An'E'row is expanded to two rowsA·x ≤ band-A·x ≤ -b(scip_interface.py); each resulting≤row is added as anaddConsIndicatorwith the chosen binary. Thus SCIP’s handling is native indicator constraints, just with more bookkeeping — it does not fall back to big-M.
GLPK (glpk_interface.py). GLPK has no indicator support, so this is the one backend
where the IndicatorConstraints block is reduced to big-M rows at construction, using the M
passed down (default 1000 if none — glpk_interface.py). The reduction implements exactly
the linearization documented in indicatorConstraints.py:
an
'E'row is first split intoA·x ≤ band-A·x ≤ -b;if the constraint is active when the binary is 1 (
indicval = 1): set the binary’s column coefficient to+Mand addMto the RHS, givingA·x + M·z ≤ b + M— inactive (slackM) whenz = 0, and the originalA·x ≤ bwhenz = 1;if active when the binary is 0 (
indicval = 0): set the column coefficient to-M, givingA·x − M·z ≤ b— the original constraint atz = 0, relaxed byMatz = 1(glpk_interface.py).
These synthesized rows are stacked below the ordinary A_ineq/A_eq rows and loaded as GLP_UP
(upper-bounded) rows (glpk_interface.py). Two warnings fire announcing the reduction and
the M used (glpk_interface.py). Because a loose M inflates the LP relaxation and
courts the numerical trouble genome-scale MCS problems are already prone to (Ch 11), GLPK is the
weakest backend for large strain-design MILPs and is best reserved for validation on small models.
The upshot connects directly to Ch 7’s fork: on CPLEX / Gurobi / SCIP the unbounded SUPPRESS rows stay as native indicators (tight relaxation, better numerics); on GLPK they, plus any other indicator rows, are forced into big-M with all the relaxation-quality and conditioning costs that entails.
14.5 Status-code translation to the canonical vocabulary
The canonical statuses (defined in names.py) are the strings OPTIMAL='optimal',
INFEASIBLE='infeasible', UNBOUNDED='unbounded', TIME_LIMIT='time_limit',
TIME_LIMIT_W_SOL='time_limit_w_sols', and ERROR='error'. Every backend’s solve/slim_solve/
populate maps its native status into these. The mapping is not mechanical — several native codes
carry information the abstraction must preserve or repair:
Canonical |
CPLEX ( |
Gurobi ( |
SCIP ( |
GLPK ( |
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bare |
bare |
References: CPLEX cplex_interface.py; Gurobi gurobi_interface.py; SCIP
scip_interface.py; GLPK glpk_interface.py.
Three subtleties are worth calling out because they are correctness-load-bearing:
Gurobi’s
INF_OR_UNBDdisambiguation. Gurobi’s presolve frequently returns the fused status “infeasible or unbounded.” The interface cannot let that ambiguity leak into the canonical vocabulary, so on{INF_OR_UNBD, UNBOUNDED, INFEASIBLE}it re-solves once withDualReductions = 0(which disables the presolve reduction responsible for the fusion), then reads the now-unambiguous status (gurobi_interface.py). This costs an extra solve but guarantees the enumeration loop is told exactly whether the current knockout set made the SUPPRESS behavior infeasible (the whole point of an MCS) or accidentally left the problem unbounded.CPLEX LP vs MIP code overloading. Because CPLEX reuses
2/4for LP-unbounded and118/119for MIP-unbounded (and similarly for other states), the interface lists all the variants in each branch (cplex_interface.py,246), so the sameMILP_LP.slim_solveworks whether the object was built as an LP (preprocessing) or a MIP (strain design).Rounding at the boundary. GLPK’s
solverounds bothxandoptto 12 decimals (glpk_interface.py) as a workaround for GLPK returning values like-1e-15; combined withMILP_LP.solve’s integer rounding, binary variables come back exactly0/1.
Anything a backend cannot classify raises "Case not yet handled", deliberately loud so that a new
solver-version status code is caught in testing rather than silently mismapped.
14.6 Parameters: seed, threads, tolerances, time limit, working memory
All four constructors set tolerances tighter than solver defaults, because MCS/Farkas MILPs are
sensitive to integrality slop (a spuriously “knocked-in” reaction from a z_j = 1e-6):
Parameter |
CPLEX |
Gurobi |
SCIP (MILP) |
GLPK |
|---|---|---|---|---|
optimality/dual tol |
|
|
(SoPlex LP: |
— |
feasibility tol |
|
|
(SoPlex LP: |
|
integrality tol |
|
|
emphasis-default |
|
seed |
|
|
|
not supported |
threads |
|
|
|
single-threaded |
output silenced |
log/error/warn/results streams → |
|
|
|
References: CPLEX cplex_interface.py; Gurobi gurobi_interface.py; SCIP
scip_interface.py, SoPlex scip_interface.py; GLPK glpk_interface.py.
Seed. When the caller does not supply one, each MILP backend draws a fresh seed in
[0, 2¹⁶)and sets it (cplex_interface.py,gurobi_interface.py,scip_interface.py). This makes a single run reproducible given a fixed seed but means two default runs explore the branch-and-bound tree differently — relevant when comparing wall-times (Ch 8/11 pin the seed for benchmarking). GLPK cannot set a seed — the code notes swiglpk exposes no such hook (glpk_interface.py) — so GLPK enumeration order is not seed-tunable.Threads. Only set if
milp_threadsis explicitly passed; otherwise each solver uses its own default (typically all cores). SCIP sets both min and max thread counts. GLPK rejects the option upstream inMILP_LP.__init__.Working memory (CPLEX only). For MIPs, CPLEX’s
workmemis set to 75 % of total physical RAM (cplex_interface.py, viapsutil.virtual_memory), so that CPLEX keeps its branch-and-cut node file and cut pool in memory rather than spilling to disk on the large iML1515 problems. No equivalent knob is set for the others.Time limit.
MILP_LP.set_time_limit(t)floorstat1e-3s before dispatch (solver_interface.py). The reason is documented in-line: the enumeration loop computes the remaining budget asendtime − time.timeright after a> 0guard, and a scheduling hiccup can make it zero or slightly negative; a 1 ms floor keeps the value valid because Gurobi rejects a negativeTimeLimitand, critically, GLPK treatstm_lim == 0as “no limit” (an unbounded run). Each backend then clamps against its own maximum: CPLEX mapsinf → timelimit.max(cplex_interface.py); GLPK stores milliseconds and caps at its initialtm_lim(glpk_interface.py); SCIP caps at itslimits/timemax (scip_interface.py);SCIP_LP.set_time_limitis a no-op — SoPlex LP solves are not time-limited (scip_interface.py).LP method / warm start.
set_lp_methodmaps the neutral constantsLP_METHOD_{AUTO,PRIMAL,DUAL,BARRIER}to each solver’s code (cplex_interface.py,gurobi_interface.py,scip_interface.py,glpk_interface.py). Barrier is unavailable on GLPK (falls back to dual, with a warning) and on SoPlex (SCIP_LP.set_lp_methodis a no-op). Basis extraction (get_basis/set_basis) is supported on CPLEX, Gurobi, and GLPK for warm-starting LP re-solves;SCIP_MILPrefuses it (the LP basis is discarded after a MIP solve —scip_interface.py), andSCIP_LPreconstructs an approximate basis fromgetBasisIndsplus solution values (scip_interface.py).
No MIP optimality gap is set anywhere. Neither mip.tolerances.mipgap (CPLEX) nor MIPGap
(Gurobi) appears in these constructors, so both solvers run at their default relative gap of
1e-4. For MCS enumeration this is benign for correctness — every design that survives an integer
cut is re-checked — but it does mean “optimal” is optimal to 1e-4, which is why the integer
variables are hard-rounded on the way out (Section 14.3).
14.7 The solution pool: native (CPLEX, Gurobi) vs emulated (SCIP, GLPK)
populate(n) is where POPULATE enumeration harvests many optimal-cost designs at once instead of
re-solving after each integer cut. The two commercial solvers do it natively; the two open-source
ones fake it.
CPLEX (cplex_interface.py). populate sets the pool capacity and the populate limit
to n (or their maxima for n = inf), calls populate_solution_pool, translates the status, and
harvests every pool member via self.solution.pool.get_values(i). The pool behaviour is governed
by three parameters fixed in the constructor (cplex_interface.py):
mip.pool.absgap = 0.0, mip.pool.relgap = 0.0, and mip.pool.intensity = 4. Absgap/relgap = 0
mean only solutions matching the optimal objective are retained — exactly what MCS enumeration
wants (all minimum-cost designs, nothing worse); intensity = 4 is CPLEX’s most aggressive pool-
generation effort. These three parameters are inert for a single solve: solve calls
super.solve, not populate_solution_pool, so during ANY/BEST the pool is never populated
and stays empty (verified; CONTEXT). They matter only inside populate. This is not a performance
bug — it is simply that pool configuration only takes effect on the pool-generating call.
Gurobi (gurobi_interface.py). populate sets PoolSolutions = n (or MAXINT),
PoolSearchMode = 2 (“find the n best solutions, systematically”), and raises NumericFocus = 2
for the pool sweep, solves, then restores PoolSearchMode = 0 and NumericFocus = 0 so an
ensuing single solve is not slowed by pool search. The equivalent of CPLEX’s absgap/relgap = 0
is set in the constructor: PoolGap = 1e-9 and PoolGapAbs = 1e-9 (gurobi_interface.py),
i.e. keep essentially only optimal-objective solutions. Harvesting (getSolutions,
gurobi_interface.py) iterates the pool via SolutionNumber and — this is the important
filter — keeps a pool member only if its PoolObjVal == ObjVal, dropping any non-optimal-cost
solution Gurobi may have parked in the pool. Without this filter the pool could return designs above
the minimum cost.
SCIP (scip_interface.py) and GLPK (glpk_interface.py) have no native
pool, so populate is emulated by a high-level loop that reproduces the pool semantics:
solve to optimality; keep
x, recordmin_cx;pin the objective to optimality by adding the row
cᵀx ≤ min_cx— so every further solution has the same (minimum) cost;add an exclusion (integer-cut) constraint on the current solution’s binaries so it cannot recur;
loop: solve, and if still optimal, exclude and append — until the problem becomes infeasible (no more min-cost designs) or the time budget runs out;
tear down the temporary rows by freeing their RHS to
+∞(SCIPchgRhs(..., None),scip_interface.py; GLPKset_ineq_constraint(j, 0, inf),glpk_interface.py), rather than deleting them (row deletion is “very unstable” in GLPK, per the in-code comment).
Two backend-specific wrinkles: the exclusion constraint’s binary set is recomputed each pass, and
GLPK must treat integer variables as binaries too, because GLPK silently promotes a binary whose
bounds are pinned to 0 into an integer variable (glpk_interface.py), which would
otherwise be missed by the exclusion. If the loop exits by infeasibility it is relabelled OPTIMAL
(the pool was exhausted, not a genuine failure) — scip_interface.py,
glpk_interface.py. SCIP’s emulated exclusion uses addExclusionConstraintIneq
(scip_interface.py), the classic Σ_{j∈S} z_j − Σ_{j∉S}(…) ≤ |S| − 1 cut restricted to
the binary variables.
The performance consequence: on SCIP/GLPK, POPULATE pays one full MILP solve per design found, whereas CPLEX/Gurobi amortize many designs into a single branch-and-cut tree. For the 393-design iML1515 gene-MCS benchmark this is the difference between a viable and an impractical run — another reason the open-source backends are validation tools, not production engines, for large problems.
14.8 Numeric-status robustness
Genome-scale strain-design MILPs are numerically nasty for reasons detailed in Ch 11: the big-M rows for PROTECT behaviors, the wide dynamic range of stoichiometric coefficients, and the Farkas-dual normalization all inflate the condition number, so even a “correct” model can drive a solver into scaled-versus-unscaled disagreement. Historically these states crashed the pipeline; the interface now degrades gracefully, accepting a usable-but-caveated solution with a warning instead of raising.
CPLEX status 5/6 — “optimal / best with unscaled infeasibilities.” These mean CPLEX found a solution that is optimal in the scaled problem but shows small infeasibilities when unscaled. Both
solveandslim_solvenow take the objective value, log a warning recommending tighter bounds, and return it asTIME_LIMIT_W_SOL(a “usable but not certified-exact” status) rather than hitting the"Case not yet handled"branch (cplex_interface.py,250–251). Downstream,verify_sd(Ch 8) will re-check the design, so accepting the caveated incumbent is safe.Gurobi status 12 —
NUMERIC. On numerical failure,solveretries once with the strongest setting,NumericFocus = 3, restores the previous focus, and then: if now optimal, reportOPTIMAL; else if any incumbent exists (SolCount > 0), accept it asTIME_LIMIT_W_SOLwith a warning; else reportTIME_LIMITwith no solution — never a crash (gurobi_interface.py).slim_solvemirrors this by returning the incumbent objective if one exists, elsenan(gurobi_interface.py), andpopulatepre-emptively runs the pool sweep atNumericFocus = 2.Gurobi 13 indicator-presolve bug (error 10005). A separate robustness layer,
_safe_optimize(gurobi_interface.py), wraps everyoptimizecall: Gurobi 13 can raiseGurobiError 10005(“Unable to retrieve attribute ‘ObjBound’”) when indicator constraints meet presolve. Rather than disabling presolve globally (~1.6× slowdown), it catches only that error only when indicators are present and retries withPresolve = 0, Crossover = 1. This is why the constructor recordsself._has_indicator_constr.SCIP
'unknown'and GLPK undefined states are handled in the same spirit: SCIP salvages an incumbent ifgetSolsis non-empty, else reportsTIME_LIMIT(scip_interface.py,270–271); GLPK’s bare-exceptpaths returnERROR/-1cleanly rather than propagating a swiglpk crash (glpk_interface.py), and the LP pre-solve insolve_MILP_LPretries with presolve on ifglp_simplexreturnsGLP_EFAILon a feasible LP (glpk_interface.py).
The common design principle: a numerically caveated but present solution is returned as
TIME_LIMIT_W_SOL and left for the outer verification to accept or reject, never crashing the
enumeration mid-run.
14.9 Where the CPLEX-vs-Gurobi performance story physically lives
The interface choices in this chapter are the physical substrate of the headline benchmark
(CONTEXT): the canonical iML1515 gene-MCS run (SUPPRESS biomass ≥ 0.001, POPULATE, max_cost = 3,
gene KOs) finds 393 MCS in Gurobi 280 s vs CPLEX 1241 s (≈ 4.4×), with the split
preprocessing FVA ~117 s, MILP build ~4 s, populate ~1101 s. Reading that against the code:
The gap is in
populate, not construction. Both backends receive the same abstract MILP with the same native indicator constraints and the same default1e-4MIP gap; construction is ~4 s either way. The ~1101 s populate phase is a single native pool search on each solver, and the 4.4× difference is the two solvers’ pool-search engines exploring the design space at different rates — not a formulation asymmetry this layer introduces. This is why the CPLEX pool parameters, though set since 2022, are not the culprit: they are inert duringsolveand, inpopulate, they configure the pool identically in spirit to Gurobi’sPoolGap/PoolSearchMode.Per-LP overhead in preprocessing goes through this layer. The ~117 s of blocked/irreversible FVA is thousands of small LPs, each a
slim_solveon a freshly constructed backend object. Gurobi mitigates the per-object cost by sharing one quietEnvacross all models (gurobi_interface.py,_get_quiet_env) — creating a Gurobi environment per model would spin up a licence session each time, which on a node-locked HPC licence is expensive. CPLEX constructs a freshCplexper object (and sizesworkmemto 75 % RAM each time). For a run that instantiates the interface thousands of times, this fixed per-solve overhead — object creation, parameter setting, matrix load — is real and is paid insideMILP_LP.__init__and the backend constructors, which is exactly whyslim_solve(no solution-vector extraction) andskip_checksexist as fast paths.The abstraction does not tax the hot path with translation. Matrices are handed to each solver in its preferred bulk form (CPLEX
set_coefficientson COO triplets, GurobiaddMConstron the sparse matrix directly, GLPK a singleglp_load_matrix), so the per-call cost is solver-native assembly, not a Python re-encoding loop — with the exception of SCIP, whose term-by-termExprassembly (scip_interface.py) is inherently slower and compounds its lack of a native pool. This is the mechanical reason SCIP and GLPK, while correct, are validation backends rather than the engines behind the benchmark numbers.
For the enumeration-loop mechanics that drive these calls and the deeper benchmark analysis, see Ch 8 and Ch 11; for the conditioning that provokes the Section 14.8 numeric states, see Ch 11.
15. Analysis & exploration API
Everything documented so far serves one endpoint — compute_strain_designs. But straindesign
also ships a second, smaller surface that has nothing to do with the MILP: a set of standalone
LP-based tools for inspecting a model or a design after the fact. You call these directly, on a
cobra.Model, to ask “what growth rate does this network support?”, “what is the flux range of every
reaction?”, “what does the growth-vs-product trade-off look like before and after I knock out these
reactions?”, “what is the maximal product yield per mole of substrate?”. None of them is invoked
inside the compute pipeline (the preprocessing FVA calls, Ch 5, reach a different internal
entry point in speedy_fva; here we document the public wrappers). They all live in
lptools.py — “a collection of functions for the LP-based analysis of metabolic networks”
(lptools.py) — and are re-exported at package top level (__init__.py,
from .lptools import *), so a user writes from straindesign import fba, fva, plot_flux_space, yopt.
This chapter covers six public entry points and their private helpers:
Function |
|
Returns |
Purpose |
|---|---|---|---|
|
438 |
|
optimize a linear objective (FBA / pFBA) |
|
245 |
|
per-reaction min/max flux ranges |
|
733 |
|
optimize a ratio (yield) via linear-fractional programming |
|
1406 |
|
2D/3D projection of the flux polytope (production envelope, yield space) |
|
617 |
|
FBA on a compressed model, objective returned in original units |
|
955 |
|
lift a compressed flux vector back to the original reactions |
The mathematics of the underlying LPs (Sv = 0, bounds, the flux polytope, FBA/FVA standard forms,
LP duality) is Ch 2; the internal preprocessing use of FVA and the speedy_fva acceleration is Ch 5;
the compression map (reac_map_exp) and how interventions are decompressed is Ch 3/9. This chapter
does not re-derive those; it documents the API contract — signatures, options, return shapes —
and the new mathematics that only appears here: the production-envelope scan, the Charnes–Cooper
transform behind yopt, and the flux-vector expansion (as opposed to the intervention-set expansion
of Ch 9).
A note that pervades the whole module: the objective sign convention. Every one of these functions
builds its LP through MILP_LP (Ch 14), whose solve/slim_solve minimize c·x. A user
request to maximize is therefore serviced by negating c and negating the returned optimum. You
will see c = [-i for i in c] and opt_cx = -opt_cx repeatedly; that is this convention, not a bug.
15.1 fba — flux balance analysis and its parsimonious variants
Contract
sol = fba(model, obj=..., obj_sense='maximize', constraints='...', pfba=0, solver=None)
fba (lptools.py) solves
max / min cᵀ v
subject to S v = 0 (steady state, S ∈ ℝ^{m×n})
A_ineq v ≤ b_ineq (user constraints)
A_eq v = b_eq (user equality constraints)
lb ≤ v ≤ ub (model bounds)
and returns a cobra.core.Solution carrying objective_value, the flux vector fluxes (a
{reaction_id: value} dict), and a status string. The keyword options:
obj(lptools.py) — the objective, as a reaction-ID string, a linear-expression string ('2 EX_etoh_e - EX_ac_e'), or a dict{'EX_etoh_e': 2, 'EX_ac_e': -1}. A string is parsed bylinexpr2dictand then densified to a coefficient row bylinexprdict2mat. Ifobjis omitted, the objective is read from the model itself:c = [i.objective_coefficient for i in model.reactions].obj_sense(lptools.py) —'maximize'/'max'(default, inferred frommodel.objective_directionwhenobjis not given) or'minimize'/'min'. Maximization is realized by negatingc.constraints(lptools.py) — extra linear constraints layered on top of the model, in any of the flexible input forms (string, list of strings, list of[dict, sign, rhs]). These pass throughresolve_gene_constraints(so a constraint may name a gene:'b0008 = -1'becomes the reaction-level effect of knocking that gene out; see Ch 12) and thenparse_constraints/lineqlist2mat, which turn them intoA_ineq, b_ineq, A_eq, b_eqrows (Ch 12 owns this grammar). The stoichiometric blockS v = 0is stacked on top of the user’s equality rows (lptools.py).solver(lptools.py) —'glpk' | 'cplex' | 'gurobi' | 'scip', resolved byselect_solver(lptools.py): a supplied name wins if available; otherwise the solver named in the model, then the cobra configuration, then the first available in priority orderglpk, cplex, gurobi, scip.pfba(lptools.py) — the parsimonious-FBA level, discussed next.
Unbounded-objective repair
If the primal comes back UNBOUNDED (lptools.py) the objective can grow without limit, so
there is no finite optimal v to report — but the user still wants a representative flux vector on the
ray. The code re-solves the opposite objective to find the extreme in the bounded direction
(min_cx = num_prob.slim_solve) and then pins the objective to a finite value with an added
equality row (add_eq_constraints(c, min_cx), or to -1.0 when the reversed optimum is non-positive)
before solving once more. The returned objective_value stays the unbounded signal; the flux vector is
a concrete point on the unbounded face. This is a deliberate usability choice: return something
plottable rather than an empty solution.
Parsimonious FBA (pfba)
Plain FBA fixes the objective value but leaves the rest of v under-determined — many flux
distributions achieve the same growth. pFBA picks a biologically motivated representative among them by
adding a secondary objective after pinning the primary optimum opt_cx (add_eq_constraints(c_pfba, [opt_cx])). Two levels:
pfba=1— minimize total flux (lptools.py). Each reaction is split into a forward and reverse partv = v⁺ − v⁻,v⁺, v⁻ ≥ 0, by horizontally stackingAwith−Aand building the split boundslb_pfba = [max(0, l)] + [max(0, -u)],ub_pfba = [max(0, u)] + [max(0, -l)]. Minimizing∑(v⁺ + v⁻) = ∑|vⱼ|(objective[1.0]*2n) subject to the pinned primal gives the flux vector with the smallest 1-norm — the “minimal enzyme usage” distribution. The reported flux is recomposedx = v⁺ − v⁻(lptools.py).pfba=2— minimize the number of active reactions (lptools.py). This is a genuine MILP, not an LP. First an FVA (with the primal pinned,kwargs_fva[...].append([{...}, '=', opt_cx])) finds which reactions are essential under the optimum — a reaction whose min and max fluxes share a sign (prod(sign(lim)) > 0) cannot be switched off, so it is excluded from the knockable set (ub_pfba2 = ... 0.0 if prod(sign(lim)) > 0 else 1.0). Then a binaryy_jper remaining reaction is wired by an indicator constrainty_j = 1 ⇒ v_j = 0(IndicatorConstraints([...], A_ic, [0]*numr, 'E'*numr, [1.0]*numr),lptools.py) and∑ −y_jis minimized, i.e. the count of forced-zero reactions is maximized. The reactions selected zero are fixed tolb=ub=0and level-1 pFBA is then run on the reduced network. This yields the sparsest-support flux distribution.
The compressed-flux hook
fba accepts two undocumented-in-signature kwargs, cmp_map and orig_reaction_ids
(lptools.py). If both are present the resulting flux dict — computed on whatever model was
passed — is run through expand_fluxes (§15.5) to yield fluxes keyed by the original reaction IDs.
This is the glue that lets you FBA a compressed model but read the answer in original terms.
15.2 fva — flux variability analysis as a public tool
df = fva(model, constraints='EX_o2_e=0', solver='gurobi',
compress=None, threads=None, reaction_list=None)
fva (lptools.py) determines, for every reaction, the full range [min vⱼ, max vⱼ] reachable at
steady state under the model bounds and any extra constraints. Mathematically it is 2n linear
programs — for each reaction j, minimize and maximize vⱼ over the same polytope FBA uses (Ch 2 gives
the standard form; fva_legacy, lptools.py, is the literal brute-force 2n-LP reference kept for
debugging). The return is a pandas.DataFrame indexed by reaction ID with two columns, minimum and
maximum.
The public fva is a one-line delegator (lptools.py):
from straindesign.speedy_fva import speedy_fva
return speedy_fva(model, **kwargs)
so its real options are speedy_fva’s (speedy_fva.py), and the acceleration mathematics is Ch 5.
For the API contract, the options a user sets are:
constraints,solver— as forfba(gene IDs are resolved, strings parsed to matrix rows).compress(speedy_fva.py, defaultNone) — whether to lump flux-coupled reactions and drop conservation rows before the scan, then map results back. WhenNoneit auto-enables for models with ≥ 200 reactions (compress = n_original >= 200). Compression shrinks the LP and makes each of the2nsolves cheaper; the ranges of lumped reactions are recovered from the representative’s range. This is the same coupled compression as Ch 3 but applied transiently, purely to speed the scan.reaction_list(defaultNone) — restrict the scan to a subset of reactions, so you pay for2kLPs instead of2n. Used heavily by the internal pipeline (Ch 5’s knockable-scoped FVA) but available to users who only care about a handful of reactions.threads(defaultNone) — parallel worker count; auto-set toConfiguration.processesfor models with ≥ 1000 reactions, else 1 (speedy_fva.py). The multiprocessing machinery (SDPool, thefva_worker_*init/compute helpers atlptools.py, with a GLPK-specific path because GLPK cannot solve in a spawned thread) is shared with the legacy implementation.
An infeasible base problem yields a DataFrame of NaNs rather than an exception (fva_legacy
demonstrates this at lptools.py). Fluxes with |v| < 1e-11 are snapped to 0.0
(lptools.py) to suppress solver noise.
A companion utility, remove_redundant_bounds (lptools.py), runs fva and then relaxes every
non-binding bound in place: if fva_min > lb + tol the lower bound never binds, so it is set to −inf;
symmetrically for the upper bound. It returns the FVA DataFrame and mutates the model. This is the
user-facing sibling of the internal bound_blocked_or_irrevers_fva (Ch 5) — the same idea (a bound the
network can never reach is redundant and only bloats big-M constants downstream) offered as a
standalone model-cleanup step.
15.3 yopt — yield optimization by linear-fractional programming
What and why
FBA maximizes a linear objective. But the quantity a metabolic engineer most wants to push is often a ratio: product formed per substrate consumed,
cᵀ v (numerator: e.g. 2·EX_etoh_e)
Y = ─────
dᵀ v (denominator: e.g. −6·EX_glc__D_e)
The coefficients let you express carbon recovery directly — 2 EX_etoh_e / -6 EX_glc__D_e is
(2 C in ethanol) per (6 C in glucose). A ratio of two linear forms over a polytope is a
linear-fractional program (LFP); it is not an LP, but a classical result — the Charnes–Cooper
transformation — converts it to one exactly.
sol = yopt(model, obj_num='2 EX_etoh_e', obj_den='-6 EX_glc__D_e',
obj_sense='maximize', constraints='EX_o2_e=0', solver=None)
yopt (lptools.py) requires obj_num and obj_den (each a string or dict; missing either raises,
lptools.py) and returns a Solution.
The transform
Charnes–Cooper: to maximize (cᵀv)/(dᵀv) over {v : Av ≤ b} with dᵀv > 0, substitute
t = 1 / (dᵀv) > 0, y = t · v.
Then dᵀy = t·dᵀv = 1, the fractional objective becomes the linear cᵀy, and each original
constraint Aᵢᵀv ≤ bᵢ homogenizes to Aᵢᵀy ≤ bᵢ t, i.e. Aᵢᵀy − bᵢ t ≤ 0. The LFP is thus the LP
max cᵀ y
s.t. A_ineq y − b_ineq t ≤ 0
A_eq y − b_eq t = 0
dᵀ y = 1, (t free ≥ 0)
and the original flux is recovered as v = y / t.
The code builds precisely this (lptools.py). It appends one extra column — the scale variable
t — to every matrix:
inequalities:
A_ineq_lfp = [A_ineq | −b_ineq],b_ineq_lfp = 0(lptools.py);equalities: stack
[A_eq | −b_eq]with the normalization row[obj_den | 0], RHS[0,…,0, d](lptools.py);objective
c = [−d·obj_num | 0], minimized (the−dand the outer sign flip restore the requestedmax/min,lptools.py,896–897).
Note the bounds are first folded into A_ineq as explicit rows (lptools.py) — because in the
homogenized problem a finite bound vⱼ ≤ uⱼ must also become yⱼ − uⱼ t ≤ 0, so it cannot stay a plain
variable bound. Only finite bounds are added (isinf filtered).
After solving, factor = x[-1] is t, and the reported flux is x[i] / factor (lptools.py);
if t = 0 the flux vector is scalable by any positive factor and sol.scalable = True is set.
The sign of the denominator — den_sign
The transform assumes dᵀv keeps a fixed sign over the polytope; the normalization dᵀy = 1 (or
= −1) implicitly chooses it. But a user’s denominator could be positive on part of the polytope and
negative on another, or fixed at zero. yopt handles this robustly (lptools.py) by first
solving for the min and max of dᵀv:
if
min dᵀv < 0,−1is a viable normalization sign;if
max dᵀv > 0,+1is viable;if neither (the denominator can only be
0), the yield is undefined — returnINFEASIBLE.
It then solves the LFP once per attainable sign in den_sign and keeps the better optimum
(lptools.py). The documented failure taxonomy (lptools.py) maps directly onto the
return values:
Situation |
Return |
|---|---|
base model infeasible |
|
denominator fixed to 0 |
|
numerator unbounded while denominator can be 0 |
|
denominator can reach 0 (yield undefined) |
flux vector maximizing the numerator, warning logged |
The UNBOUNDED branch (lptools.py) is the subtle one: an infinite yield means the numerator
grows while the denominator stays fixed near zero, so the code separately checks whether the numerator is
bounded when dᵀv = 0 is added as a constraint, and returns a representative flux accordingly.
15.4 plot_flux_space — production envelopes, yield space, and 3D projections
What it visualizes
plot_flux_space (lptools.py) projects the (high-dimensional) steady-state flux polytope onto 2 or
3 user-chosen axes and draws the resulting shadow. The two canonical uses:
Production envelope — x = growth rate, y = product exchange. The shape shows, for every attainable growth rate, the min and max product rate; a strain design “works” when the envelope’s lower boundary is lifted off zero at high growth (product becomes coupled to growth). This is the single most common way to validate that a computed MCS actually forces production.
Yield-space plot — x = biomass yield, y = product yield (each a ratio), giving the trade-off in per-substrate terms.
plot_flux_space(model, ('BIOMASS_Ecoli_core_w_GAM', 'EX_etoh_e')) # 2D rate–rate
plot_flux_space(model, [['BIOMASS','-EX_glc_e'], ['EX_etoh_e','-EX_glc_e']]) # 2D yield–yield
plot_flux_space(model, ('r1','r2','r3')) # 3D
Axis grammar
Each axis in axes is either a single linear expression → a 'rate' axis (len(ax)==1,
lptools.py), or a [numerator, denominator] pair → a 'yield' axis (len(ax)==2,
lptools.py). A rate axis is scanned with fba; a yield axis with yopt (§15.3). Two or three
axes are allowed (lptools.py, else raise). Options mirror the other tools plus:
constraints,solver— as before, applied to every internal LP so you can plot the envelope of a sub-model (e.g. add the knockouts of a candidate design asconstraintsand see how the envelope changes).points(lptools.py, default 40 in 2D, 25 in 3D) — resolution of the approximate (yield-containing) regions only. For pure rate axes the boundary is traced exactly (see below) andpointsis ignored.show(defaultTrue),plt_backend,cmap(default'managua', for 3D face colouring).
The return is always (datapoints, triang, plot1): datapoints are the computed boundary points,
triang a list of index-triples describing how to connect them into a closed surface, and plot1 the
matplotlib artist. A user who wants a custom figure sets show=False and rebuilds from datapoints.
The mathematics of a production envelope
For each axis the code first finds the overall range by optimizing that axis both ways
(lptools.py for rate, 1562–1563 for yield). val_limits[i] = [min, max] and the drawing
window ax_limits is padded to include the origin (lptools.py). An axis whose min ≈ max is
degenerate; _detect_degeneracy (lptools.py) classifies the projection as point, line,
plane, or full by counting degenerate axes, and each class has its own cheap drawing path
(lptools.py, 1752–1820) rather than a wasted full scan.
For the non-degenerate 2D case the boundary is traced by one of two algorithms:
Rate–rate → exact convex polygon (
_trace_polygon_rate_rate,lptools.py). The projection of a polytope under a linear map is again a convex polytope, so the boundary is a polygon with finitely many vertices, each the maximizer of some direction. The algorithm finds the four axis extremes, orders them CCW byatan2about the centroid, then recursively refines each edge: for edge(vᵢ, vⱼ)it optimizes the outward normal directionn = (dy, −dx)(one FBA with objectivenₓ·ax₀ + n_y·ax₁,lptools.py); if the maximizer lies beyond the edge (dist > tol), it is a new vertex and both sub-edges recurse. This isO(V)LPs forVvertices and returns the polygon exactly — no discretization error. This is whypointsis irrelevant for production envelopes.Yield-containing → adaptive upper/lower boundary (
_trace_boundary_adaptive,lptools.py). A yield axis makes the region non-polygonal, so the boundary is traced as two functions of the x-axis: scan x, and at each x fix axis-0 to that value (_make_fix_constraint,lptools.py— for a yield axis this fixesnum − value·den = 0, i.e. the ratio, as a linear equality) and optimize axis-1 up and down. Midpoints are refined recursively wherever the true boundary deviates from the linear interpolation by more thanabs_tol(lptools.py), to a depthmax(5, log2(points))(lptools.py). The polygon isupper + reversed(lower). Such plots are labelled'approximate'on the axes (lptools.py).
The 3D paths generalize this: pure-rate axes get an exact polytope by ConvexHull + iterative
face-normal refinement (_trace_polytope_3d_rate, lptools.py — optimize each hull face’s outward
normal, add any new vertex, repeat until no face yields one), with coplanar simplices merged into polygon
faces (_hull_face_polygons, lptools.py) for clean rendering. One yield axis triggers
slicing: scan the yield level, trace an exact rate–rate polygon per slice, and stitch adjacent slices
into a triangle mesh (_trace_3d_slice_polygon, lptools.py; _triangulate_strips,
lptools.py). Two or more yield axes fall back to a full grid scan (lptools.py).
Faces are coloured by normal direction through the chosen cmap (_normal_color, lptools.py).
To use it for design validation: plot the envelope of the wild-type, then call again with the design’s
knockouts injected as constraints (e.g. constraints=['ACALD = 0', 'PFL = 0']) and overlay
(show=False, reuse the axes). A successful growth-coupled design shows the lower boundary of the second
envelope rising above zero at the growth optimum.
15.5 Compressed-analysis tools (PR #56)
Genome-scale FBA/plots are cheap individually, but a production envelope or a 3D scan issues hundreds of LPs, and a plot of an iML1515-sized model can be slow. PR #56 added a path to do the LP work in the compressed space (Ch 3) — where the network is a fraction of the size — and lift the answers back to original reactions. Three pieces cooperate.
expand_fluxes — lifting a flux vector
expand_fluxes(fluxes_cmp, cmp_map, orig_reaction_ids) (lptools.py) reverses the compression to
recover a flux for every original reaction. The compression map is a list of step-dicts; each step’s
reac_map_exp is { cmp_id : { orig_id : factor, … } } — the reactions before the step that were
lumped into each compressed reaction, with the rational scaling that made the merge exact (the same
structure Ch 9 uses, networktools.py). The algorithm walks the steps in reverse
(lptools.py):
for step in reversed(cmp_map):
for cmp_id, orig_map in step["reac_map_exp"].items():
v_cmp = fluxes.pop(cmp_id, 0.0)
for orig_id, factor in orig_map.items():
fluxes[orig_id] = factor * v_cmp
so each original reaction’s flux is v_orig = factor · v_cmp. The factor’s meaning depends on the merge
type (Ch 3):
coupled reactions —
factoris the stoichiometric coupling coefficient, so the split is exact and deterministic;parallel reactions — the total compressed flux is distributed by the stored proportional factors;
removed reactions (blocked / never appear in any step) — set to
0.0(lptools.py).
This is the crucial contrast with Ch 9’s expand_sd, which lifts an intervention set and therefore
ignores the factors (it only needs the set of original IDs behind a compressed one). Here we lift a
quantity, so the factors are load-bearing — dropping them would give wrong flux magnitudes.
slim_fba_via_cmp — objective-only FBA on the compressed model
slim_fba_via_cmp(model, cmp_model, cmp_map, obj=..., constraints=..., ...) (lptools.py) returns
just the optimal objective value, in original-model units, without ever materializing a full flux
vector — the cheapest possible compressed FBA. It:
resolves and compresses the constraints (
resolve_gene_constraintsthencompress_constraints, which applies the same coefficient-scaling as the compression map,lptools.py);traces only the objective reactions through the compression steps to accumulate their cumulative coupling factor and map them to compressed IDs (
lptools.py) — this is the “slim” part: instead of expanding anO(n)vector it follows only the handful of reactions inobj. The compressed objective coefficient iscoeff · cum_factor;solves with
slim_solve(objective only, no vector) and returns−opt_cx/opt_cxperobj_sense(lptools.py). Becausecwas pre-scaled bycum_factor, the returned number is already in original units.
Use it when you need to evaluate a design’s objective (say, max growth) thousands of times — e.g. inside an outer search or a batch scan over candidate constraint sets — on a large model.
Compressed plot_flux_space
plot_flux_space accepts optional cmp_model and cmp_map kwargs (lptools.py). When both
are supplied it:
resolves gene constraints on the original model and compresses them (
compress_constraints);maps each axis’s reaction IDs to compressed IDs via
_build_cmp_reverse_map(networktools.py— walks forward through steps building{orig_id : final_cmp_id}), while tracing the cumulative coupling factor per axis into_ax_scale(lptools.py);switches
model = cmp_modeland runs the entire tracing machinery in the small space;finally rescales the traced coordinates back to original units by multiplying by
_ax_scale(lptools.pyfor limits,1660–1668for the polygon vertices) and restores the original reaction names as axis labels (lptools.py).
The user sees an envelope drawn in original-reaction coordinates, but every LP behind it ran on the compressed network. All the coupling factors are applied at the end, so the picture is quantitatively identical to the uncompressed one (up to the exact rational scalings of Ch 3).
15.6 Where these fit the developer’s workflow
These functions form the exploration and reporting layer that brackets a compute_strain_designs
run:
Before — characterize the model.
fba(model)confirms the wild-type growth rate;fva(model)(orremove_redundant_bounds) finds blocked/essential reactions and cleans non-binding bounds;plot_flux_space(model, (growth, product))shows the baseline production envelope, revealing whether the target is even producible and how far it sits from growth coupling.yoptgives the theoretical maximum yield the design could ever reach.After — validate a returned design. Take an
SDSolutionsentry (Ch 13), feed its knockouts asconstraintstofba/yopt/plot_flux_space, and confirm that the SUPPRESS behavior is now impossible (product-free growth infeasible / envelope lower bound lifted) while the PROTECT behavior survives (growth still ≥ threshold). The overlaid production envelope is the standard figure for “this design works”.At scale / batch — when validating hundreds of candidate designs on a genome-scale model, the PR #56 compressed tools (
slim_fba_via_cmpfor objective sweeps, compressedplot_flux_spacefor envelopes,expand_fluxesto report a representative flux vector in original terms) let all this LP work happen in the compressed space the pipeline already built, so exploration costs a fraction of the naïve genome-scale price.
None of this touches the MILP or the compute pipeline; it is the read-only microscope you point at a model or a solution.
16. Testing & contributing
Tests live in tests/ (pytest, one file per feature area) and are run with:
pytest tests -v --log-cli-level=INFO --junit-xml=test-results.xml
CI matrix (.github/workflows/CI-test.yml): OS ubuntu-latest / windows-latest; Python
3.10–3.13; both pip and conda. CPLEX is excluded for Python 3.13 (max supported: 3.12). A
JPype/JVM-shutdown segfault on Ubuntu is tolerated via a JUnit-XML exit-code check rather than the raw
process exit code.
Correctness gates. The canonical known-answer tests are the ones to keep green after any change to
the pipeline: gene-level MCS on e_coli_core = 455 solutions, and on iML1515 = 393. These
exercise compression, GPR extension, MILP construction, and enumeration end-to-end.
What to re-verify after changes:
Constraint parsing — any change to
parse_constr.pymust be checked against all input formats (string, list of strings, list of structured constraints; see Ch 12).MILP construction — after touching
link_z,build_primal_from_cbm, or the dualization functions (Ch 6, Ch 7): run a small toy model and check solutions against known answers.Compression — changes to
compression.py/networktools.pymust verify thatexpand_sdreconstructs original-space solutions correctly (Ch 9), and that every returned design still satisfies all PROTECT modules when re-evaluated on the original model (Ch 10).Solver backends — changes to any
*_interface.pyrequire testing with that solver installed (Ch 14).GPR translation — changes to
networktools.extend_model_gprorSDSolutions._translate_genes_to_reactionsmust be tested with models that have AND/OR GPR logic (e.g.,iJO1366).
Adding tests. Use a small toy model (3–5 reactions) for unit tests of MILP construction;
e_coli_core for full-pipeline integration tests; iJO1366 / iML1515 for performance regressions.
Test with at least GLPK (always available) plus one commercial solver where possible. Because the
branch-and-bound tree is seed-dependent, any timing comparison should use several seeds and report a
distribution, not a single run (Ch 8, Ch 11).
Profiling.
import cProfile, pstats
cProfile.run("compute_strain_designs(model, sd_modules=[...], solver='glpk')", 'profile_out')
pstats.Stats('profile_out').sort_stats('cumulative').print_stats(30)
The hot spots are typically the preprocessing FVA, link_z (its per-constraint LP bounding), and the
solver’s enumeration loop (Ch 11).