Backend lazy proxies — run igraph & NetworkX algorithms straight off an AnnNet¶
AnnNet is the source of truth; igraph, NetworkX, and graph-tool are algorithm libraries you
borrow on demand. G.ig, G.nx, and G.gt are lazy proxies: no backend graph exists
until you call an algorithm, then AnnNet converts once, caches the result (keyed on
the graph version), and rebuilds transparently if you mutate the graph.
So you call G.ig.betweenness() or G.nx.pagerank(G) by gene symbol and get an
answer — never holding a backend object yourself. The network below is the real
DoRothEA TF→target graph (~5k genes, ~15k signed interactions), fetched live from
OmniPath, so the notebook runs anywhere with no data files.
1. Build the graph¶
AnnNet reaches no knowledge base of its own, so the fetch and the build are two
steps. omnipath.interactions.Dorothea fetches the DoRothEA regulatory network
live from OmniPath, and omnipath_client.to_annnet turns that table into a
graph. The result is a directed, signed graph carrying real edge annotations
(is_stimulation, is_inhibition).
import annnet as an
import omnipath as op
import omnipath_client as oc
dorothea = op.interactions.Dorothea.get(genesymbols=True)
G = oc.to_annnet(
dorothea,
source_col='source_genesymbol',
target_col='target_genesymbol',
directed_col='is_directed',
edge_attr_cols=['is_stimulation', 'is_inhibition'],
)
print('nodes :', G.nv)
print('edges :', G.ne)
print('directed :', G.directed)
print('edge cols:', list(G.var.columns))
nodes : 5252 edges : 15267 directed : True edge cols: ['edge_id', 'is_stimulation', 'is_inhibition']
2. The proxy is lazy¶
Nothing converts until you ask. .backend() materialises and caches the backend
graph; call it again and you get the same object back.
ig_graph = G.ig.backend() # first call: convert AnnNet -> igraph
ig_graph_again = G.ig.backend() # second call: served from cache
print(type(ig_graph).__module__, type(ig_graph).__name__)
print('vcount / ecount :', ig_graph.vcount(), ig_graph.ecount())
print('same cached object :', ig_graph is ig_graph_again)
nx_graph = G.nx.backend()
print(type(nx_graph).__name__, '->', nx_graph.number_of_nodes(), 'nodes,',
nx_graph.number_of_edges(), 'edges')
igraph Graph vcount / ecount : 5252 18266 same cached object : True
MultiDiGraph -> 5252 nodes, 18266 edges
3. igraph algorithms, driven from the AnnNet¶
Any igraph method or top-level function is reachable as G.ig.<name>(...). igraph
returns results by node index, so pair them with G.ig.peek_nodes() to read
them back as gene symbols.
# Betweenness flags regulatory bottlenecks. igraph returns a plain list keyed by
# node index; peek_nodes gives the matching gene symbols, in the same order.
bt = G.ig.betweenness()
names = G.ig.peek_nodes(G.nv)
top_bt = sorted(zip(names, bt), key=lambda s: -s[1])[:8]
print('Top betweenness hubs (master regulators):')
for gene, score in top_bt:
print(f' {gene:8s} {score:12.1f}')
Top betweenness hubs (master regulators): MYC 1355290.7 SP1 1099562.5 TP53 1085433.2 E2F1 1049322.2 STAT1 944328.9 CTCF 787783.6 ETS1 649352.4 EGR1 627043.3
# Community detection wants an undirected view: pass _ig_directed=False and the
# proxy caches a separate undirected conversion under its own key.
comm = G.ig.community_multilevel(_ig_directed=False)
print('communities :', len(comm))
print('modularity :', round(comm.modularity, 3))
print('largest 3 :', sorted((len(c) for c in comm), reverse=True)[:3])
communities : 23 modularity : 0.415 largest 3 : [1042, 710, 686]
4. Address nodes by gene symbol¶
Node IDs are gene symbols, so pass them straight in — the proxy maps them to
backend indices and maps results back. (Different labels? Point at the column with
_ig_label_field= / _nx_label_field=.)
# Shortest directed path length, MYC -> CDKN1A.
d = G.ig.distances(source='MYC', target='CDKN1A', mode='out')
print('igraph MYC -> CDKN1A hops:', d[0][0])
igraph MYC -> CDKN1A hops: 1
5. The same graph through NetworkX¶
G.nx.<name>(...) reaches any NetworkX algorithm; pass G where it expects a
graph. NetworkX outputs come back already labelled with gene symbols.
# Named shortest path — nodes come back as gene symbols.
path = G.nx.shortest_path(G, source='MYC', target='CDKN1A')
print('NetworkX MYC -> CDKN1A path:', path)
# PageRank as a {gene: score} dict.
pr = G.nx.pagerank(G)
print('\nTop PageRank genes:')
for gene, score in sorted(pr.items(), key=lambda kv: -kv[1])[:5]:
print(f' {gene:8s} {score:.5f}')
NetworkX MYC -> CDKN1A path: ['MYC', 'CDKN1A'] Top PageRank genes: SP1 0.01612 MYC 0.01342 E2F1 0.01025 TP53 0.01000 NFKB1 0.00884
6. And again through graph-tool¶
G.gt is the third lazy proxy. graph-tool groups its algorithms into namespaces,
so you call G.gt.<namespace>.<algo>(...) — e.g. G.gt.centrality.pagerank. Like
igraph, it hands back native graph-tool objects (property maps); read them back
through the graph's id vertex property. graph-tool ships via conda, not pip, so
this section self-skips when it isn't installed.
import importlib.util
if importlib.util.find_spec('graph_tool') is None:
print('graph-tool not installed — skipping (conda install -c conda-forge graph-tool).')
else:
gt_graph = G.gt.backend()
print(type(gt_graph).__module__, '->',
gt_graph.num_vertices(), 'vertices,', gt_graph.num_edges(), 'edges')
# Named shortest directed distance, MYC -> CDKN1A (addressed by gene symbol).
dist = G.gt.topology.shortest_distance(G, source='MYC', target='CDKN1A')
print('graph-tool MYC -> CDKN1A hops:', int(dist))
# PageRank -> a graph-tool VertexPropertyMap; map it back via the 'id' property.
pr = G.gt.centrality.pagerank(G)
ids = gt_graph.vp['id']
top_pr = sorted(((ids[v], pr[v]) for v in gt_graph.vertices()), key=lambda s: -s[1])[:5]
print('Top PageRank genes (graph-tool):')
for gene, score in top_pr:
print(f' {gene:8s} {score:.5f}')
graph-tool not installed — skipping (conda install -c conda-forge graph-tool).
7. Mutating the AnnNet invalidates the cache¶
The cache is keyed on the graph's version counter. Any structural edit bumps it, so the next proxy call reconverts — you never manage staleness by hand.
before = G.ig.backend()
G.add_nodes(['__PROBE__']) # structural mutation -> version bump
after = G.ig.backend()
print('same object after mutation :', before is after) # False: rebuilt
print('vcount before / after :', before.vcount(), '/', after.vcount())
same object after mutation : False vcount before / after : 5252 / 5253
8. Two things to know¶
- Lossy conversions warn. Hyperedges and multiple layers/slices have no plain
igraph/NetworkX equivalent; converting a graph that has them emits a
RuntimeWarningnaming what was dropped. The AnnNet is untouched — the backend view is just a projection. - Take collections, not scalar counts. The proxy maps integer outputs back to
node IDs, so a function returning a plain count has its int mapped to a node.
Use the collection-returning variant and
len()it yourself.
# DON'T rely on scalar-int returns through the proxy:
mapped = G.nx.number_weakly_connected_components(G)
print('number_weakly_connected_components ->', repr(mapped), ' # <- int mapped to a node id!')
# DO take the collection and measure it:
n_wcc = len(list(G.nx.weakly_connected_components(G)))
print('len(weakly_connected_components) ->', n_wcc, ' # correct')
number_weakly_connected_components -> 'CCNA2' # <- int mapped to a node id! len(weakly_connected_components) -> 10 # correct
Recap¶
G.ig/G.nx/G.gtare lazy — no backend graph until an algorithm is called.- The conversion is cached per graph version and reused across calls; mutating the AnnNet invalidates it transparently.
- Address nodes by their AnnNet IDs (or a label column). NetworkX maps outputs
back to them; igraph returns by index (pair it with
peek_nodes); graph-tool returns native property maps (read back via theidnode property). - AnnNet stays the single source of truth; igraph, NetworkX, and graph-tool are disposable, on-demand compute backends.