Layers: coordinates, coupling, measurements, and mathematics¶
A node can occur in several conditions, timepoints, or compartments. A multilayer graph makes those placements explicit and can connect them. This chapter develops the coordinate model, imports a two-layer network, attaches measurements, selects regions, and computes with its supra-matrices.
Requires annnet[polars,storage] and the guide data in a
data/ directory beside this notebook. The notebook does not depend on the
other guide chapters. The examples use simplified signaling data and do not
show biological relations between experimental conditions.
The coordinate model¶
Let $V$ be the set of node identities and $L_1,\ldots,L_d$ the labels of the aspects. The possible layer coordinates form $L=L_1\times\cdots\times L_d$. The placements actually present are a subset $V_M\subseteq V\times L$. Declaring the labels does not create the whole product.
For condition, ERK in ('control',) and ERK in ('treated',) are different
placements of one node. With time and condition, a coordinate could be
('6h', 'treated'). Tuple order follows the aspect declarations; anet.at lets
you address existing placements by aspect name instead.
| Edge endpoints | Role |
|---|---|
| Same layer | Intra-layer |
| Different nodes in different layers | Inter-layer |
| Same node in different layers | Coupling |
A present, isolated placement is different from an absent one. Use
anet.layers.place to register isolated placements. Generic node attributes apply
to the identity across placements; node_layers attributes belong to one pair.
Slices name sets of existing elements. They suit multiple analysis results over one network. Layers change endpoint identity, and are useful when a method needs separate placements or inter-layer connections.
from pathlib import Path
from tempfile import TemporaryDirectory
import annnet as an
import numpy as np
import polars as pl
from annnet import Aspect
Prepare contextual edge batches¶
Declare the condition labels, then convert table endpoints to explicit
(node_id, layer_coordinate) pairs. We reuse the same prior in both conditions
for this example. With condition-specific input, select the corresponding rows
instead. The ID suffix distinguishes each placed interaction.
interactions = pl.read_csv('data/interactions.csv')
conditions = ['control', 'treated']
anet = an.Graph(directed=True, aspects={'condition': conditions})
anet.attrs.backend = 'polars'
for condition in conditions:
records = [
{
**row,
'edge_id': f'{row["edge_id"]}@{condition}',
'source': (row['source'], (condition,)),
'target': (row['target'], (condition,)),
}
for row in interactions.iter_rows(named=True)
]
an.add_edges_from_frame(anet, records, edge_id='edge_id', sign='effect', slice='prior')
anet.summary()
AnnNet summary: 10 node(s), 22 edge(s), 20 placement(s) directed default: True aspects (1): condition: [control, treated] layers occurring: 2 slices (2): default (0n/0e), prior (10n/22e) attrs: edges: ['sign', 'confidence', 'evidence'] edges (computed): binary=22; directed=22, undirected=0
There are 10 node identities, 20 placements, and 22 edges. ERK in control and ERK in treatment share a node ID but occupy different coordinates. Explicit endpoint pairs also avoid creating unintended placeholder placements.
anet.at('ERK', condition='treated')
('ERK', ('treated',))
Attach a labeled measurement array¶
The CSV has nodes in rows and conditions in columns. attach_values expects
layers in rows and nodes in columns, so transpose the numerical block and pass
both sets of labels. The array is borrowed rather than copied.
measurements = pl.read_csv('data/node_measurements.csv')
measurements
| node_id | control | treated |
|---|---|---|
| str | f64 | f64 |
| "EGFR" | 1.0 | 1.2 |
| "GRB2" | 0.8 | 1.0 |
| "SOS1" | 0.5 | 1.1 |
| "RAS" | 0.4 | 1.2 |
| "RAF" | 0.3 | 1.0 |
| "MEK" | 0.2 | 0.9 |
| "ERK" | 0.2 | 1.1 |
| "DUSP6" | 0.1 | 0.7 |
| "AKT" | 0.5 | 0.2 |
| "FOXO3" | -0.3 | -0.1 |
values = measurements.select(conditions).to_numpy().T
backing = anet.layers.attach_values(
{'response': values},
layers=[(condition,) for condition in conditions],
nodes=measurements['node_id'].to_list(),
)
block = anet.layers.matrix('response', nodes=['ERK', 'DUSP6'])
print('Rows:', block.layers)
print('Columns:', block.nodes)
print(block.values)
Rows: [('control',), ('treated',)]
Columns: ['ERK', 'DUSP6']
[[0.2 0.1]
[1.1 0.7]]
The rectangle is [[0.2, 0.1], [1.1, 0.7]]: control first, treatment
second; ERK first, DUSP6 second. Keep these labels with an algorithm's array
input so its output can be assigned back correctly. Attaching measurements alone
does not establish structural placements; the imports already created ours.
Add coupling only when the model needs it¶
For illustration, connect each node to its counterpart in the other condition with weight 0.2. This could express an analysis assumption about continuity; it is not inferred from the measurements and is not evidence of causality. Keep these introduced edges in a separate slice.
anet.slices.add('coupling')
anet.slices.active = 'coupling'
anet.layers.couple('condition', kind='categorical', weight=0.2)
10
Edge kinds¶
annnet sets the multilayer kind of each edge from its two endpoints. You do not give it:
| endpoints | kind |
|---|---|
| different nodes in the same layer | intra |
| the same node in different layers | coupling |
| different nodes in different layers | inter |
The ml_kind column of the edge table shows the kind. The prior network gives
the intra edges, the coupling step the coupling edges. An edge from
('ERK', ('control',)) to ('DUSP6', ('treated',)) would be inter.
anet.attrs.table('edges', derived=True).group_by('ml_kind').len().sort('ml_kind')
| ml_kind | len |
|---|---|
| str | u32 |
| "coupling" | 10 |
| "intra" | 22 |
Choose a boundary¶
The closed treatment view contains its 11 intra-layer edges and excludes coupling to control. An open boundary also includes touching coupling edges and their full endpoints. It therefore reaches beyond the treatment layer.
window = anet.layers.where(condition='treated')
closed = anet.view(layers=window)
opened = anet.view(layers=window, boundary='open')
print('Closed:', len(closed.E), 'edges')
print('Open:', len(opened.E), 'edges')
Closed: 11 edges Open: 21 edges
Supra-adjacency and its blocks¶
A supra-matrix puts the placements on ordinary matrix axes. For binary edges, $A_{ij}$ is the sum of weights from placement $i$ to $j$. Undirected edges contribute in both directions. Parallel edges add; their individual identities remain available in the graph even when the matrix combines their weights.
With coordinates grouped by condition, our graph has the block form
$$A=\begin{pmatrix}A_{control}&\omega I\\\omega I&A_{treated}\end{pmatrix}, \qquad \omega=0.2.$$
The cross-layer identity blocks arise because each coupling joins the same node across conditions. Other inter-layer relationships would have a different pattern. This example contains no hyperedges; projecting hyperedges to a pairwise operator requires its own policy.
A = anet.layers.supra_adjacency()
A_intra = anet.layers.build_intra_block()
A_inter = anet.layers.build_inter_block()
A_coupling = anet.layers.build_coupling_block()
print('Supra-adjacency shape:', A.shape)
print(
'Sum of the blocks equals A:',
np.allclose(A.toarray(), (A_intra + A_inter + A_coupling).toarray()),
)
nodes = sorted(anet.N)
control_rows = [anet.layers.nl_to_row(node, ('control',)) for node in nodes]
treated_rows = [anet.layers.nl_to_row(node, ('treated',)) for node in nodes]
cross_block = A.toarray()[np.ix_(control_rows, treated_rows)]
print('Cross-layer block:')
print(cross_block)
Supra-adjacency shape: (20, 20) Sum of the blocks equals A: True Cross-layer block: [[0.2 0. 0. 0. 0. 0. 0. 0. 0. 0. ] [0. 0.2 0. 0. 0. 0. 0. 0. 0. 0. ] [0. 0. 0.2 0. 0. 0. 0. 0. 0. 0. ] [0. 0. 0. 0.2 0. 0. 0. 0. 0. 0. ] [0. 0. 0. 0. 0.2 0. 0. 0. 0. 0. ] [0. 0. 0. 0. 0. 0.2 0. 0. 0. 0. ] [0. 0. 0. 0. 0. 0. 0.2 0. 0. 0. ] [0. 0. 0. 0. 0. 0. 0. 0.2 0. 0. ] [0. 0. 0. 0. 0. 0. 0. 0. 0.2 0. ] [0. 0. 0. 0. 0. 0. 0. 0. 0. 0.2]]
Use anet.layers.nl_to_row and row_to_nl with the full supra index. Do not
assume its ordering matches a separately built anet.A, a filtered matrix, or
alphabetical order of bare node IDs. Named matrices such as
anet.matrices.adjacency() provide their own maps. Save labels with numerical
results and rebuild positional lookups after structural edits.
row = anet.layers.nl_to_row('ERK', ('control',))
print('ERK in control occupies supra row', row)
print('Row', row, 'maps back to', anet.layers.row_to_nl(row))
ERK in control occupies supra row 6
Row 6 maps back to ('ERK', ('control',))
Laplacian and a random walk¶
For this nonnegative adjacency, set $D_{ii}=\sum_j A_{ij}$.
The combinatorial Laplacian is $L=D-A$. Its rows sum to zero.
kind='norm' provides $I-D^{-1/2}AD^{-1/2}$; symmetric spectral interpretations
require an appropriate undirected model, which our directed prior is not.
The transition matrix is $P=D^{-1}A$ on rows with positive degree. Here every placement has a coupling neighbor, so all rows sum to one. For a graph with an isolated placement, the API leaves its row zero; choose a dangling-node policy before claiming that probability is conserved. Negative weights likewise need a different interpretation.
L = anet.layers.supra_laplacian(kind='comb')
P = anet.layers.transition_matrix()
print('Laplacian rows sum to zero:', np.allclose(L.sum(axis=1), 0))
print('Transition rows sum to one:', np.allclose(P.sum(axis=1), 1))
p0 = np.zeros(A.shape[0])
p0[anet.layers.nl_to_row('ERK', ('control',))] = 1.0
p1 = anet.layers.random_walk_step(p0)
[(anet.layers.row_to_nl(i), float(p1[i])) for i in np.flatnonzero(p1)]
Laplacian rows sum to zero: True Transition rows sum to one: True
[(('DUSP6', ('control',)), 0.8333333333333334),
(('ERK', ('treated',)), 0.16666666666666669)]
From ERK in control, one step reaches DUSP6 in control with probability $1/1.2$ and ERK in treatment with probability $0.2/1.2$. These probabilities follow from the chosen weights. They do not use the response array or the effect sign.
For a vector of node features, diffusion_step(x, tau=...) evaluates
$x-\tau Lx$. That is a different operation from moving a row probability vector
with $pP$. Choose a step size and directed/undirected model suitable for the
intended dynamics rather than treating these operators as interchangeable.
x = np.zeros(A.shape[0])
x[row] = 1.0
next_x = anet.layers.diffusion_step(x, tau=0.1)
np.allclose(next_x, x - 0.1 * (L @ x))
True
Several aspects and an ordered window¶
An aspect is categorical by default. Declare an order for time or dose when queries should compare positions. The order is ordinal, not a declaration of elapsed time. The following small table has two timepoints in treatment; the control labels exist but no control placements are created.
rows = [
{
'edge_id': f'feedback@{time}',
'source': ('DUSP6', (time, 'treated')),
'target': ('ERK', (time, 'treated')),
'effect': -1,
}
for time in ('0h', '6h')
]
time_anet = an.from_edge_frame(
rows,
edge_id='edge_id',
sign='effect',
aspects={
'time': Aspect(['0h', '6h', '24h'], ordered=True),
'condition': ['control', 'treated'],
},
)
print('Nodes:', len(time_anet.N), 'placements:', time_anet.nv_supra)
print(time_anet.layers.where(time__lte='6h', condition='treated').layers)
print(
'Coupling edges:', time_anet.layers.couple('time', within={'condition': 'treated'}, weight=0.1)
)
Nodes: 2 placements: 4
(('0h', 'treated'), ('6h', 'treated'))
Coupling edges: 2
Ordinal coupling joins consecutive declared time labels where both placements
exist. both_present=False allows it to create missing placements; use that
only when the model calls for them. Comparisons requiring order are refused on
categorical aspects. Explicit pairs= and within= can restrict coupling to
the coordinates you intend.
The costs to watch are placement count, edge count, and the size of requested matrices. Attaching a large assay array does not require placing every assay entity in every sample. Place the network entities needed for topology and contextual writes, and read other measurements through their array labels.
Save the placed measurements¶
The native writer materializes attached values for existing placements. All pairs in this small matrix have placements, so the layer coordinates and the measurements stay after the round trip. A larger external assay can include pairs without placements; keep that assay separately.
The example writes into a temporary directory. To keep the file, use your own path.
with TemporaryDirectory() as directory:
path = Path(directory) / 'layered.annnet'
anet.write(path)
restored = an.read(path)
print(restored.layers.matrix('response', nodes=['ERK', 'DUSP6']).values)
restored.summary()
[[0.2 0.1] [1.1 0.7]]
AnnNet summary: 10 node(s), 32 edge(s), 20 placement(s) directed default: True aspects (1): condition: [control, treated] layers occurring: 2 slices (3): default (0n/0e), prior (10n/22e), coupling (10n/10e) attrs: edges: ['sign', 'confidence', 'evidence', 'edge_kind']; node_layers: ['response'] edges (computed): binary=32; directed=22, undirected=10