raven-toolbox 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raven_toolbox/__init__.py +15 -0
- raven_toolbox/analysis/__init__.py +23 -0
- raven_toolbox/analysis/fseof.py +161 -0
- raven_toolbox/analysis/reporter.py +119 -0
- raven_toolbox/analysis/sampling.py +218 -0
- raven_toolbox/annotation/__init__.py +24 -0
- raven_toolbox/annotation/delta_g.py +115 -0
- raven_toolbox/annotation/sbo.py +180 -0
- raven_toolbox/binaries.py +185 -0
- raven_toolbox/biomass/__init__.py +50 -0
- raven_toolbox/biomass/config.py +95 -0
- raven_toolbox/biomass/gam.py +67 -0
- raven_toolbox/biomass/scale.py +207 -0
- raven_toolbox/comparison/__init__.py +23 -0
- raven_toolbox/comparison/compare.py +150 -0
- raven_toolbox/comparison/diff.py +285 -0
- raven_toolbox/conditions/__init__.py +25 -0
- raven_toolbox/conditions/apply.py +192 -0
- raven_toolbox/curation/__init__.py +54 -0
- raven_toolbox/curation/batch.py +460 -0
- raven_toolbox/data.py +215 -0
- raven_toolbox/gapfilling/__init__.py +9 -0
- raven_toolbox/gapfilling/fill.py +188 -0
- raven_toolbox/init/__init__.py +48 -0
- raven_toolbox/init/build.py +113 -0
- raven_toolbox/init/ftinit.py +330 -0
- raven_toolbox/init/genes.py +81 -0
- raven_toolbox/init/init.py +256 -0
- raven_toolbox/init/merge.py +226 -0
- raven_toolbox/init/prep.py +241 -0
- raven_toolbox/init/score.py +81 -0
- raven_toolbox/init/steps.py +62 -0
- raven_toolbox/init/taskfill.py +186 -0
- raven_toolbox/io/__init__.py +18 -0
- raven_toolbox/io/ec_data.py +392 -0
- raven_toolbox/io/excel.py +136 -0
- raven_toolbox/io/git.py +106 -0
- raven_toolbox/io/sif.py +96 -0
- raven_toolbox/io/yaml.py +494 -0
- raven_toolbox/localization/__init__.py +27 -0
- raven_toolbox/localization/predict.py +382 -0
- raven_toolbox/localization/scores.py +117 -0
- raven_toolbox/manifest.py +123 -0
- raven_toolbox/manipulation/__init__.py +39 -0
- raven_toolbox/manipulation/add.py +365 -0
- raven_toolbox/manipulation/change.py +128 -0
- raven_toolbox/manipulation/compartments.py +196 -0
- raven_toolbox/manipulation/expand.py +126 -0
- raven_toolbox/manipulation/irreversible.py +72 -0
- raven_toolbox/manipulation/merge.py +147 -0
- raven_toolbox/manipulation/parameters.py +78 -0
- raven_toolbox/manipulation/remove.py +120 -0
- raven_toolbox/manipulation/simplify.py +277 -0
- raven_toolbox/manipulation/transfer.py +145 -0
- raven_toolbox/manipulation/transport.py +162 -0
- raven_toolbox/omics/__init__.py +23 -0
- raven_toolbox/omics/hpa.py +190 -0
- raven_toolbox/py.typed +0 -0
- raven_toolbox/reconstruction/__init__.py +1 -0
- raven_toolbox/reconstruction/homology/__init__.py +23 -0
- raven_toolbox/reconstruction/homology/blast.py +146 -0
- raven_toolbox/reconstruction/homology/hits.py +64 -0
- raven_toolbox/reconstruction/homology/homology.py +287 -0
- raven_toolbox/reconstruction/kegg/__init__.py +83 -0
- raven_toolbox/reconstruction/kegg/assemble.py +82 -0
- raven_toolbox/reconstruction/kegg/download.py +269 -0
- raven_toolbox/reconstruction/kegg/hmm.py +451 -0
- raven_toolbox/reconstruction/kegg/organism.py +168 -0
- raven_toolbox/reconstruction/kegg/parse.py +618 -0
- raven_toolbox/reconstruction/kegg/query.py +250 -0
- raven_toolbox/reconstruction/kegg/taxonomy.py +176 -0
- raven_toolbox/tasks/__init__.py +23 -0
- raven_toolbox/tasks/check.py +336 -0
- raven_toolbox/tasks/tasklist.py +141 -0
- raven_toolbox/utils/__init__.py +16 -0
- raven_toolbox/utils/balance.py +90 -0
- raven_toolbox/utils/gpr.py +148 -0
- raven_toolbox/utils/parse.py +59 -0
- raven_toolbox/utils/sort.py +21 -0
- raven_toolbox/utils/validate.py +86 -0
- raven_toolbox-0.2.0.dist-info/METADATA +135 -0
- raven_toolbox-0.2.0.dist-info/RECORD +84 -0
- raven_toolbox-0.2.0.dist-info/WHEEL +4 -0
- raven_toolbox-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""raven_toolbox — Python counterpart of the RAVEN Toolbox, built on cobrapy.
|
|
2
|
+
|
|
3
|
+
raven_toolbox reuses cobrapy for simulation, standard analyses, SBML I/O, and model
|
|
4
|
+
manipulation, and provides the RAVEN-specific functionality on top: de novo
|
|
5
|
+
reconstruction (KEGG / homology), context-specific modeling (tINIT / ftINIT),
|
|
6
|
+
metabolic task validation, connectivity gap-filling, omics integration (HPA),
|
|
7
|
+
sub-cellular localisation, N-model comparison, and the RAVEN-style I/O formats.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
__version__ = version("raven-toolbox")
|
|
14
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
15
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Analyses not in cobrapy's core.
|
|
2
|
+
|
|
3
|
+
* :func:`reporter_metabolites` — Reporter Metabolites (around-metabolite gene-score test).
|
|
4
|
+
* :func:`fseof` — Flux Scanning based on Enforced Objective Flux.
|
|
5
|
+
* :func:`random_sampling` — random-objective flux sampling.
|
|
6
|
+
"""
|
|
7
|
+
from raven_toolbox.analysis.fseof import FSEOFResult, fseof
|
|
8
|
+
from raven_toolbox.analysis.reporter import ReporterResult, reporter_metabolites
|
|
9
|
+
from raven_toolbox.analysis.sampling import (
|
|
10
|
+
RandomSamplingResult,
|
|
11
|
+
find_good_reactions,
|
|
12
|
+
random_sampling,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"FSEOFResult",
|
|
17
|
+
"RandomSamplingResult",
|
|
18
|
+
"ReporterResult",
|
|
19
|
+
"find_good_reactions",
|
|
20
|
+
"fseof",
|
|
21
|
+
"random_sampling",
|
|
22
|
+
"reporter_metabolites",
|
|
23
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Flux Scanning based on Enforced Objective Flux — FSEOF (port + redesign).
|
|
2
|
+
|
|
3
|
+
FSEOF (Choi et al., Appl Environ Microbiol 2010) finds metabolic-engineering targets
|
|
4
|
+
for over-producing a metabolite: enforce an increasing flux toward the target product
|
|
5
|
+
while optimising growth, and watch how each reaction's flux responds. This is a port
|
|
6
|
+
of RAVEN's ``FSEOF`` with a substantially richer, more robust output (RAVEN's
|
|
7
|
+
weaknesses are noted in IMPROVEMENTS, FS1–FS4):
|
|
8
|
+
|
|
9
|
+
* **Robust trend, not strict monotonicity.** Each reaction's flux is regressed against
|
|
10
|
+
the enforced product flux across the scan; the **slope** is the response and the
|
|
11
|
+
**correlation** (|r|) is a quality score. A reaction is a target if it tracks the
|
|
12
|
+
product cleanly (|r| ≥ ``correlation_threshold``) — one noisy step from LP
|
|
13
|
+
alternative optima no longer discards it (and pFBA per step keeps the scan stable).
|
|
14
|
+
* **Direction classification RAVEN lacks.** Targets are labelled ``amplify`` (|flux|
|
|
15
|
+
rises with the product → over-express), ``knockdown`` (|flux| falls), or ``knockout``
|
|
16
|
+
(|flux| → ~0 → delete). RAVEN only ever reports the amplification targets.
|
|
17
|
+
* **Gene-level view** via :attr:`FSEOFResult.gene_targets`, and the full flux scan is
|
|
18
|
+
retained in :attr:`FSEOFResult.scan` — all as DataFrames, not a printed TSV.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
import cobra
|
|
25
|
+
import numpy as np
|
|
26
|
+
import pandas as pd
|
|
27
|
+
from cobra.exceptions import OptimizationError
|
|
28
|
+
from cobra.flux_analysis import pfba
|
|
29
|
+
from scipy.stats import linregress
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class FSEOFResult:
|
|
34
|
+
"""FSEOF output.
|
|
35
|
+
|
|
36
|
+
``scan`` is reactions × enforced-flux-levels (the full flux scan); ``enforced`` are
|
|
37
|
+
the enforced target fluxes; ``targets`` is the classified per-reaction table
|
|
38
|
+
(sorted by score). :attr:`gene_targets` aggregates targets to genes.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
scan: pd.DataFrame
|
|
42
|
+
enforced: list[float]
|
|
43
|
+
targets: pd.DataFrame
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def amplification(self) -> pd.DataFrame:
|
|
47
|
+
return self.targets[self.targets["target_type"] == "amplify"].reset_index(drop=True)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def knockout(self) -> pd.DataFrame:
|
|
51
|
+
mask = self.targets["target_type"].isin(["knockout", "knockdown"])
|
|
52
|
+
return self.targets[mask].reset_index(drop=True)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def gene_targets(self) -> pd.DataFrame:
|
|
56
|
+
"""Per-gene aggregation: the target reactions each gene is associated with."""
|
|
57
|
+
rows = []
|
|
58
|
+
for _, t in self.targets.iterrows():
|
|
59
|
+
for gene in t["genes"]:
|
|
60
|
+
rows.append({"gene": gene, "reaction": t["reaction"],
|
|
61
|
+
"target_type": t["target_type"], "slope": t["slope"]})
|
|
62
|
+
if not rows:
|
|
63
|
+
return pd.DataFrame(columns=["gene", "target_type", "reactions", "max_abs_slope"])
|
|
64
|
+
df = pd.DataFrame(rows)
|
|
65
|
+
agg = df.groupby("gene").agg(
|
|
66
|
+
target_type=("target_type", lambda s: ";".join(sorted(set(s)))),
|
|
67
|
+
reactions=("reaction", lambda s: ";".join(sorted(set(s)))),
|
|
68
|
+
max_abs_slope=("slope", lambda s: float(np.max(np.abs(s)))),
|
|
69
|
+
).reset_index()
|
|
70
|
+
return agg.sort_values("max_abs_slope", ascending=False, ignore_index=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fseof(
|
|
74
|
+
model: cobra.Model,
|
|
75
|
+
target_rxn: str,
|
|
76
|
+
*,
|
|
77
|
+
biomass_rxn: str | None = None,
|
|
78
|
+
n_steps: int = 10,
|
|
79
|
+
max_fraction: float = 0.9,
|
|
80
|
+
correlation_threshold: float = 0.9,
|
|
81
|
+
flux_eps: float = 1e-6,
|
|
82
|
+
) -> FSEOFResult:
|
|
83
|
+
"""Run FSEOF for over-production of ``target_rxn``'s product.
|
|
84
|
+
|
|
85
|
+
Enforces target flux from ``max_fraction/n_steps`` up to ``max_fraction`` of the
|
|
86
|
+
theoretical maximum in ``n_steps`` steps, maximising growth (``biomass_rxn`` or the
|
|
87
|
+
model's current objective) with pFBA at each step. Returns an :class:`FSEOFResult`.
|
|
88
|
+
"""
|
|
89
|
+
with model: # find the theoretical maximum target flux
|
|
90
|
+
model.objective = target_rxn
|
|
91
|
+
target_opt = model.slim_optimize()
|
|
92
|
+
# slim_optimize returns NaN on an infeasible model; np.isfinite catches that too.
|
|
93
|
+
if target_opt is None or not np.isfinite(target_opt) or target_opt <= flux_eps:
|
|
94
|
+
raise ValueError(f"{target_rxn!r} cannot carry positive flux; nothing to scan.")
|
|
95
|
+
target_max = target_opt * max_fraction
|
|
96
|
+
levels = [target_max * (i + 1) / n_steps for i in range(n_steps)]
|
|
97
|
+
|
|
98
|
+
columns: dict[float, pd.Series] = {}
|
|
99
|
+
enforced: list[float] = []
|
|
100
|
+
for level in levels:
|
|
101
|
+
with model:
|
|
102
|
+
if biomass_rxn is not None:
|
|
103
|
+
model.objective = biomass_rxn
|
|
104
|
+
model.reactions.get_by_id(target_rxn).lower_bound = level
|
|
105
|
+
try:
|
|
106
|
+
columns[level] = pfba(model).fluxes
|
|
107
|
+
except OptimizationError:
|
|
108
|
+
break # enforced flux became infeasible — stop scanning
|
|
109
|
+
enforced.append(level)
|
|
110
|
+
if len(enforced) < 2:
|
|
111
|
+
raise RuntimeError("FSEOF needs at least two feasible enforced-flux levels.")
|
|
112
|
+
|
|
113
|
+
scan = pd.DataFrame(columns)
|
|
114
|
+
targets = _classify(model, scan, np.asarray(enforced), correlation_threshold, flux_eps)
|
|
115
|
+
return FSEOFResult(scan=scan, enforced=enforced, targets=targets)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _classify(model, scan, enforced, corr_threshold, flux_eps) -> pd.DataFrame:
|
|
119
|
+
rows = []
|
|
120
|
+
for rxn in model.reactions:
|
|
121
|
+
flux = scan.loc[rxn.id, enforced.tolist() if hasattr(enforced, "tolist") else enforced]
|
|
122
|
+
flux = flux.to_numpy(dtype=float)
|
|
123
|
+
initial, final = flux[0], flux[-1]
|
|
124
|
+
if flux.std() < flux_eps: # flat -> no response
|
|
125
|
+
continue
|
|
126
|
+
fit = linregress(enforced, flux)
|
|
127
|
+
slope, corr = float(fit.slope), float(fit.rvalue)
|
|
128
|
+
if abs(corr) < corr_threshold or abs(slope) < flux_eps:
|
|
129
|
+
continue
|
|
130
|
+
# Classify on the slope of |flux| vs the enforced product flux — the
|
|
131
|
+
# criterion the docstring states (|flux| rises = amplify, etc.). The
|
|
132
|
+
# old endpoint-only check (``abs(final) vs abs(initial)``) could
|
|
133
|
+
# mislabel a track whose first/last values straddled a peak/trough but
|
|
134
|
+
# whose overall trend was the opposite. Keep ``knockout`` for tracks
|
|
135
|
+
# the regression drives essentially to zero.
|
|
136
|
+
abs_fit = linregress(enforced, np.abs(flux))
|
|
137
|
+
abs_slope = float(abs_fit.slope)
|
|
138
|
+
if abs(final) < flux_eps and abs_slope < 0:
|
|
139
|
+
ttype = "knockout"
|
|
140
|
+
elif abs_slope > 0:
|
|
141
|
+
ttype = "amplify"
|
|
142
|
+
else:
|
|
143
|
+
ttype = "knockdown"
|
|
144
|
+
rows.append({
|
|
145
|
+
"reaction": rxn.id,
|
|
146
|
+
"name": rxn.name,
|
|
147
|
+
"subsystem": rxn.subsystem,
|
|
148
|
+
"gene_reaction_rule": rxn.gene_reaction_rule,
|
|
149
|
+
"genes": sorted(g.id for g in rxn.genes),
|
|
150
|
+
"target_type": ttype,
|
|
151
|
+
"slope": slope,
|
|
152
|
+
"correlation": corr,
|
|
153
|
+
"initial_flux": initial,
|
|
154
|
+
"final_flux": final,
|
|
155
|
+
"score": abs(slope) * abs(corr),
|
|
156
|
+
})
|
|
157
|
+
table = pd.DataFrame(rows, columns=[
|
|
158
|
+
"reaction", "name", "subsystem", "gene_reaction_rule", "genes",
|
|
159
|
+
"target_type", "slope", "correlation", "initial_flux", "final_flux", "score",
|
|
160
|
+
])
|
|
161
|
+
return table.sort_values("score", ascending=False, ignore_index=True)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Reporter Metabolites — metabolites around which transcriptional change concentrates.
|
|
2
|
+
|
|
3
|
+
Patil & Nielsen, PNAS 2005. Each gene's differential-expression p-value becomes a
|
|
4
|
+
Z-score ``z = -Φ⁻¹(p)``; for every metabolite the Z-scores of the genes on its
|
|
5
|
+
neighbouring reactions are aggregated (``Σz / √n``), background-corrected, and turned
|
|
6
|
+
back into a p-value.
|
|
7
|
+
|
|
8
|
+
The background correction has an exact closed form (sampling with replacement from the
|
|
9
|
+
scored-gene pool: a random ``Σz/√n`` has mean ``√n·μ`` and standard deviation ``σ``
|
|
10
|
+
with μ, σ the mean/std of the scored Z-scores), so the corrected score is just
|
|
11
|
+
``(metZ − √n·μ) / σ`` — no Monte-Carlo sampling needed.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import cobra
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
from scipy.stats import norm
|
|
23
|
+
|
|
24
|
+
_CLAMP = 15.0 # |Z| cap for p-values of exactly 0 or 1 (RAVEN's ±15)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ReporterResult:
|
|
29
|
+
"""Reporter-metabolite scores for one gene set.
|
|
30
|
+
|
|
31
|
+
``test`` is ``"all"``, ``"up"`` or ``"down"``; ``table`` is a DataFrame with
|
|
32
|
+
columns ``metabolite, name, z_score, p_value, n_genes, mean_z, std_z`` sorted by
|
|
33
|
+
descending ``z_score``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
test: str
|
|
37
|
+
table: pd.DataFrame
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _gene_z(pvalues: dict[str, float]) -> dict[str, float]:
|
|
41
|
+
genes = list(pvalues)
|
|
42
|
+
z = -norm.ppf([pvalues[g] for g in genes])
|
|
43
|
+
z = np.where(np.isposinf(z), _CLAMP, z)
|
|
44
|
+
z = np.where(np.isneginf(z), -_CLAMP, z)
|
|
45
|
+
return dict(zip(genes, z, strict=True))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _reporter_one(model: cobra.Model, gene_z: dict[str, float], test: str) -> ReporterResult:
|
|
49
|
+
z_values = np.fromiter(gene_z.values(), dtype=float)
|
|
50
|
+
mu = float(z_values.mean()) if z_values.size else 0.0
|
|
51
|
+
sigma = float(z_values.std(ddof=0)) if z_values.size else 0.0
|
|
52
|
+
|
|
53
|
+
rows = []
|
|
54
|
+
for met in model.metabolites:
|
|
55
|
+
neighbours = {g.id for rxn in met.reactions for g in rxn.genes if g.id in gene_z}
|
|
56
|
+
if not neighbours:
|
|
57
|
+
continue
|
|
58
|
+
zs = np.array([gene_z[g] for g in neighbours])
|
|
59
|
+
n = zs.size
|
|
60
|
+
raw = zs.sum() / math.sqrt(n)
|
|
61
|
+
# Exact background correction for sampling-with-replacement (see module doc).
|
|
62
|
+
corrected = (raw - math.sqrt(n) * mu) / sigma if sigma > 0 else 0.0
|
|
63
|
+
if not math.isfinite(corrected): # never on clamped z-scores; guards the p-value
|
|
64
|
+
corrected = 0.0
|
|
65
|
+
rows.append(
|
|
66
|
+
{
|
|
67
|
+
"metabolite": met.id,
|
|
68
|
+
"name": met.name or met.id,
|
|
69
|
+
"z_score": corrected,
|
|
70
|
+
"p_value": float(1.0 - norm.cdf(corrected)),
|
|
71
|
+
"n_genes": n,
|
|
72
|
+
"mean_z": float(zs.mean()),
|
|
73
|
+
"std_z": float(zs.std(ddof=1)) if n > 1 else float("nan"),
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
table = pd.DataFrame(rows, columns=["metabolite", "name", "z_score", "p_value", "n_genes", "mean_z", "std_z"])
|
|
77
|
+
table = table.sort_values("z_score", ascending=False, ignore_index=True)
|
|
78
|
+
return ReporterResult(test, table)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def reporter_metabolites(
|
|
82
|
+
model: cobra.Model,
|
|
83
|
+
gene_pvalues: Mapping[str, float],
|
|
84
|
+
*,
|
|
85
|
+
gene_fold_changes: Mapping[str, float] | None = None,
|
|
86
|
+
) -> list[ReporterResult]:
|
|
87
|
+
"""Compute Reporter Metabolites from per-gene differential-expression p-values.
|
|
88
|
+
|
|
89
|
+
``gene_pvalues`` maps gene id → p-value (genes not in the model, or with a NaN or
|
|
90
|
+
out-of-``[0, 1]`` p-value, are dropped — a stray invalid p-value would otherwise
|
|
91
|
+
turn the whole result NaN). If ``gene_fold_changes`` (gene id → log fold change)
|
|
92
|
+
is given, two extra results are returned for the up- (fc ≥ 0) and down- (fc < 0)
|
|
93
|
+
regulated gene subsets, in addition to ``"all"``.
|
|
94
|
+
|
|
95
|
+
Parity with RAVEN's ``reporterMetabolites``: the ``z_score`` and underlying
|
|
96
|
+
background correction match exactly (exact closed-form instead of RAVEN's
|
|
97
|
+
Monte-Carlo, see IMPROVEMENTS RM1). The reported ``p_value`` is the
|
|
98
|
+
*one-sided* (``"up"``) enrichment ``1 - Φ(z)`` and the result is sorted by
|
|
99
|
+
``z_score`` descending. RAVEN sorts by p-value and reports both tails
|
|
100
|
+
(``allPValues``, ``allUpPValues``, ``allDownPValues``); the up/down splits
|
|
101
|
+
here come from the ``gene_fold_changes`` subset partition instead, so the
|
|
102
|
+
same information is available via the three returned ``ReporterResult``
|
|
103
|
+
rows.
|
|
104
|
+
"""
|
|
105
|
+
model_genes = {g.id for g in model.genes}
|
|
106
|
+
scored = {
|
|
107
|
+
g: float(p)
|
|
108
|
+
for g, p in gene_pvalues.items()
|
|
109
|
+
if g in model_genes and p is not None and not math.isnan(p) and 0.0 <= p <= 1.0
|
|
110
|
+
}
|
|
111
|
+
gene_z = _gene_z(scored)
|
|
112
|
+
results = [_reporter_one(model, gene_z, "all")]
|
|
113
|
+
|
|
114
|
+
if gene_fold_changes is not None:
|
|
115
|
+
up = {g: z for g, z in gene_z.items() if gene_fold_changes.get(g, 0.0) >= 0}
|
|
116
|
+
down = {g: z for g, z in gene_z.items() if gene_fold_changes.get(g, 0.0) < 0}
|
|
117
|
+
results.append(_reporter_one(model, up, "up"))
|
|
118
|
+
results.append(_reporter_one(model, down, "down"))
|
|
119
|
+
return results
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Random-objective flux sampling — RAVEN's ``randomSampling`` (port + improvements).
|
|
2
|
+
|
|
3
|
+
Samples the flux solution space by the **random-objective** method of Bordel et al.
|
|
4
|
+
(2010, PLoS Comput Biol, doi:10.1371/journal.pcbi.1000859), as ported from RAVEN's
|
|
5
|
+
``randomSampling``: each sample maximises a small random linear combination of
|
|
6
|
+
reactions, so every sample is an *extreme point* (vertex) of the flux polytope.
|
|
7
|
+
|
|
8
|
+
This is a different statistical object from cobrapy's ``cobra.sampling`` (OptGP /
|
|
9
|
+
ACHR), which draw a (near-)uniform Markov-chain sample of the polytope *interior*.
|
|
10
|
+
Use cobra's samplers when you need the uniform flux distribution; use this when you
|
|
11
|
+
want a fast, robust spread of diverse optimal states — the workflow RAVEN uses to
|
|
12
|
+
compare conditions, and one that stays well-behaved on large or tightly-constrained
|
|
13
|
+
models where MCMC mixing is poor. cobrapy has no equivalent, so this is a genuine
|
|
14
|
+
addition, not a wrapper.
|
|
15
|
+
|
|
16
|
+
Improvements over RAVEN (see IMPROVEMENTS SAMP1):
|
|
17
|
+
|
|
18
|
+
* **`good_reactions` via one FVA pass**, not a hand-rolled per-reaction ``parfor``
|
|
19
|
+
loop. A reaction is usable as a random objective if it can carry flux and is not
|
|
20
|
+
stuck in a stoichiometrically-infeasible loop (its range blows past the arbitrary
|
|
21
|
+
large bound). ``cobra``'s FVA computes exactly that, faster and in far less code,
|
|
22
|
+
and can optionally be made ``loopless``.
|
|
23
|
+
* **Reproducible** via ``seed`` (RAVEN has no seed control).
|
|
24
|
+
* **`n_objectives` is a parameter** (RAVEN hard-codes 2, though its docstring claims
|
|
25
|
+
3).
|
|
26
|
+
* **Tidy output**: a ``samples`` DataFrame shaped samples × reactions (matching
|
|
27
|
+
``cobra.sampling``), plus the reusable ``good_reactions`` list — instead of a
|
|
28
|
+
reactions × samples matrix and a parallel index vector.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
from collections.abc import Iterable
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
|
|
36
|
+
import cobra
|
|
37
|
+
import numpy as np
|
|
38
|
+
import pandas as pd
|
|
39
|
+
from cobra.exceptions import OptimizationError
|
|
40
|
+
from cobra.flux_analysis import flux_variability_analysis, pfba
|
|
41
|
+
from optlang.symbolics import add
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class RandomSamplingResult:
|
|
48
|
+
"""Output of :func:`random_sampling`.
|
|
49
|
+
|
|
50
|
+
``samples`` is a DataFrame of flux vectors shaped *n_samples × n_reactions*
|
|
51
|
+
(one sample per row, reaction ids as columns — the ``cobra.sampling`` layout).
|
|
52
|
+
``good_reactions`` is the list of reaction ids that were eligible as random
|
|
53
|
+
objectives; pass it back in to skip the (one-off) FVA on a repeat run.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
samples: pd.DataFrame
|
|
57
|
+
good_reactions: list[str]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def find_good_reactions(
|
|
61
|
+
model: cobra.Model,
|
|
62
|
+
*,
|
|
63
|
+
flux_tol: float = 1e-9,
|
|
64
|
+
loopless: bool = True,
|
|
65
|
+
exclude_reactions: Iterable[str] | None = None,
|
|
66
|
+
) -> list[str]:
|
|
67
|
+
"""Reactions usable as random objectives: carry real (non-loop) flux.
|
|
68
|
+
|
|
69
|
+
A reaction is kept if its FVA range spans more than ``flux_tol``. With
|
|
70
|
+
``loopless`` (default) the FVA is loopless (``cycleFreeFlux``), so reactions
|
|
71
|
+
that can carry flux *only* through a thermodynamically-infeasible cycle have a
|
|
72
|
+
~0 loopless range and are dropped — the right test for "loopy", unlike a fixed
|
|
73
|
+
bound threshold which wrongly drops legitimate reactions that simply reach the
|
|
74
|
+
model's default (e.g. 1000) bound. Pass ``loopless=False`` for a faster, looser
|
|
75
|
+
pass that keeps any flux-carrying reaction (loops included).
|
|
76
|
+
"""
|
|
77
|
+
fva = flux_variability_analysis(
|
|
78
|
+
model, fraction_of_optimum=0.0,
|
|
79
|
+
loopless="cycleFreeFlux" if loopless else None,
|
|
80
|
+
)
|
|
81
|
+
excluded = set(exclude_reactions or ())
|
|
82
|
+
return [
|
|
83
|
+
rxn_id
|
|
84
|
+
for rxn_id, lo, hi in zip(fva.index, fva["minimum"], fva["maximum"], strict=True)
|
|
85
|
+
if rxn_id not in excluded and max(abs(lo), abs(hi)) > flux_tol
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def random_sampling(
|
|
90
|
+
model: cobra.Model,
|
|
91
|
+
n_samples: int = 1000,
|
|
92
|
+
*,
|
|
93
|
+
n_objectives: int = 2,
|
|
94
|
+
good_reactions: Iterable[str] | None = None,
|
|
95
|
+
replace_max_bound: bool = False,
|
|
96
|
+
min_flux: bool = False,
|
|
97
|
+
loopless_good_reactions: bool = True,
|
|
98
|
+
exclude_reactions: Iterable[str] | None = None,
|
|
99
|
+
max_attempts: int = 100,
|
|
100
|
+
suppress_errors: bool = False,
|
|
101
|
+
seed: int | None = None,
|
|
102
|
+
) -> RandomSamplingResult:
|
|
103
|
+
"""Random-objective sampling of ``model``'s flux space (Bordel et al. 2010).
|
|
104
|
+
|
|
105
|
+
Each sample maximises ``sum(w_i * v_i)`` over ``n_objectives`` reactions drawn at
|
|
106
|
+
random from ``good_reactions``, with weights ``w_i = U(0,1) * (±1)`` (a random
|
|
107
|
+
sign per reaction, as in RAVEN). The resulting flux vector is one sample.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
n_samples
|
|
112
|
+
Number of flux vectors to return.
|
|
113
|
+
n_objectives
|
|
114
|
+
Reactions combined into each random objective (RAVEN's fixed 2).
|
|
115
|
+
good_reactions
|
|
116
|
+
Reaction ids eligible as objectives. If ``None`` they are computed once with
|
|
117
|
+
:func:`find_good_reactions` and returned for reuse.
|
|
118
|
+
replace_max_bound
|
|
119
|
+
RAVEN's ``replaceBoundsWithInf``: replace the largest upper bound with
|
|
120
|
+
``+inf`` (and the smallest negative lower bound with ``-inf``) before
|
|
121
|
+
sampling, so a reaction whose biological maximum exceeds the model's
|
|
122
|
+
arbitrary cap is not pinned at it. **Off by default** — unlike RAVEN. It
|
|
123
|
+
applies only to the sampling phase (``good_reactions`` is always found on
|
|
124
|
+
the finite bounds), and it can open unbounded directions through loops
|
|
125
|
+
that show up as large fluxes in non-objective reactions; pair it with
|
|
126
|
+
``min_flux`` if you enable it.
|
|
127
|
+
min_flux
|
|
128
|
+
After maximising the random objective, re-solve parsimoniously
|
|
129
|
+
(:func:`cobra.flux_analysis.pfba`) to minimise total flux at that optimum —
|
|
130
|
+
squeezes residual loops out of each individual sample.
|
|
131
|
+
loopless_good_reactions, exclude_reactions
|
|
132
|
+
Forwarded to :func:`find_good_reactions` when it is invoked (loopless loop
|
|
133
|
+
detection is on by default).
|
|
134
|
+
max_attempts, suppress_errors
|
|
135
|
+
A sample is retried if the random objective is degenerate (zero flux). After
|
|
136
|
+
``max_attempts`` failures this raises, unless ``suppress_errors`` (then the
|
|
137
|
+
degenerate solution is kept with a warning).
|
|
138
|
+
seed
|
|
139
|
+
Seed for reproducible objective draws.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
RandomSamplingResult
|
|
144
|
+
"""
|
|
145
|
+
if n_samples <= 0:
|
|
146
|
+
raise ValueError("n_samples must be positive.")
|
|
147
|
+
rng = np.random.default_rng(seed)
|
|
148
|
+
model = model.copy()
|
|
149
|
+
|
|
150
|
+
if model.slim_optimize(error_value=None) is None:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
"The model has no feasible solution, likely due to incompatible constraints."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# good_reactions must be found on the finite bounds (FVA cannot handle inf),
|
|
156
|
+
# before any bound replacement.
|
|
157
|
+
if good_reactions is None:
|
|
158
|
+
good_reactions = find_good_reactions(
|
|
159
|
+
model, loopless=loopless_good_reactions,
|
|
160
|
+
exclude_reactions=exclude_reactions,
|
|
161
|
+
)
|
|
162
|
+
good_reactions = list(good_reactions)
|
|
163
|
+
|
|
164
|
+
if replace_max_bound:
|
|
165
|
+
max_ub = max(r.upper_bound for r in model.reactions)
|
|
166
|
+
min_lb = min(r.lower_bound for r in model.reactions)
|
|
167
|
+
for r in model.reactions:
|
|
168
|
+
if r.upper_bound == max_ub:
|
|
169
|
+
r.upper_bound = float("inf")
|
|
170
|
+
if min_lb < 0 and r.lower_bound == min_lb:
|
|
171
|
+
r.lower_bound = float("-inf")
|
|
172
|
+
|
|
173
|
+
if len(good_reactions) < n_objectives:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"Only {len(good_reactions)} usable reactions found, need at least "
|
|
176
|
+
f"n_objectives={n_objectives}. Check the model's constraints."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
good_rxn_objs = [model.reactions.get_by_id(r) for r in good_reactions]
|
|
180
|
+
reaction_ids = [r.id for r in model.reactions]
|
|
181
|
+
samples = np.zeros((n_samples, len(reaction_ids)))
|
|
182
|
+
|
|
183
|
+
for i in range(n_samples):
|
|
184
|
+
for attempt in range(1, max_attempts + 1):
|
|
185
|
+
chosen = rng.choice(len(good_rxn_objs), size=n_objectives, replace=False)
|
|
186
|
+
signs = rng.choice((-1.0, 1.0), size=n_objectives)
|
|
187
|
+
weights = rng.random(n_objectives) * signs
|
|
188
|
+
terms = [w * good_rxn_objs[j].flux_expression
|
|
189
|
+
for j, w in zip(chosen, weights, strict=True)]
|
|
190
|
+
# add() (not sum()) builds the symbolic objective in one pass; sum()
|
|
191
|
+
# re-canonicalises the optlang expression on every term (O(n^2)).
|
|
192
|
+
model.objective = model.problem.Objective(add(terms), direction="max")
|
|
193
|
+
sol = model.optimize()
|
|
194
|
+
if sol.status == "optimal" and abs(sol.objective_value) > 1e-8:
|
|
195
|
+
fluxes = (pfba(model) if min_flux else sol).fluxes.reindex(reaction_ids)
|
|
196
|
+
if fluxes.isna().any(): # solver returned an unexpected reaction set
|
|
197
|
+
missing = fluxes.index[fluxes.isna()].tolist()
|
|
198
|
+
raise OptimizationError(
|
|
199
|
+
"solver returned fluxes missing reaction(s) "
|
|
200
|
+
f"{missing[:5]}; cannot assemble a NaN-free sample matrix."
|
|
201
|
+
)
|
|
202
|
+
samples[i, :] = fluxes.to_numpy()
|
|
203
|
+
break
|
|
204
|
+
if attempt == max_attempts:
|
|
205
|
+
if not suppress_errors:
|
|
206
|
+
raise OptimizationError(
|
|
207
|
+
"Could not find a non-zero, loop-free solution after "
|
|
208
|
+
f"{max_attempts} attempts for sample {i}. Review the model's "
|
|
209
|
+
"constraints, or set suppress_errors=True."
|
|
210
|
+
)
|
|
211
|
+
logger.warning("Sample %d: kept a degenerate solution after %d attempts.",
|
|
212
|
+
i, max_attempts)
|
|
213
|
+
samples[i, :] = sol.fluxes.reindex(reaction_ids).to_numpy()
|
|
214
|
+
|
|
215
|
+
return RandomSamplingResult(
|
|
216
|
+
samples=pd.DataFrame(samples, columns=reaction_ids),
|
|
217
|
+
good_reactions=good_reactions,
|
|
218
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Annotation helpers — SBO term assignment, ΔG CSV persistence.
|
|
2
|
+
|
|
3
|
+
These are the pieces of yeast-GEM's ``missingFields`` module that are
|
|
4
|
+
organism-agnostic enough to live upstream. Default parameter values
|
|
5
|
+
match the RAVEN/yeast convention so the functions are immediately
|
|
6
|
+
useful on the standard layout; consumers with different naming pass
|
|
7
|
+
overrides.
|
|
8
|
+
"""
|
|
9
|
+
from raven_toolbox.annotation.delta_g import load_delta_g_csv, save_delta_g_csv
|
|
10
|
+
from raven_toolbox.annotation.sbo import (
|
|
11
|
+
DEFAULT_BIOMASS_MET_NAMES,
|
|
12
|
+
DEFAULT_BIOMASS_RXN_NAME,
|
|
13
|
+
DEFAULT_NGAM_RXN_NAME,
|
|
14
|
+
add_sbo_terms,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_BIOMASS_MET_NAMES",
|
|
19
|
+
"DEFAULT_BIOMASS_RXN_NAME",
|
|
20
|
+
"DEFAULT_NGAM_RXN_NAME",
|
|
21
|
+
"add_sbo_terms",
|
|
22
|
+
"load_delta_g_csv",
|
|
23
|
+
"save_delta_g_csv",
|
|
24
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Persist scalar annotations (Gibbs free energy, etc.) as a side-car CSV.
|
|
2
|
+
|
|
3
|
+
The committed SBML standard has no slot for thermodynamic ΔG values, so
|
|
4
|
+
projects like yeast-GEM keep them in a paired CSV instead. This module
|
|
5
|
+
provides the generic load / save round-trip — the file format is a two
|
|
6
|
+
column table ``<id>, <value>`` — and exposes the per-call schema so
|
|
7
|
+
existing projects can configure their conventions.
|
|
8
|
+
|
|
9
|
+
The functions operate on cobra ``.notes`` (string round-tripped through
|
|
10
|
+
SBML), keeping any other notes untouched.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import cobra
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_delta_g_csv(
|
|
23
|
+
entities: Iterable,
|
|
24
|
+
path: str | Path,
|
|
25
|
+
*,
|
|
26
|
+
id_column: str = "Var1",
|
|
27
|
+
value_column: str = "Var2",
|
|
28
|
+
note_key: str = "deltaG",
|
|
29
|
+
verbose: bool = False,
|
|
30
|
+
) -> int:
|
|
31
|
+
"""Stamp ``note_key`` on each entity from a CSV of ``id → value``.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
entities
|
|
36
|
+
Cobra entities (``model.metabolites`` or ``model.reactions``).
|
|
37
|
+
path
|
|
38
|
+
Path to the CSV.
|
|
39
|
+
id_column, value_column
|
|
40
|
+
Column labels in the CSV. Defaults match the yeast-GEM
|
|
41
|
+
convention (``Var1`` / ``Var2`` from MATLAB's ``array2table``).
|
|
42
|
+
note_key
|
|
43
|
+
Key under which the value is stored on ``entity.notes``.
|
|
44
|
+
Default ``"deltaG"``.
|
|
45
|
+
verbose
|
|
46
|
+
Print a summary of unmatched entity ids.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
The number of entities that were stamped (i.e. matched the CSV).
|
|
51
|
+
"""
|
|
52
|
+
df = pd.read_csv(path)
|
|
53
|
+
if id_column not in df.columns or value_column not in df.columns:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"{path} columns {list(df.columns)} do not contain "
|
|
56
|
+
f"both {id_column!r} and {value_column!r}"
|
|
57
|
+
)
|
|
58
|
+
lookup = dict(zip(df[id_column], df[value_column], strict=True))
|
|
59
|
+
|
|
60
|
+
stamped = 0
|
|
61
|
+
missing: list[str] = []
|
|
62
|
+
for entity in entities:
|
|
63
|
+
value = lookup.get(entity.id)
|
|
64
|
+
if value is None or (isinstance(value, float) and math.isnan(value)):
|
|
65
|
+
missing.append(entity.id)
|
|
66
|
+
continue
|
|
67
|
+
entity.notes[note_key] = str(value)
|
|
68
|
+
stamped += 1
|
|
69
|
+
|
|
70
|
+
if verbose and missing:
|
|
71
|
+
print(
|
|
72
|
+
f"{len(missing)} entity ids were not in {path} "
|
|
73
|
+
f"(e.g. {missing[:3]}); left untouched."
|
|
74
|
+
)
|
|
75
|
+
return stamped
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def save_delta_g_csv(
|
|
79
|
+
entities: Iterable,
|
|
80
|
+
path: str | Path,
|
|
81
|
+
*,
|
|
82
|
+
id_column: str = "Var1",
|
|
83
|
+
value_column: str = "Var2",
|
|
84
|
+
note_key: str = "deltaG",
|
|
85
|
+
) -> int:
|
|
86
|
+
"""Dump ``entity.notes[note_key]`` for each entity to a CSV.
|
|
87
|
+
|
|
88
|
+
Entities without ``note_key`` set get ``NaN`` written, preserving
|
|
89
|
+
one-row-per-entity ordering (mirrors MATLAB's ``array2table([ids,
|
|
90
|
+
values])`` behaviour).
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
The number of rows written (= number of entities).
|
|
95
|
+
"""
|
|
96
|
+
rows: list[tuple[str, float]] = []
|
|
97
|
+
for entity in entities:
|
|
98
|
+
raw = entity.notes.get(note_key)
|
|
99
|
+
if raw is None:
|
|
100
|
+
value: float = math.nan
|
|
101
|
+
else:
|
|
102
|
+
try:
|
|
103
|
+
value = float(raw)
|
|
104
|
+
except ValueError:
|
|
105
|
+
value = math.nan
|
|
106
|
+
rows.append((entity.id, value))
|
|
107
|
+
pd.DataFrame(rows, columns=[id_column, value_column]).to_csv(path, index=False)
|
|
108
|
+
return len(rows)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Re-export the cobra Model type for type-checker friendliness; helps
|
|
112
|
+
# IDEs surface the right hints to callers that hand us model.metabolites
|
|
113
|
+
# / model.reactions directly.
|
|
114
|
+
__all__ = ["load_delta_g_csv", "save_delta_g_csv"]
|
|
115
|
+
_ = cobra # silence "imported but unused" — used for typing context above
|