Multi-condition FBA#

Multi-condition flux balance analysis represents several metabolic contexts in one optimization problem. Every condition has its own flux vector, reaction bounds, and objectives, so media, perturbations, or required phenotypes can differ while the underlying metabolic model remains the same.

In CORNETO, MultiSampleFBA.build_many constructs this condition-by-reaction flux matrix directly. This supports two related uses:

  • solve and inspect several condition-specific FBA problems through one consistent interface;

  • couple reaction selection across conditions to identify a compact shared program and the reactions needed by particular contexts.

The second use is the main advantage of the multi-condition formulation: all conditions influence reaction selection before the networks are compared.

Independent and coupled formulations#

Without regularization, the condition-specific flux vectors obey separate steady-state constraints and can be interpreted as several FBA problems built together. With lambda_reg > 0, CORNETO adds structured sparsity across the complete flux matrix.

For each reaction, CORNETO records whether it is active in each condition and whether it is active in the union. A reaction used by one or several conditions contributes once to the union-level penalty. Reusing a reaction is therefore cheaper than introducing another alternative, while condition-specific reactions remain available when required by their bounds or phenotypes.

Structured sparsity penalizes the union; it does not force identical fluxes and does not directly maximize the intersection. Shared and context-specific reactions are summaries derived from the jointly selected condition networks.

Why is this different from pairwise comparisons?#

A metabolic model usually admits many equivalent or near-equivalent flux distributions. Consider an experiment with a control and two perturbed conditions, A and B. One common strategy is to run two independent optimizations:

Optimization

Condition-specific flux solutions

Reactions coupled by one union penalty

Separate analysis 1

control and condition A

reactions used by control or A

Separate analysis 2

a new control solution and condition B

reactions used by control or B

The media are not combined: every condition keeps its own bounds and flux vector. However, the control is solved twice, once inside each independent optimization. Its selected control reaction set—the reactions carrying flux in that solution—can differ between the two runs even though its bounds and required phenotype are unchanged.

Comparing the two results afterward can therefore mix genuine biological differences with independent selection among alternative pathways. In a joint CORNETO analysis, one optimization contains one flux vector for control, one for A, and one for B, and applies a single union penalty across all three. The resulting shared and condition-dependent reaction sets are internally consistent with the complete experiment.

Relation to COBRApy#

COBRApy naturally solves FBA or pFBA independently for each medium. A user can reproduce joint selection by manually constructing a coupled mixed-integer formulation with optlang. CORNETO provides the condition-specific flux matrix and union-level structured sparsity directly through MultiSampleFBA.build_many.

Example: shared and context-specific metabolism#

The FBA guide showed how to solve several media conditions in one CORNETO problem. Here we use that interface to address a small biological question:

Which reactions form a compact metabolic program shared across metabolic conditions, and which reactions are required only in particular contexts?

We will compare aerobic growth on glucose (the control), aerobic growth on acetate, and anaerobic growth on glucose in the COBRApy E. coli core model. The conditions are deliberately simple: they illustrate joint reaction selection rather than reproduce a particular experiment. After solving them jointly, reactions can be described as:

  • shared core: active in all conditions;

  • partially shared: active in more than one condition, but not all;

  • context-specific: active in exactly one condition;

  • union: active in at least one condition.

from contextlib import redirect_stdout
from io import StringIO

import numpy as np
import pandas as pd
from cobra.io import load_model

from corneto.io import cobra_model_to_graph
from corneto.methods import MultiSampleFBA

with redirect_stdout(StringIO()):
    cobra_model = load_model("textbook")

G = cobra_model_to_graph(cobra_model)
reaction_ids = list(G.get_attr_from_edges("id"))
biomass_id = "Biomass_Ecoli_core"
biomass_index = reaction_ids.index(biomass_id)

Define the metabolic conditions#

The conditions change either the carbon source or oxygen availability. As in the previous guide, uptake is a negative exchange flux and 0 blocks uptake.

Condition

Glucose uptake

Acetate uptake

Oxygen uptake

Glucose aerobic

−10

0

−20

Acetate aerobic

0

−10

−20

Glucose anaerobic

−10

0

0

condition_names = ["glucose aerobic", "acetate aerobic", "glucose anaerobic"]

media_bounds = {
    "glucose aerobic": {
        "EX_glc__D_e": (-10.0, 1000.0),
        "EX_ac_e": (0.0, 1000.0),
        "EX_o2_e": (-20.0, 1000.0),
    },
    "acetate aerobic": {
        "EX_glc__D_e": (0.0, 1000.0),
        "EX_ac_e": (-10.0, 1000.0),
        "EX_o2_e": (-20.0, 1000.0),
    },
    "glucose anaerobic": {
        "EX_glc__D_e": (-10.0, 1000.0),
        "EX_ac_e": (0.0, 1000.0),
        "EX_o2_e": (0.0, 1000.0),
    },
}
biomass_objectives = {name: {biomass_id: -1} for name in condition_names}

Establish each condition’s growth capacity#

First, standard multi-condition FBA determines the maximum biomass supported by each medium. At this stage the flux vectors share one problem object but are not coupled by sparsity.

standard_problem = MultiSampleFBA().build_many(
    G,
    objectives=biomass_objectives,
    reaction_bounds=media_bounds,
)
standard_problem.solve(solver="scipy")

maximum_biomass = pd.Series(
    standard_problem.expr.flow.value[biomass_index, :],
    index=condition_names,
    name="maximum biomass",
)

assert (maximum_biomass > 0).all()
maximum_biomass.to_frame()
maximum biomass
glucose aerobic 0.832614
acetate aerobic 0.173339
glucose anaerobic 0.211663

Require growth and favor a compact reaction union#

For this controlled comparison, biomass is fixed at exactly 90% of each condition’s optimum. Pairwise and joint analyses therefore explain the same predefined phenotypes: network differences cannot be attributed to different achieved growth rates.

lambda_reg=0.1 penalizes reactions in the union of the selected condition networks. Because biomass is fixed, any positive regularization weight ranks feasible solutions by their union size instead of trading additional growth for sparsity.

The two helpers below keep the scientific inputs visible: one builds a sparse problem for named conditions and the other labels its flux matrix.

biomass_fraction = 0.90
biomass_targets = biomass_fraction * maximum_biomass
activity_tolerance = 1e-6


def build_sparse_problem(names):
    """Build and solve sparse FBA for the requested conditions."""
    objectives = {name: biomass_objectives[name] for name in names}
    bounds = {
        name: {
            **media_bounds[name],
            biomass_id: (biomass_targets[name], biomass_targets[name]),
        }
        for name in names
    }
    problem = MultiSampleFBA(lambda_reg=0.1).build_many(
        G,
        objectives=objectives,
        reaction_bounds=bounds,
    )
    problem.solve(solver="highs")
    return problem


def labeled_fluxes(problem, names):
    """Return the solved flux matrix with reaction and condition labels."""
    return pd.DataFrame(
        problem.expr.flow.value,
        index=reaction_ids,
        columns=names,
    )

What happens in separate two-condition analyses?#

First we build one optimization containing glucose-aerobic and acetate-aerobic flux vectors. Separately, we build another containing glucose-aerobic and glucose-anaerobic flux vectors. Each condition retains its own medium bounds; they are columns in the same optimization, not a combined medium.

Because these are independent optimizations, glucose aerobic is solved twice. We compare the active reactions selected for that identical glucose-aerobic condition in the two results.

control_acetate_names = ["glucose aerobic", "acetate aerobic"]
control_anaerobic_names = ["glucose aerobic", "glucose anaerobic"]

control_acetate_problem = build_sparse_problem(control_acetate_names)
control_anaerobic_problem = build_sparse_problem(control_anaerobic_names)

control_acetate_fluxes = labeled_fluxes(control_acetate_problem, control_acetate_names)
control_anaerobic_fluxes = labeled_fluxes(control_anaerobic_problem, control_anaerobic_names)

pairwise_control_summary = pd.DataFrame(
    {
        "analyzed with acetate aerobic": {
            "biomass": control_acetate_fluxes.loc[biomass_id, "glucose aerobic"],
            "active reactions": (
                control_acetate_fluxes["glucose aerobic"].abs() > activity_tolerance
            ).sum(),
        },
        "analyzed with glucose anaerobic": {
            "biomass": control_anaerobic_fluxes.loc[biomass_id, "glucose aerobic"],
            "active reactions": (
                control_anaerobic_fluxes["glucose aerobic"].abs() > activity_tolerance
            ).sum(),
        },
    }
)

assert np.isclose(pairwise_control_summary.loc["biomass"].iloc[0], pairwise_control_summary.loc["biomass"].iloc[1])
pairwise_control_summary
analyzed with acetate aerobic analyzed with glucose anaerobic
biomass 0.749352 0.749352
active reactions 50.000000 48.000000
control_activity = pd.DataFrame(
    {
        "analyzed with acetate aerobic": (
            control_acetate_fluxes["glucose aerobic"].abs() > activity_tolerance
        ),
        "analyzed with glucose anaerobic": (
            control_anaerobic_fluxes["glucose aerobic"].abs() > activity_tolerance
        ),
    }
)

different_control_reactions = control_activity[
    control_activity.iloc[:, 0] != control_activity.iloc[:, 1]
].copy()
different_control_reactions.insert(0, "reaction", different_control_reactions.index)

assert not different_control_reactions.empty
different_control_reactions.reset_index(drop=True)
reaction analyzed with acetate aerobic analyzed with glucose anaerobic
0 FUM True False
1 G6PDH2r False True
2 GND False True
3 ICL True False
4 MALS True False
5 MDH True False
6 NADTRHD False True
7 PGL False True
8 PPC False True
9 PPCK True False
10 SUCDi True False
11 THD2 True False

Both columns in the table describe the active reaction set for glucose aerobic, not the other condition in each analysis. Its medium and fixed biomass are identical in both runs, yet the selected reactions differ. The difference arises because each two-condition optimization can choose a different feasible pathway for glucose aerobic while minimizing its own reaction union. It is not a change in control biology. A post-hoc comparison cannot distinguish this optimization variability from genuine context specificity.

Solve all conditions jointly#

We now place all three condition-specific flux vectors in one optimization. Glucose aerobic appears only once, alongside acetate aerobic and glucose anaerobic, and a single reaction-union penalty couples the three solutions.

joint_problem = build_sparse_problem(condition_names)
joint_fluxes = labeled_fluxes(joint_problem, condition_names)
joint_activity = joint_fluxes.abs() > activity_tolerance

condition_summary = pd.DataFrame(
    {
        "biomass target": biomass_targets,
        "solved biomass": joint_fluxes.loc[biomass_id],
        "active reactions": joint_activity.sum(axis=0),
    }
)

assert (condition_summary["solved biomass"] >= condition_summary["biomass target"] - 1e-7).all()
condition_summary
biomass target solved biomass active reactions
glucose aerobic 0.749352 0.749352 48
acetate aerobic 0.156005 0.156005 53
glucose anaerobic 0.190497 0.190497 47

Shared and context-specific reaction usage#

The activity matrix has one Boolean column per condition. Counting active columns assigns every reaction in the union to exactly one usage category.

active_condition_count = joint_activity.sum(axis=1)
joint_union = active_condition_count > 0

reaction_usage = pd.DataFrame(
    {
        "reaction": joint_activity.index[joint_union],
        "active in": [
            ", ".join(joint_activity.columns[row])
            for row in joint_activity.loc[joint_union].to_numpy()
        ],
        "category": np.select(
            [
                active_condition_count[joint_union] == len(condition_names),
                active_condition_count[joint_union] == 1,
            ],
            ["shared core", "context-specific"],
            default="partially shared",
        ),
    }
)

category_counts = reaction_usage["category"].value_counts().rename("reactions")
expected_categories = {"shared core", "partially shared", "context-specific"}

assert set(reaction_usage["category"]) == expected_categories
assert int(category_counts.sum()) == int(joint_union.sum())
category_counts.to_frame()
reactions
category
shared core 31
partially shared 25
context-specific 5
usage_patterns = (
    reaction_usage.groupby(["category", "active in"], sort=False)
    .agg(
        reactions=("reaction", "size"),
        examples=("reaction", lambda values: ", ".join(values.iloc[:6])),
    )
    .reset_index()
)
usage_patterns
category active in reactions examples
0 partially shared acetate aerobic, glucose anaerobic 8 ACALD, ACKr, ACt2r, ALCD2x, ETOHt2r, EX_ac_e
1 shared core glucose aerobic, acetate aerobic, glucose anae... 31 ACONTa, ACONTb, ATPM, ATPS4r, Biomass_Ecoli_co...
2 partially shared glucose aerobic, acetate aerobic 9 AKGDH, CYTBD, EX_o2_e, FUM, MDH, NADH16
3 partially shared glucose aerobic, glucose anaerobic 8 EX_for_e, EX_glc__D_e, FORti, GLCpts, PFK, PFL
4 context-specific acetate aerobic 5 FBP, ICL, MALS, NADTRHD, PPCK

Biological interpretation#

The selected groups recover recognizable metabolic adaptations rather than isolated reactions:

Usage pattern

Representative selected reactions

Interpretation

Glucose anaerobic only

ACALD, ALCD2x, ETOHt2r, EX_etoh_e

A complete ethanol-fermentation route, from acetyl-CoA reduction to ethanol secretion, that supports redox balancing without respiration.

Acetate aerobic only

ICL, MALS, FBP, PPCK, NADTRHD

The glyoxylate shunt and gluconeogenesis conserve acetate carbon and produce biomass precursors; transhydrogenase contributes cofactor balancing.

Both aerobic conditions

CYTBD, NADH16, EX_o2_e, AKGDH

Oxygen uptake together with respiratory-chain and TCA-cycle activity shared by aerobic growth.

These are interpretations of one sparse model solution, not evidence that the reactions are biologically exclusive to those conditions.

Activity does not imply the same flux direction. The categories above use abs(flux) > activity_tolerance. A reaction shared by two conditions may run in opposite directions or serve different roles. For example, the acetate transport and exchange reactions shared by acetate-aerobic and glucose-anaerobic solutions support acetate uptake in one context and acetate production or secretion in the other. Here, shared means that the same reaction is active—not that its direction, flux magnitude, or physiological role is identical.

Visualize the joint metabolic program#

The plot shows the complete reaction union. Shared-core reactions are gray and de-emphasized; colored reactions show how the core is adapted to carbon source and oxygen availability.

Active conditions

Color

All conditions

Gray

Both aerobic conditions

Blue

Both glucose conditions

Green

Acetate aerobic and glucose anaerobic

Purple

Acetate aerobic only

Orange

Glucose anaerobic only

Red

reaction_groups = reaction_usage.set_index("reaction")["active in"].replace(
    {", ".join(condition_names): "shared core"}
)

group_colors = {
    "shared core": "#b0b0b0",
    "glucose aerobic, acetate aerobic": "#277da1",
    "glucose aerobic, glucose anaerobic": "#43aa8b",
    "acetate aerobic, glucose anaerobic": "#7b2cbf",
    "acetate aerobic": "#f8961e",
    "glucose anaerobic": "#e63946",
}

selected_reactions = np.flatnonzero(joint_union.to_numpy())
joint_network = G.edge_subgraph(selected_reactions)
edge_style = {}

for displayed_index, original_index in enumerate(selected_reactions):
    reaction_id = reaction_ids[original_index]
    group = reaction_groups[reaction_id]
    is_shared_core = group == "shared core"

    edge_style[displayed_index] = {
        "color": group_colors[group],
        "penwidth": "1.5" if is_shared_core else "3",
    }

joint_network.plot(
    graph_attr={"rankdir": "LR"},
    node_attr={
        "fixedsize": "false",
        "shape": "box",
        "style": "rounded",
        "margin": "0.05,0.03",
    },
    custom_edge_attr=edge_style,
)
../../_images/753bc17d7a4fae14af7c07c1db79a662a5b90b2d6bbe85a3064e927e2d144011.svg

Compare the selected unions#

The aggregate from the separate analyses includes every reaction selected in either two-condition optimization, including both alternative glucose-aerobic reaction sets. The joint analysis instead selects one reaction union while considering all three conditions simultaneously.

pairwise_activity = pd.concat(
    [
        control_acetate_fluxes.abs() > activity_tolerance,
        control_anaerobic_fluxes.abs() > activity_tolerance,
    ],
    axis=1,
)
pairwise_union = pairwise_activity.any(axis=1)

union_comparison = pd.Series(
    {
        "aggregate union from separate comparisons": int(pairwise_union.sum()),
        "union from one joint analysis": int(joint_union.sum()),
    },
    name="active reactions",
)

assert joint_union.sum() < pairwise_union.sum()
union_comparison.to_frame()
active reactions
aggregate union from separate comparisons 65
union from one joint analysis 61

Interpretation and limitations#

The joint result provides one internally consistent reference across all three conditions. Reactions in the shared core support every selected state; partially shared and context-specific reactions describe how that core is adapted to carbon source and oxygen availability. Because a reused reaction contributes once to the union penalty, alternative pathways are selected consistently instead of independently in each comparison.

These conditions are designed to illustrate joint reaction selection. The resulting shared and context-specific sets are properties of this model and formulation, not experimental evidence of pathway activity. Structured sparsity assumes that reusing reactions is preferable when several feasible explanations exist, and alternative joint optima may still exist.

Continue with gene expression integration to learn how expression evidence selects a feasible metabolic state for one condition. The following Multi-condition iMAT guide will extend that formulation across several contexts.