Integrating gene expression into metabolic models#

The preceding FBA and Multi-condition FBA guides inferred feasible metabolic states from stoichiometry, reaction bounds, and required phenotypes. Gene expression provides another source of information: it can help choose which of the many feasible pathways best represents a particular biological context.

This guide introduces gene-expression integration for one condition using iMAT, the method currently implemented in CORNETO. We will extract an expression-consistent metabolic state from the same COBRApy E. coli core model used in the FBA guides.

One established approach: iMAT#

iMAT was introduced by Shlomi and colleagues to infer context-specific metabolism from expression evidence. It retains the FBA feasible space:

\[ Sv=0, \qquad \ell \leq v \leq u. \]

It then uses qualitative expression evidence to select a flux state:

  • reactions supported by high expression are encouraged to carry flux;

  • reactions associated with low expression are encouraged to remain inactive;

  • reactions with intermediate or missing evidence are decided by network feasibility and the other objectives.

The optimization minimizes disagreement with this evidence. It does not convert transcript abundance into a flux value, and expression cannot override mass balance, reaction bounds, or a required phenotype. Because reaction activity is represented with indicators, iMAT is a mixed-integer optimization problem.

CORNETO provides this formulation through MultiSampleIMAT, but the framework is not restricted to the original iMAT objective. The built problem exposes its constraints, activity variables, and objective terms, making it straightforward to add biological constraints, compose other penalties, or implement methodological variations.

From genes to reaction evidence#

Metabolic models associate genes with reactions through gene–protein–reaction (GPR) rules. CORNETO applies these rules before building iMAT: an and relationship uses the least-supported required subunit, while an or relationship uses the best-supported alternative enzyme.

Normalized expression

iMAT interpretation

At or above the high threshold

Encourage the mapped reaction to be active

At or below the low threshold

Encourage the mapped reaction to be inactive

Between thresholds or missing

Do not score the reaction

eps defines the minimum absolute flux used to call a supported reaction active. The optional lambda_reg adds a small reaction-count penalty, favoring a compact network when several solutions fit the expression evidence equally well.

Relationship to COBRApy#

COBRApy remains the model-management layer in this workflow: it loads the metabolic model, exposes reactions and GPR rules, and supports standard FBA and pFBA analyses. CORNETO converts that model into its graph representation and composes the FBA constraints, expression-fit terms, reaction-activity indicators, and optional regularization in one optimization problem.

from contextlib import redirect_stdout
from io import StringIO

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from cobra.flux_analysis import pfba
from cobra.io import load_model

from corneto.io import cobra_model_to_graph
from corneto.methods import MultiSampleIMAT
from corneto.methods.metabolism import evaluate_gpr_expression

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

model.solver = "glpk"
G = cobra_model_to_graph(model)

reaction_ids = list(G.get_attr_from_edges("id"))
reaction_index = {reaction_id: i for i, reaction_id in enumerate(reaction_ids)}
biomass_id = "Biomass_Ecoli_core"

pd.Series(
    {
        "metabolites": len(model.metabolites),
        "reactions": len(model.reactions),
        "genes": len(model.genes),
        "reactions with GPR rules": sum(bool(reaction.gene_reaction_rule) for reaction in model.reactions),
    },
    name="E. coli core model",
)
metabolites                  72
reactions                    95
genes                       137
reactions with GPR rules     69
Name: E. coli core model, dtype: int64

Example: selecting an anaerobic fermentation program#

We now address a small biological question:

At a viable anaerobic growth rate, does the expression evidence support ethanol fermentation rather than alternative lactate or acetate routes?

Glucose uptake is limited to 10 units and oxygen uptake is blocked. We require a biomass flux of at least 0.1, but we do not maximize growth inside iMAT: the phenotype defines what the cell must accomplish, and expression evidence helps select how it accomplishes it.

The expression values below are deliberately simple, synthetic normalized log-expression values. They resemble the positive scale commonly obtained after transforming expression measurements, but they do not represent a particular experiment.

Check that the required phenotype is feasible#

Before integrating expression, standard FBA establishes the maximum anaerobic growth supported by the medium.

anaerobic_model = model.copy()
anaerobic_model.reactions.EX_glc__D_e.lower_bound = -10.0
anaerobic_model.reactions.EX_o2_e.bounds = (0.0, 1000.0)

maximum_growth = anaerobic_model.optimize().fluxes[biomass_id]
minimum_growth = 0.1

assert maximum_growth > minimum_growth

pd.Series(
    {
        "maximum anaerobic biomass": maximum_growth,
        "minimum biomass required for inference": minimum_growth,
    }
)
maximum anaerobic biomass                 0.211663
minimum biomass required for inference    0.100000
dtype: float64

Define a qualitative expression profile#

Genes linked to ethanol production have expression values around 11–12. Genes linked to competing lactate and acetate production have values around 4–5. Values at or above 10 are classified as high, values at or below 6 as low, and values between the thresholds would remain unclassified.

high_expression_threshold = 10.0
low_expression_threshold = 6.0

gene_expression = {
    # High expression: acetaldehyde and alcohol dehydrogenases
    "b0351": 12.1,
    "b1241": 11.6,
    "b0356": 10.9,
    "b1478": 11.4,
    # Low expression: lactate dehydrogenase
    "b1380": 4.2,
    "b2133": 5.1,
    # Low expression: phosphotransacetylase and acetate kinase
    "b2297": 4.8,
    "b2458": 5.3,
    "b1849": 3.9,
    "b2296": 4.6,
    "b3115": 5.0,
}

expression_modules = pd.DataFrame(
    [
        {
            "module": "ethanol formation",
            "reactions": "ACALD, ALCD2x",
            "genes": "b0351, b1241, b0356, b1478",
            "expression range": "10.9-12.1",
            "class": "high",
        },
        {
            "module": "lactate formation",
            "reactions": "LDH_D",
            "genes": "b1380, b2133",
            "expression range": "4.2-5.1",
            "class": "low",
        },
        {
            "module": "acetate formation",
            "reactions": "PTAr, ACKr",
            "genes": "b2297, b2458, b1849, b2296, b3115",
            "expression range": "3.9-5.3",
            "class": "low",
        },
    ]
)

expression_modules
module reactions genes expression range class
0 ethanol formation ACALD, ALCD2x b0351, b1241, b0356, b1478 10.9-12.1 high
1 lactate formation LDH_D b1380, b2133 4.2-5.1 low
2 acetate formation PTAr, ACKr b2297, b2458, b1849, b2296, b3115 3.9-5.3 low
scored_reactions = ["ACALD", "ALCD2x", "LDH_D", "PTAr", "ACKr"]
gpr_table = pd.DataFrame(
    {
        "reaction": scored_reactions,
        "GPR rule": [
            model.reactions.get_by_id(reaction_id).gene_reaction_rule
            for reaction_id in scored_reactions
        ],
    }
)

gpr_table
reaction GPR rule
0 ACALD b0351 or b1241
1 ALCD2x b1478 or b0356 or b1241
2 LDH_D b2133 or b1380
3 PTAr b2297 or b2458
4 ACKr b2296 or b3115 or b1849

An expression-free reference#

For comparison, COBRApy pFBA fixes biomass at the required value and minimizes total flux without using expression. This provides one compact feasible state, not a context-specific expression fit.

reference_model = model.copy()
reference_model.reactions.EX_glc__D_e.lower_bound = -10.0
reference_model.reactions.EX_o2_e.bounds = (0.0, 1000.0)
reference_model.reactions.get_by_id(biomass_id).bounds = (minimum_growth, minimum_growth)

pfba_solution = pfba(reference_model)

assert np.isclose(pfba_solution.fluxes[biomass_id], minimum_growth)
pfba_solution.fluxes[[biomass_id, "EX_glc__D_e", "EX_lac__D_e", "EX_etoh_e", "EX_ac_e"]].to_frame(
    "pFBA flux"
)
pFBA flux
Biomass_Ecoli_core 0.100000
EX_glc__D_e -7.767692
EX_lac__D_e 8.928430
EX_etoh_e 4.868550
EX_ac_e 0.000000

Build and solve iMAT#

MultiSampleIMAT.build accepts gene scores, reaction bounds, and optional reaction objectives directly. Here the biomass lower bound represents viability, so no biomass objective is needed.

The small lambda_reg value breaks ties in favor of a compact active network. Expression disagreement remains the primary biological criterion in this example.

eps = 1e-3
imat = MultiSampleIMAT(
    eps=eps,
    lambda_reg=0.01,
    high_expression_threshold=high_expression_threshold,
    low_expression_threshold=low_expression_threshold,
)

imat_problem = imat.build(
    G,
    gene_expression=gene_expression,
    reaction_bounds={
        "EX_glc__D_e": (-10.0, 1000.0),
        "EX_o2_e": (0.0, 1000.0),
        biomass_id: (minimum_growth, None),
    },
)
imat_problem.solve(solver="highs")

imat_fluxes = pd.Series(
    np.asarray(imat_problem.expr.flow.value),
    index=reaction_ids,
    name="iMAT flux",
)

Did the inferred state agree with expression?#

For iMAT, the main interpretation is whether a scored reaction is active, not whether its flux magnitude matches its transcript abundance. Flux signs follow the direction in which each reaction is written in the model.

reaction_evidence = pd.Series(
    {
        "ACALD": "high",
        "ALCD2x": "high",
        "LDH_D": "low",
        "PTAr": "low",
        "ACKr": "low",
    },
    name="expression evidence",
)

expression_fit = pd.DataFrame(
    {
        "expression evidence": reaction_evidence,
        "flux": imat_fluxes[reaction_evidence.index],
        "active": imat_fluxes[reaction_evidence.index].abs() >= eps * (1 - eps),
    }
)

assert expression_fit.loc[["ACALD", "ALCD2x"], "active"].all()
assert not expression_fit.loc[["LDH_D", "PTAr", "ACKr"], "active"].any()
assert imat_fluxes[biomass_id] >= minimum_growth - 1e-7

expression_fit
expression evidence flux active
ACALD high -12.273558 True
ALCD2x high -12.273558 True
LDH_D low 0.000000 False
PTAr low 0.000000 False
ACKr low 0.000000 False

Compare the selected fermentation routes#

At the same biomass value, pFBA and iMAT answer different questions. pFBA minimizes total flux and uses both lactate and ethanol routes. iMAT instead selects a state consistent with the supplied high-ethanol and low-lactate/acetate evidence.

The bars show representative flux solutions. Their magnitudes should not be interpreted as predictions from transcript abundance; the iMAT evidence distinguishes active from inactive reactions.

fermentation_products = {
    "lactate": "EX_lac__D_e",
    "ethanol": "EX_etoh_e",
    "acetate": "EX_ac_e",
}

product_fluxes = pd.DataFrame(
    {
        "pFBA (no expression)": {
            product: pfba_solution.fluxes[reaction_id]
            for product, reaction_id in fermentation_products.items()
        },
        "iMAT (expression integrated)": {
            product: imat_fluxes[reaction_id]
            for product, reaction_id in fermentation_products.items()
        },
    }
)

assert product_fluxes.loc["ethanol", "iMAT (expression integrated)"] > eps
assert np.isclose(product_fluxes.loc["lactate", "iMAT (expression integrated)"], 0.0, atol=1e-7)
assert product_fluxes.loc["lactate", "pFBA (no expression)"] > eps

product_fluxes
pFBA (no expression) iMAT (expression integrated)
lactate 8.92843 0.000000
ethanol 4.86855 12.273558
acetate 0.00000 0.000000
ax = product_fluxes.plot.bar(
    figsize=(7, 3.5),
    color=["#9aa0a6", "#2a9d8f"],
    width=0.75,
)
ax.set_ylabel("exchange flux")
ax.set_xlabel("")
ax.set_title("Fermentation products at the same biomass requirement")
ax.tick_params(axis="x", rotation=0)
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout()
../../_images/9dcd22dc051d991af6c1f6bbd8fa8564c06eda81a7824621d1e4b16453ef2cb6.png

Visualize the contextualized metabolic network#

The graph below contains the active iMAT network plus scored reactions that were kept inactive. High-expression active reactions are green, low-expression inactive reactions are dashed red, and active reactions without expression evidence are gray.

Meaning

Color and style

Active and supported by high expression

Green

Inactive and associated with low expression

Dashed red

Active without supplied expression evidence

Gray

active_reactions = imat_fluxes.abs() >= eps * (1 - eps)
scored_reaction_set = set(reaction_evidence.index)
displayed_reactions = np.array(
    [
        active_reactions.iloc[i] or reaction_id in scored_reaction_set
        for i, reaction_id in enumerate(reaction_ids)
    ]
)

selected_indices = np.flatnonzero(displayed_reactions)
context_network = G.edge_subgraph(selected_indices)
edge_style = {}

for displayed_index, original_index in enumerate(selected_indices):
    reaction_id = reaction_ids[original_index]
    evidence = reaction_evidence.get(reaction_id)

    if evidence == "high":
        edge_style[displayed_index] = {"color": "#2a9d8f", "penwidth": "4"}
    elif evidence == "low":
        edge_style[displayed_index] = {
            "color": "#e76f51",
            "penwidth": "3",
            "style": "dashed",
        }
    else:
        edge_style[displayed_index] = {"color": "#b0b0b0", "penwidth": "1.5"}

context_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/7b05cc8b97aac20079fa6488c568679389eddfcfb32d5983872acafad8bd307d.svg

Advanced: compute and supply reaction scores directly#

Passing gene_expression= is convenient because MultiSampleIMAT performs thresholding and GPR mapping automatically. For method development or custom preprocessing, it can be useful to inspect that transformation and pass the final reaction-level evidence through reaction_scores= instead.

At this level, signed values no longer represent expression abundance. They encode the iMAT classification:

Reaction score

Meaning in the optimization

+1

High-expression evidence: favor an active reaction

−1

Low-expression evidence: favor an inactive reaction

0 or omitted

No expression-fit term for the reaction

First, threshold the positive expression measurements into categorical gene evidence.

gene_scores = {}

for gene_id, expression in gene_expression.items():
    if expression >= high_expression_threshold:
        gene_scores[gene_id] = 1.0
    elif expression <= low_expression_threshold:
        gene_scores[gene_id] = -1.0
    else:
        gene_scores[gene_id] = 0.0

pd.DataFrame(
    {
        "expression": pd.Series(gene_expression),
        "gene score": pd.Series(gene_scores),
    }
).sort_values("expression", ascending=False)
expression gene score
b0351 12.1 1.0
b1241 11.6 1.0
b1478 11.4 1.0
b0356 10.9 1.0
b2458 5.3 -1.0
b2133 5.1 -1.0
b3115 5.0 -1.0
b2297 4.8 -1.0
b2296 4.6 -1.0
b1380 4.2 -1.0
b1849 3.9 -1.0

Next, CORNETO’s evaluate_gpr_expression applies every model GPR rule to the gene scores in one call. Reactions with a final score of zero are omitted, so they remain governed by stoichiometry, bounds, and regularization rather than expression-fit terms. For several samples, the related evaluate_gpr_rules function evaluates the same rule list against multiple gene-score mappings.

gpr_rules = list(G.get_attr_from_edges("GPR"))
reaction_score_values = evaluate_gpr_expression(gpr_rules, gene_scores)

reaction_scores = {
    reaction_id: float(score)
    for reaction_id, score in zip(reaction_ids, reaction_score_values)
    if not np.isclose(score, 0.0)
}

reaction_score_table = pd.DataFrame(
    {
        "GPR rule": {
            reaction_id: gpr_rules[reaction_index[reaction_id]]
            for reaction_id in reaction_scores
        },
        "reaction score": reaction_scores,
    }
)

reaction_score_table
GPR rule reaction score
ACALD b0351 or b1241 1.0
ACKr b2296 or b3115 or b1849 -1.0
ALCD2x b1478 or b0356 or b1241 1.0
LDH_D b2133 or b1380 -1.0
PTAr b2297 or b2458 -1.0

The reaction-score interface is closer to the optimization problem and is useful when reaction evidence comes from another mapping method or has already been curated. Threshold arguments are unnecessary because the scores already encode high and low reaction evidence.

reaction_score_problem = MultiSampleIMAT(
    eps=eps,
    lambda_reg=0.01,
).build(
    G,
    reaction_scores=reaction_scores,
    reaction_bounds={
        "EX_glc__D_e": (-10.0, 1000.0),
        "EX_o2_e": (0.0, 1000.0),
        biomass_id: (minimum_growth, None),
    },
)
reaction_score_problem.solve(solver="highs")

reaction_score_fluxes = pd.Series(
    np.asarray(reaction_score_problem.expr.flow.value),
    index=reaction_ids,
)

interface_comparison = pd.DataFrame(
    {
        "reaction score": pd.Series(reaction_scores),
        "active from gene_expression": (
            imat_fluxes[list(reaction_scores)].abs() >= eps * (1 - eps)
        ),
        "active from reaction_scores": (
            reaction_score_fluxes[list(reaction_scores)].abs() >= eps * (1 - eps)
        ),
    }
)

assert (
    interface_comparison["active from gene_expression"]
    == interface_comparison["active from reaction_scores"]
).all()

interface_comparison
reaction score active from gene_expression active from reaction_scores
ACALD 1.0 True True
ACKr -1.0 False False
ALCD2x 1.0 True True
LDH_D -1.0 False False
PTAr -1.0 False False

Interpretation and limitations#

The inferred state is stoichiometrically feasible, satisfies the anaerobic medium and growth requirement, activates the expression-supported ethanol route, and avoids the low-expression lactate and acetate routes. This illustrates the role of iMAT: expression resolves part of the ambiguity left by FBA by ranking feasible reaction-activity patterns.

The result remains a modeling hypothesis:

  • expression is a cue for likely activity, not an activity or flux measurement; protein abundance, post-translational modification, and metabolite-level regulation can decouple expression from flux;

  • thresholds determine which genes contribute evidence and should be checked for sensitivity;

  • iMAT infers reaction activity, not flux magnitude from expression;

  • low-expression reactions may remain active when network constraints or the required phenotype need them;

  • alternative expression-consistent optima may still exist.

The original iMAT paper reports a central role for post-transcriptional regulation and uses disagreements between expression and predicted activity to generate hypotheses about regulation beyond transcript abundance. They do not identify a mechanism on their own: additional protein, enzyme-activity, metabolite, or flux measurements are needed to distinguish biological regulation from data and model assumptions.

Real analyses should inspect threshold choices, biological constraints, expression coverage, and alternative solutions. Continue with multi-condition gene expression integration to learn how CORNETO couples expression-informed metabolic inference across several contexts.