Annotations, selections, and views¶
An imported network has values attached to different objects. This notebook explains where those values belong, how to read and update them, and how a predicate becomes a live graph view. The running question is which supported, inhibitory interactions to keep for analysis.
Requires annnet[polars,storage] and the guide data in a
data/ directory beside this notebook. This notebook does not depend on the
other guide chapters.
from pathlib import Path
from tempfile import TemporaryDirectory
import annnet as an
import polars as pl
Inspect the input¶
The CSV contains one evidence record per row. Two rows share the EGFR–GRB2 endpoints but have different IDs and confidence scores. Retaining the IDs lets later results distinguish those records.
interactions = pl.read_csv('data/interactions.csv')
interactions
| edge_id | source | target | effect | confidence | evidence |
|---|---|---|---|---|---|
| str | str | str | i64 | f64 | str |
| "egfr_grb2" | "EGFR" | "GRB2" | 1 | 0.95 | "example_primary" |
| "egfr_grb2_alt" | "EGFR" | "GRB2" | 1 | 0.65 | "example_alternative" |
| "grb2_sos1" | "GRB2" | "SOS1" | 1 | 0.9 | "example_primary" |
| "sos1_ras" | "SOS1" | "RAS" | 1 | 0.9 | "example_primary" |
| "ras_raf" | "RAS" | "RAF" | 1 | 0.95 | "example_primary" |
| … | … | … | … | … | … |
| "mek_erk" | "MEK" | "ERK" | 1 | 0.98 | "example_primary" |
| "erk_dusp6" | "ERK" | "DUSP6" | 1 | 0.85 | "example_primary" |
| "dusp6_erk" | "DUSP6" | "ERK" | -1 | 0.9 | "example_primary" |
| "egfr_akt" | "EGFR" | "AKT" | 1 | 0.6 | "example_branch" |
| "akt_foxo3" | "AKT" | "FOXO3" | -1 | 0.75 | "example_branch" |
Put values at the right address¶
The tutorial network has a confidence score for each evidence record. That is an edge attribute. An activity estimated separately in control and treatment belongs to an edge-slice pair. A measured response for ERK in the treatment layer belongs to a node-layer pair.
anet.attrs provides the same reading and writing operations at eight addresses:
| Address | Key | Example |
|---|---|---|
nodes |
Node ID | Display label or identifier namespace. |
edges |
Edge ID | Confidence, source resource, or effect sign. |
slices |
Slice ID | Method and parameters for an analysis result. |
edge_slices |
(slice_id, edge_id) |
Activity of an edge in one result. |
aspects |
Aspect name | Description of a condition axis. |
elementary_layers |
(aspect, label) |
Metadata for the label treated. |
layers |
Layer coordinate | Metadata for a complete coordinate such as ('6h', 'treated'). |
node_layers |
(node_id, layer_coordinate) |
A node's measurement in that layer. |
Graph-wide metadata goes in anet.uns, a dictionary suitable for an analysis
description, source information, or parameter settings. A generic node
attribute applies to the node across its placements; use node_layers when
the value differs between them.
The node and edge addresses apply throughout the graph. Slice-scoped values are developed in Slices and analysis contexts; node-layer arrays are covered in Layers. Use a contextual address when two analyses need different values for the same identity.
Import identities and annotations¶
Map effect to the sign annotation and put the imported interactions in a
slice named prior. Confidence and evidence remain ordinary edge attributes.
There is no weight mapping here, so structural weights remain 1.
anet = an.from_edge_frame(interactions, edge_id='edge_id', sign='effect', slice='prior')
anet.attrs.backend = 'polars'
anet.summary()
AnnNet summary: 10 node(s), 11 edge(s), 10 placement(s) directed default: True slices (2): default (0n/0e), prior (10n/11e) attrs: edges: ['sign', 'confidence', 'evidence'] edges (computed): binary=11; directed=11, undirected=0
The result has 10 nodes and 11 edges. Endpoint nodes were created during import.
The empty default slice is separate from the populated prior slice; naming
an import slice does not make every other slice contain its edges.
anet.attrs.table('edges', derived=True).select('edge_id', 'source', 'target', 'sign', 'confidence')
| edge_id | source | target | sign | confidence |
|---|---|---|---|---|
| str | str | str | i64 | f64 |
| "egfr_grb2" | "EGFR" | "GRB2" | 1 | 0.95 |
| "egfr_grb2_alt" | "EGFR" | "GRB2" | 1 | 0.65 |
| "grb2_sos1" | "GRB2" | "SOS1" | 1 | 0.9 |
| "sos1_ras" | "SOS1" | "RAS" | 1 | 0.9 |
| "ras_raf" | "RAS" | "RAF" | 1 | 0.95 |
| … | … | … | … | … |
| "mek_erk" | "MEK" | "ERK" | 1 | 0.98 |
| "erk_dusp6" | "ERK" | "DUSP6" | 1 | 0.85 |
| "dusp6_erk" | "DUSP6" | "ERK" | -1 | 0.9 |
| "egfr_akt" | "EGFR" | "AKT" | 1 | 0.6 |
| "akt_foxo3" | "AKT" | "FOXO3" | -1 | 0.75 |
Attribute reads and writes¶
anet.attrs.row returns a detached dictionary; tables are detached too. A change
to a returned object does not update the graph. Write with update to merge
fields, replace to replace the table of an address, or delete to remove
attributes and keep the graph elements. annnet validates a batch before it
changes the graph.
The examples select Polars. pandas
and PyArrow are also supported.
Table operations such as .select() are specific to the selected library.
row = anet.attrs.row('edges', 'egfr_grb2')
row['confidence'] = 0.0
print('Stored confidence:', anet.attrs.row('edges', 'egfr_grb2')['confidence'])
anet.attrs.update('nodes', {'ERK': {'label': 'ERK response', 'score': 0.0}})
anet.attrs.row('nodes', 'ERK')
Stored confidence: 0.95
{'label': 'ERK response', 'score': np.float64(0.0)}
Zero is a value. Writing None or a floating-point NaN through update removes
the field, and predicates treat the resulting absence as null. Whole columns
read through anet.N['score'] or anet.E['confidence'] are read-only arrays; copy
those arrays if you need them to remain stable across later edits.
anet.attrs.update('nodes', {'ERK': {'score': None}})
print(anet.attrs.row('nodes', 'ERK'))
print(anet.attrs.schema('edges'))
{'label': 'ERK response'}
Schema('edges', 11 row(s))
keys: edge_id: str
fields: sign: int, confidence: float, evidence: str
Select evidence and inspect a graph view¶
Keep confidence at least 0.85. This threshold is an illustrative analysis choice. The selection retains the condition; the view exposes the corresponding graph, including the endpoints of its selected edges.
supported = anet.E.select(confidence__gte=0.85)
view = anet.view(edges=supported)
print('Selected:', len(view.N), 'nodes and', len(view.E), 'edges')
view.attrs.table('edges', derived=True).select('edge_id', 'confidence')
Selected: 8 nodes and 8 edges
| edge_id | confidence |
|---|---|
| str | f64 |
| "egfr_grb2" | 0.95 |
| "grb2_sos1" | 0.9 |
| "sos1_ras" | 0.9 |
| "ras_raf" | 0.95 |
| "raf_mek" | 0.98 |
| "mek_erk" | 0.98 |
| "erk_dusp6" | 0.85 |
| "dusp6_erk" | 0.9 |
Eight edges pass the threshold. Add a second condition to ask which of those
records describe inhibition. The answer is the DUSP6–ERK record; its negative
sign is an annotation, not a reversal of the stored edge.
inhibitory = anet.E.select(sign=-1)
hits = supported & inhibitory
hits.ids
('dusp6_erk',)
Compose predicates and choose a boundary¶
Selections support & (intersection), | (union), and - (difference), and
retain their graph and axis. Operators include equality, membership, ordered
comparisons, and isnull. A missing value only satisfies isnull=True.
For a node selection, a closed view retains only edges wholly inside it. An open view includes touching edges and their full endpoints for one hop. Hyperedges are kept whole or excluded. A nested view can only narrow its parent.
closed = anet.view(nodes=['MEK', 'ERK'])
opened = anet.view(nodes=['MEK', 'ERK'], boundary='open')
print('Closed edges:', closed.E.ids)
print('Open edges:', opened.E.ids)
print('Added by the open boundary:', opened.summary()['expanded'])
Closed edges: ('mek_erk',)
Open edges: ('raf_mek', 'mek_erk', 'erk_dusp6', 'dusp6_erk')
Added by the open boundary: {'nodes': 2, 'edges': 0}
A selection retains its predicate, but .ids is a tuple read at one moment.
anet.attrs.from_frame also captures the identities named by a table instead of
retaining its filter. Use that distinction when deciding whether a selection
should follow later annotation changes.
Observe what stays live¶
Materialize the current view before changing the evidence score of EGFR–AKT. The parent edit makes that record pass the threshold. The live view gains it; the independent graph retains the earlier selection.
selected_anet = view.materialize()
anet.attrs.update('edges', {'egfr_akt': {'confidence': 0.92}})
print('Live view:', len(view.E), 'edges')
print('Independent graph:', len(selected_anet.E), 'edges')
Live view: 9 edges Independent graph: 8 edges
view is read-only. Write through the parent when the source data should change,
or through selected_anet when developing a separate analysis. Editing an attribute table
returned by either graph does not update the graph itself.
selected_anet.attrs.update('nodes', {'ERK': {'label': 'ERK response'}})
selected_anet.uns['selection_rule'] = 'confidence >= 0.85 before the EGFR–AKT update'
selected_anet.attrs.row('nodes', 'ERK')
{'label': 'ERK response'}
Save the selected graph¶
The example writes into a temporary directory, so you can run it again. To keep an analysis, use your own path. The native format keeps IDs and annotations, so the reopened graph has the selected edges and the label of ERK.
with TemporaryDirectory() as directory:
path = Path(directory) / 'selected.annnet'
selected_anet.write(path)
restored = an.read(path)
restored.summary()
AnnNet summary: 8 node(s), 8 edge(s), 8 placement(s) directed default: True slices (2): default (0n/0e), prior (8n/8e) attrs: nodes: ['label']; edges: ['sign', 'confidence', 'evidence'] edges (computed): binary=8; directed=8, undirected=0
The graph owns its annotations; detached tables support analysis with your dataframe library. Predicate selections stay live, views expose a read-only subgraph, and materialization creates independent topology and ordinary annotations. Attached numerical backings can remain shared by reference; copy those arrays separately when independent measurement storage is required.