Temporal networks: formats and conversions#
Networks observed over time come in three shapes, and
nx_backbone.temporal accepts all three, built from ordinary
NetworkX graphs. This page shows each form and every conversion between
them. The temporal backbone scores (see Temporal Backbones)
take any of the three forms.
The three forms#
Snapshots are a dict {time: graph} (or a list, read as times
0, 1, ...). A node is present at a time if it is in that snapshot, even
when it has no edges:
>>> import networkx as nx
>>> import nx_backbone as nb
>>> S = {
... 0: nx.Graph([("ann", "bob")]),
... 1: nx.Graph([("ann", "bob"), ("bob", "cat")]),
... 2: nx.Graph(),
... 3: nx.Graph([("bob", "cat")]),
... }
>>> S[2].add_node("dan") # present at time 2, but with no contacts
Events are one MultiGraph (or
MultiDiGraph) with one edge per event and its time in the
time attribute:
>>> E = nx.MultiGraph()
>>> E.add_edges_from([
... ("ann", "bob", {"time": 0.5}),
... ("ann", "bob", {"time": 1.2, "weight": 3}),
... ("bob", "cat", {"time": 3.7}),
... ])
[0, 1, 0]
Spans are a multigraph whose edges last from start to end. A span
covers the half-open interval [start, end): it includes its start and
excludes its end, so a span ending at 2 and one starting at 2 do not overlap.
>>> P = nx.MultiGraph()
>>> P.add_edges_from([
... ("ann", "bob", {"start": 0, "end": 2}),
... ("bob", "cat", {"start": 1, "end": 4}),
... ])
[0, 0]
temporal_form() tells them apart. Pass time=,
start= or end= when your attributes have other names:
>>> nb.temporal_form(S), nb.temporal_form(E), nb.temporal_form(P)
('snapshots', 'events', 'spans')
Snapshots to events, and back#
events_from_snapshots() writes one event per edge per
snapshot, timed by the snapshot’s key:
>>> M = nb.events_from_snapshots(S)
>>> sorted((u, v, t) for u, v, t in M.edges(data="time"))
[('ann', 'bob', 0), ('ann', 'bob', 1), ('bob', 'cat', 1), ('bob', 'cat', 3)]
An edge list cannot hold the empty snapshot at time 2 or the isolated
dan, so the converter records them in M.graph["temporal"].
snapshots_from_events() uses that record to give the
snapshots back exactly:
>>> R = nb.snapshots_from_events(M)
>>> list(R), list(R[2])
([0, 1, 2, 3], ['dan'])
>>> all(nx.utils.graphs_equal(R[t], S[t]) for t in S)
True
Snapshots to spans, and back#
spans_from_snapshots() merges each edge’s run of
consecutive snapshots into one span. A run ends at the time of the next
snapshot; the last snapshot lasts one step here because the times are evenly
spaced (otherwise pass freq= or end_time=):
>>> M = nb.spans_from_snapshots(S)
>>> sorted((u, v, d["start"], d["end"]) for u, v, d in M.edges(data=True))
[('ann', 'bob', 0, 2), ('bob', 'cat', 1, 2), ('bob', 'cat', 3, 4)]
>>> R = nb.snapshots_from_spans(M)
>>> all(nx.utils.graphs_equal(R[t], S[t]) for t in S)
True
Events to snapshots: binning#
Binning with a step width, freq=, groups the events into snapshots. Bin
k covers [origin + k * freq, origin + (k + 1) * freq) and is keyed by
its start; origin defaults to the earliest time. Events of the same pair
in one bin collapse into one edge whose weight is their summed weight (a
missing weight counts as 1) and whose edge_count is their number:
>>> B = nb.snapshots_from_events(E, freq=1, origin=0)
>>> {t: sorted(G.edges(data="weight")) for t, G in B.items()}
{0: [('ann', 'bob', 1)], 1: [('ann', 'bob', 3)], 2: [], 3: [('bob', 'cat', 1)]}
>>> B = nb.snapshots_from_events(E, freq=2, origin=0)
>>> B[0]["ann"]["bob"]
{'edge_count': 2, 'weight': 4}
Empty bins between the first and the last event are kept (time 2 above). A
node is present in a bin only if it has an event there, so cat is absent
from bin 0. aggregate="count", "max", "min" or "mean" combine
the weights as multigraph_to_weighted() does. Without
freq, every distinct time is its own snapshot.
Spans to snapshots#
A span is observed in every bin it overlaps:
>>> B = nb.snapshots_from_spans(P, freq=1)
>>> {t: sorted(G.edges) for t, G in B.items()}
{0: [('ann', 'bob')], 1: [('ann', 'bob'), ('bob', 'cat')], 2: [('bob', 'cat')], 3: [('bob', 'cat')]}
Without freq, a snapshot starts wherever a span starts or ends:
>>> {t: sorted(G.edges) for t, G in nb.snapshots_from_spans(P).items()}
{0: [('ann', 'bob')], 1: [('ann', 'bob'), ('bob', 'cat')], 2: [('bob', 'cat')]}
Events to spans#
spans_from_events() gives each event a duration
and merges the events of a pair that overlap, touch, or come within gap
of each other:
>>> M = nb.spans_from_events(E, duration=1)
>>> sorted((u, v, d["start"], d["end"]) for u, v, d in M.edges(data=True))
[('ann', 'bob', 0.5, 2.2), ('bob', 'cat', 3.7, 4.7)]
Spans to events#
events_from_spans() keeps each contact’s onset by
default. With freq, it writes one event per bin the span overlaps, so the
events bin to the same snapshots as the spans:
>>> sorted(t for *_, t in nb.events_from_spans(P).edges(data="time"))
[0, 1]
>>> sorted(t for *_, t in nb.events_from_spans(P, freq=1).edges(data="time"))
[0, 1, 1, 2, 3]
One function for all directions#
convert_temporal() detects the input’s form and calls
the matching converter with your keyword arguments:
>>> B = nb.convert_temporal(E, to="snapshots", freq=2, origin=0)
>>> list(B)
[0, 2]
Collapsing to a static weighted graph#
flatten_temporal() collapses any of the three forms
into one ordinary graph, with one edge per node pair, so every static
backbone method applies to it. Each edge’s weight combines the weights of
the pair’s observations (a missing weight counts as 1), n_obs counts the
observations, and first and last record when the pair was first and
last seen. Isolated nodes in the snapshots are kept:
>>> H = nb.flatten_temporal(S)
>>> sorted(H.edges(data=True))
[('ann', 'bob', {'weight': 2, 'n_obs': 2, 'first': 0, 'last': 1}), ('bob', 'cat', {'weight': 2, 'n_obs': 2, 'first': 1, 'last': 3})]
>>> sorted(H)
['ann', 'bob', 'cat', 'dan']
aggregate= chooses how the weights combine ("sum", "count",
"mean", "max" or "min"):
>>> sorted(nb.flatten_temporal(E).edges(data="weight"))
[('ann', 'bob', 4), ('bob', 'cat', 1)]
>>> sorted(nb.flatten_temporal(E, aggregate="count").edges(data="weight"))
[('ann', 'bob', 2), ('bob', 'cat', 1)]
For spans, aggregate="duration" weights each pair by how long it is in
contact (overlapping spans are not counted twice), and every span edge also
gets active_time. window=(t0, t1) keeps only the half-open interval
[t0, t1), clipping spans to it:
>>> sorted(nb.flatten_temporal(P, aggregate="duration").edges(data="weight"))
[('ann', 'bob', 2), ('bob', 'cat', 3)]
>>> sorted(nb.flatten_temporal(P, aggregate="duration", window=(1, 3)).edges(data="weight"))
[('ann', 'bob', 1), ('bob', 'cat', 2)]
To count the steps in which a pair interacts rather than its events, bin first and then flatten:
>>> sorted(nb.flatten_temporal(nb.snapshots_from_events(E, freq=1, origin=0)).edges(data="n_obs"))
[('ann', 'bob', 2), ('bob', 'cat', 1)]
The flattened graph keeps no order beyond first, last and
active_time; the temporal backbone scores
(observation_fraction(),
persistence(), …) score edges by how they behave
over time instead.
Datetimes and pandas#
Numeric times need only NetworkX, and so do datetime.datetime times
with a datetime.timedelta step. pandas is needed for frequency
strings such as freq="1D" or "MS" (calendar months), and for event
tables. from_event_table() reads a DataFrame with
one row per event (or per span, with end=), and
to_event_table() writes one back:
>>> import pandas as pd
>>> df = pd.DataFrame({
... "source": ["ann", "ann", "bob"],
... "target": ["bob", "bob", "cat"],
... "time": pd.to_datetime(["2024-01-01 09:00", "2024-01-01 17:00", "2024-01-03 08:00"]),
... })
>>> M = nb.from_event_table(df)
>>> daily = nb.snapshots_from_events(M, freq="1D", origin=pd.Timestamp("2024-01-01"))
>>> {str(t.date()): G.number_of_edges() for t, G in daily.items()}
{'2024-01-01': 1, '2024-01-02': 0, '2024-01-03': 1}
>>> nb.to_event_table(M).equals(df)
True
Bins start at origin, here midnight; by default they would start at the
earliest event, 9:00. Duplicate rows are kept as separate events.
What each conversion keeps#
Conversion |
Keeps |
Loses |
|---|---|---|
|
Everything: times (empty snapshots too), nodes (isolated too), and
node, edge and graph attributes, through |
Nothing (a list comes back as a dict; edge order may change) |
|
Everything, as above; runs split where an edge’s attributes change |
Nothing (as above) |
|
Which pairs interact in each bin, the combined |
Times within a bin, unshared attributes, nodes with no event in a bin |
|
Each merged span’s extent, combined |
Event times inside a merged span (none merge with |
|
Onsets (default), or the bins each span overlaps ( |
Durations (default) or times within a bin ( |
|
One edge per pair: combined |
The order and timing of observations, unshared attributes |
|
Endpoints, times, ends, weights and edge attributes |
Node and graph attributes, isolated nodes, the
|