Directed hyperedges & stoichiometry¶
A deeper dive than the tutorial. We build a small metabolic-style network where each reaction is a directed hyperedge with explicit substrates (head) and products (tail), then we set stoichiometric coefficients on the incidence matrix and verify the column conserves mass.
You'll see:
- Building directed hyperedges
- Reading them back via views
- Reading and writing per-member coefficients on the incidence matrix
- A small worked example (two coupled reactions sharing a metabolite)
- Reversing the orientation of a reaction
from annnet import AnnNet
H = AnnNet(directed=False) # the graph default doesn't matter; each edge sets its own
H.add_vertices(['Glc', 'ATP', 'G6P', 'ADP', 'F6P'])
H
AnnNet object with n_vertices × n_edges = 5 × 0
directed: False
slices: ['default']
1. Building directed hyperedges¶
add_edges(src=[heads], tgt=[tails], edge_id=..., directed=True, weight=...).
We model two reactions:
- hexokinase: Glc + ATP → G6P + ADP
- PGI: G6P → F6P
H.add_edges(src=['Glc', 'ATP'], tgt=['G6P', 'ADP'], edge_id='hexokinase', directed=True)
H.add_edges(src=['G6P'], tgt=['F6P'], edge_id='PGI', directed=True)
for eid in ('hexokinase', 'PGI'):
e = H.get_edge(eid)
print(f' {eid:>11}: head={set(e.source)}, tail={set(e.target)}, directed={e.directed}')
hexokinase: head={'Glc', 'ATP'}, tail={'G6P', 'ADP'}, directed=True
PGI: head={'G6P'}, tail={'F6P'}, directed=True
2. Reading hyperedges via views¶
G.views.edges() exposes hyperedges through head, tail, and
members columns. kind is 'hyper' for both directed and undirected.
H.views.edges().select(['edge_id', 'kind', 'head', 'tail', 'directed']).head()
| edge_id | kind | head | tail | directed |
|---|---|---|---|---|
| str | str | list[str] | list[str] | bool |
| "hexokinase" | "hyper" | ["ATP", "Glc"] | ["ADP", "G6P"] | true |
| "PGI" | "hyper" | ["G6P"] | ["F6P"] | true |
3. Stoichiometric coefficients¶
The incidence matrix is the source of truth. Each column is one edge; each row is one entity. Default convention for directed hyperedges:
+wat every head (substrate) row-wat every tail (product) row
You can override per-member by writing directly to the matrix.
# Read hexokinase's column off the incidence matrix, via the public API.
def print_column(graph, eid):
col = graph.edge_to_idx[eid] # edge id -> matrix column
incidence = graph.ops.incidence(values=True) # dense: vertices x edges
labels = list(graph.ops.incidence_as_lists()) # vertex ids, in row order
print(f'Column for {eid}:')
for row, vid in enumerate(labels):
val = incidence[row, col]
if val != 0:
print(f' {vid:>5}: {val:+.2f}')
print_column(H, 'hexokinase')
Column for hexokinase:
Glc: +1.00
ATP: +1.00
G6P: -1.00
ADP: -1.00
# Override the coefficient on F6P (toy example: assume a 2:1 reaction).
# set_edge_coeffs writes the incidence matrix through the public API.
H.set_edge_coeffs('PGI', {'F6P': -2.0}) # 2 molecules of F6P per step (artificial)
print_column(H, 'PGI')
Column for PGI:
G6P: +1.00
F6P: -2.00
4. Mass balance for a metabolite shared between reactions¶
G6P is produced by hexokinase and consumed by PGI. If you assume both
reactions run at the same rate, the column-sum tells you the net flux
on each species.
import numpy as np
# Net flux at unit rate per reaction: B @ [1, 1] where B is the incidence
# matrix restricted to our two reactions.
B = H.ops.incidence(values=True) # dense: vertices x edges
labels = list(H.ops.incidence_as_lists()) # vertex ids, in matrix-row order
cols = [H.edge_to_idx[eid] for eid in ('hexokinase', 'PGI')]
rates = np.array([1.0, 1.0])
flux = B[:, cols] @ rates # net change per entity
for row, vid in enumerate(labels):
print(f' {vid:>5}: net flux = {flux[row]:+.2f}')
Glc: net flux = +1.00
ATP: net flux = +1.00
G6P: net flux = +0.00
ADP: net flux = -1.00
F6P: net flux = -2.00
!!! note "Reading: convention is per-edge"
Whether a reaction is head→tail or tail→head is a convention
you set when calling add_edges. AnnNet exposes both sides
via get_edge(eid).source / .target. If you want the opposite
convention, swap the lists.
5. Reading via G.get_edge¶
get_edge(edge_id) returns a typed EdgeView tuple. For directed
hyperedges, view.source is the head set, view.target is the tail
set, and view.members is the full incident set.
v = H.get_edge('hexokinase')
print('edge_id:', v.edge_id)
print('kind: ', v.kind)
print('source: ', set(v.source))
print('target: ', set(v.target))
print('members:', set(v.members))
print('directed:', v.directed)
edge_id: hexokinase
kind: hyper_directed
source: {'Glc', 'ATP'}
target: {'G6P', 'ADP'}
members: {'Glc', 'ADP', 'ATP', 'G6P'}
directed: True
See also¶
- Tutorial
05_hyperedges.ipynbfor the introductory pass. - The UC2 use-case notebook for a real metabolic network built from a Human-GEM SBML file.