From 0bd14ce3707c8ca6a3a33e5ff5e199207c84f695 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Wed, 8 Jul 2026 15:42:26 -0400 Subject: [PATCH 1/9] an EventGraph with tests --- src/pathpyG/core/event_graph.py | 122 ++++++++++++++ tests/core/test_event_graph.py | 280 ++++++++++++++++++++++++++++++++ tests/core/test_graph.py | 12 +- tests/core/test_index_map.py | 2 +- 4 files changed, 411 insertions(+), 5 deletions(-) create mode 100644 src/pathpyG/core/event_graph.py create mode 100644 tests/core/test_event_graph.py diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py new file mode 100644 index 00000000..97402aa9 --- /dev/null +++ b/src/pathpyG/core/event_graph.py @@ -0,0 +1,122 @@ +from __future__ import annotations +from typing import Tuple, Union +import numpy as np +import torch +from torch_geometric.data import Data +from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths +from pathpyG.core.graph import Graph +from pathpyG.core.index_map import IndexMap +from pathpyG.core.temporal_graph import TemporalGraph + + +class EventGraph(Graph): + def __init__( + self, + data: Data, + delta: Union[int, float], + fo_mapping: IndexMap | None = None, + num_fo_nodes: int | None = None, + mapping: IndexMap | None = None, + ) -> None: + + if "node_time" not in data: + raise ValueError("EventGraph requires a per-event `node_time` node attribute.") + + super().__init__(data, mapping=mapping) + + self.delta = delta + self.fo_mapping = fo_mapping if fo_mapping is not None else IndexMap() + if num_fo_nodes is not None: + self._num_fo_nodes = int(num_fo_nodes) + else: + self._num_fo_nodes = int(self.data.node_sequence.max().item()) + 1 + + ei = self.data.edge_index + self.data.edge_delta = self.data.node_time[ei[1]] - self.data.node_time[ei[0]] + + self._temporal_graph: TemporalGraph | None = None + + @classmethod + def from_temporal_graph(cls, g: TemporalGraph, delta: Union[int, float] = 1) -> "EventGraph": + ho_index = lift_order_temporal(g, delta) + m = g.data.time.size(0) # number of events (== number of first-order edges) + node_sequence = g.data.edge_index.as_tensor().t().contiguous() # [m, 2] + node_time = g.data.time.clone() # [m] + + data = Data( + edge_index=ho_index, + num_nodes=m, + node_sequence=node_sequence, + node_time=node_time, + ) + eg = cls(data, delta=delta, fo_mapping=g.mapping, num_fo_nodes=g.n) + + # Attach a clone of the temporal graph since we already have it + eg._temporal_graph = TemporalGraph(g.data.clone(), mapping=g.mapping) + + return eg + + def __str__(self) -> str: + events_str = "" + for i in range(self.n): + u_id, v_id, t = self.event_endpoints(i) + events_str += f"\n{u_id}->{v_id}@{t}" + return ( + f"EventGraph (delta={self.delta})" + f"{events_str}" + ) + + def __len__(self): + return self.n + + def __getitem__(self, key): + if isinstance(key, (int, np.integer)) and not isinstance(key, bool): + return self.event_endpoints(int(key)) + return super().__getitem__(key) + + def to(self, device: torch.device) -> "EventGraph": + super().to(device) + if self._temporal_graph is not None: + self._temporal_graph.to(device) + return self + + def to_temporal_graph(self) -> TemporalGraph: + if self._temporal_graph is None: + edge_index = self.data.node_sequence.t().contiguous() # [2, num_events] + self._temporal_graph = TemporalGraph( + Data( + edge_index=edge_index, + time=self.data.node_time.clone(), + num_nodes=self.num_fo_nodes, + ), + mapping=self.fo_mapping, + ) + return self._temporal_graph + + @property + def num_fo_nodes(self) -> int: + return self._num_fo_nodes + + @property + def num_events(self) -> int: + return self.n + + def event_time(self, i: int) -> Union[int, float]: + return self.data.node_time[i].item() + + def event_endpoints(self, i: int) -> Tuple: + u, v = self.data.node_sequence[i].tolist() + return self.fo_mapping.to_id(u), self.fo_mapping.to_id(v), self.data.node_time[i].item() + + def continuations(self, i: int) -> list: + out = [] + for nxt in self.get_successors(i): + nxt = int(nxt.item()) + out.append((nxt, self.data.edge_delta[self.edge_to_index[(i, nxt)]].item())) + return out + + def shortest_paths(self) -> Tuple[np.ndarray, np.ndarray]: + # TODO: This is wasteful, since we already have the lifted edge index + # Modify `temporal_shortest_paths` to take in an optional pre-computed + # edge_index? + return temporal_shortest_paths(self.to_temporal_graph(), self.delta) \ No newline at end of file diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py new file mode 100644 index 00000000..600f143b --- /dev/null +++ b/tests/core/test_event_graph.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import numpy as np +import pytest +import torch +from scipy.sparse.csgraph import dijkstra +from torch_geometric.data import Data +from torch_geometric.utils import to_scipy_sparse_matrix + +from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths +from pathpyG.core.index_map import IndexMap +from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.core.multi_order_model import MultiOrderModel +from pathpyG.core.event_graph import EventGraph + + +DELTA = 2 + + +# Example temporal graph used in the tests: +# +# a +# | t=1 +# v +# b -------- t=5 --------> d +# | t=2 +# v +# c +# | t=3 +# v +# e +# +# Event graph corresponding to the above (with DELTA = 2): +# +# 0 --gap 1--> 1 --gap 1--> 2 +# 3 (isolated event) +# +# where +# +# event 0: (a->b)@1 +# event 1: (b->c)@2 +# event 2: (c->e)@3 +# event 3: (b->d)@5 + +@pytest.fixture +def temporal_graph() -> TemporalGraph: + return TemporalGraph.from_edge_list( + [ + ("a", "b", 1), + ("b", "c", 2), + ("b", "d", 5), + ("c", "e", 3), + ] + ) + + +@pytest.fixture +def existing(temporal_graph): + # Properties of `temporal_graph` computed using the existing API. + ho = lift_order_temporal(temporal_graph, DELTA) # (2, 2) + m = temporal_graph.data.time.numel() # 4 - number of events + n = temporal_graph.n # 5 - number of FO nodes + node_time = temporal_graph.data.time + node_sequence = temporal_graph.data.edge_index.as_tensor().t() # (m, 2) + + edge_delta = node_time[ho[1]] - node_time[ho[0]] + adj = to_scipy_sparse_matrix(ho, edge_attr=edge_delta, num_nodes=m) + fastest = dijkstra(adj, directed=True) # (m, m) + + dist_fo, pred_fo = temporal_shortest_paths(temporal_graph, DELTA) # (n, n) + + return { + "ho": ho, + "m": m, + "n": n, + "node_time": node_time, + "node_sequence": node_sequence, + "edge_delta": edge_delta, + "fastest": fastest, + "dist_fo": dist_fo, + "pred_fo": pred_fo, + } + + +@pytest.fixture +def event_graph(temporal_graph) -> EventGraph: + return EventGraph.from_temporal_graph(temporal_graph, delta=DELTA) + + +def test_basic(event_graph, existing): + assert event_graph.delta == DELTA + assert len(event_graph) == existing["m"] == 4 + assert event_graph.num_events == existing["m"] == 4 + assert event_graph.n == existing["m"] == 4 + assert event_graph.num_fo_nodes == existing["n"] == 5 + + +def test_str(event_graph): + assert ( + str(event_graph) + == "EventGraph (delta=2)\na->b@1\nb->c@2\nc->e@3\nb->d@5" + ) + + +def test_node_time(event_graph, existing): + assert torch.equal(event_graph.data.node_time, existing["node_time"]) + assert event_graph.data.node_time.tolist() == [1, 2, 3, 5] + + +def test_node_sequence(event_graph, existing): + assert torch.equal(event_graph.data.node_sequence, existing["node_sequence"]) + assert event_graph.data.node_sequence.tolist() == [[0, 1], [1, 2], [2, 4], [1, 3]] + + +def test_fo_mapping(event_graph, temporal_graph): + fo = event_graph.fo_mapping + assert fo.num_ids() == 5 + for node in "abcde": + assert fo.to_id(fo.to_idx(node)) == node + assert fo.to_idx(node) == temporal_graph.mapping.to_idx(node) + + +def test_continuation_edge_index_matches_existing(event_graph, existing): + got = event_graph.data.edge_index.as_tensor() + got_set = {tuple(c) for c in got.t().tolist()} + assert got_set == {tuple(c) for c in existing["ho"].t().tolist()} + assert got_set == {(0, 1), (1, 2)} + + +def test_event_time(event_graph, existing): + for i in range(len(event_graph)): + assert event_graph.event_time(i) == existing["node_time"][i].item() + + +def test_getitem(event_graph): + # (u, v, t) for each event + assert event_graph[0] == ("a", "b", 1) + assert event_graph[1] == ("b", "c", 2) + assert event_graph[2] == ("c", "e", 3) + assert event_graph[3] == ("b", "d", 5) + + +def test_isolated_events(event_graph): + isolated = [ + i + for i in range(event_graph.num_events) + if event_graph.get_successors(i).numel() == 0 and event_graph.get_predecessors(i).numel() == 0 + ] + assert isolated == [3] + + +def test_continuations_and_gaps(event_graph): + cont = {i: event_graph.continuations(i) for i in range(event_graph.num_events)} + assert cont[0] == [(1, 1)] # (a->b)@1 -> (b->c)@2, gap 1 + assert cont[1] == [(2, 1)] # (b->c)@2 -> (c->e)@3, gap 1 + assert cont[2] == [] + assert cont[3] == [] + + +def test_continuation_deltas(event_graph): + for i in range(event_graph.num_events): + for _nxt, gap in event_graph.continuations(i): + assert 0 < gap <= event_graph.delta + + +def test_edge_delta_matches_existing(event_graph, existing): + got = { + tuple(c): d + for c, d in zip( + event_graph.data.edge_index.as_tensor().t().tolist(), + event_graph.data.edge_delta.tolist(), + ) + } + expected = { + tuple(c): d + for c, d in zip(existing["ho"].t().tolist(), existing["edge_delta"].tolist()) + } + assert got == expected + assert got == {(0, 1): 1, (1, 2): 1} + + +def test_shortest_paths_distances(event_graph, existing): + dist, _pred = event_graph.shortest_paths() + np.testing.assert_array_equal(dist, existing["dist_fo"]) + + +def test_shortest_paths_predecessors(event_graph, existing): + _dist, pred = event_graph.shortest_paths() + np.testing.assert_array_equal(pred, existing["pred_fo"]) + + +def test_shortest_paths_a_to_d_is_unreachable(event_graph): + """a -> d needs a->b@1 then b->d@5, gap of 4 > delta""" + dist, _pred = event_graph.shortest_paths() + assert dist[0, 3] == np.inf + + +def test_fastest_path_distances(event_graph, existing): + fastest = dijkstra(event_graph.sparse_adj_matrix(edge_attr="edge_delta"), directed=True) + np.testing.assert_array_equal(fastest, existing["fastest"]) + + +def test_to_temporal_graph_round_trip(event_graph, temporal_graph): + # An EventGraph can be converted to a TemporalGraph and back again. + rebuilt = event_graph.to_temporal_graph() + assert isinstance(rebuilt, TemporalGraph) + assert torch.equal( + rebuilt.data.edge_index.as_tensor(), + temporal_graph.data.edge_index.as_tensor(), + ) + assert torch.equal(rebuilt.data.time, temporal_graph.data.time) + assert rebuilt.n == temporal_graph.n + for node in ("a", "b", "c", "d", "e"): + assert rebuilt.mapping.to_idx(node) == temporal_graph.mapping.to_idx(node) + + +def test_multi_order_model_construction(event_graph, temporal_graph): + # A MultiOrderModel can be constructed from an EventGraph or a TemporalGraph. + with pytest.raises(AttributeError): + # no such attribute yet + mom_eg = MultiOrderModel.from_event_graph(event_graph, max_order=2) + mom_tg = MultiOrderModel.from_temporal_graph(temporal_graph, delta=DELTA, max_order=2) + + for k in (1, 2): + assert torch.equal( + mom_eg.layers[k].data.edge_index.as_tensor(), + mom_tg.layers[k].data.edge_index.as_tensor(), + ) + assert torch.equal( + mom_eg.layers[k].data.edge_weight, + mom_tg.layers[k].data.edge_weight, + ) + + +def test_to_device(event_graph): + # Moving an EventGraph to a different device moves the underlying TemporalGraph too. + moved = event_graph.to(torch.device("cpu")) + assert isinstance(moved, EventGraph) + assert moved is event_graph + assert moved.to_temporal_graph().data.edge_index.device.type == "cpu" + + +""" +The following tests illustrate that an EventGraph can be constructed from a raw +`torch_geometric.data.Data` object. +""" +@pytest.fixture +def event_data() -> Data: + return Data( + edge_index=torch.tensor([[0, 1], [1, 2]]), + num_nodes=4, + node_sequence=torch.tensor([[0, 1], [1, 2], [2, 4], [1, 3]]), + node_time=torch.tensor([1, 2, 3, 5]), + ) + + +def test_construct_from_data(event_data): + eg = EventGraph(event_data, delta=DELTA) + got = { + tuple(c): d + for c, d in zip( + eg.data.edge_index.as_tensor().t().tolist(), eg.data.edge_delta.tolist() + ) + } + assert got == {(0, 1): 1, (1, 2): 1} + + +def test_construct_from_data_to_temporal_graph(event_data): + # An EventGraph constructed from raw `torch_geometric.data.Data` can still give us + # a `TemporalGraph` using `.to_temporal_graph()` + fo = IndexMap(["a", "b", "c", "d", "e"]) + eg = EventGraph(event_data, delta=DELTA, fo_mapping=fo, num_fo_nodes=5) + tg = eg.to_temporal_graph() + assert isinstance(tg, TemporalGraph) + assert tg.n == 5 + assert torch.equal( + tg.data.edge_index.as_tensor(), + torch.tensor([[0, 1, 2, 1], [1, 2, 4, 3]]), + ) + assert tg.data.time.tolist() == [1, 2, 3, 5] \ No newline at end of file diff --git a/tests/core/test_graph.py b/tests/core/test_graph.py index f27071bb..2ae1c060 100644 --- a/tests/core/test_graph.py +++ b/tests/core/test_graph.py @@ -77,6 +77,7 @@ def test_from_edge_list(): ("b", "c"), ] g = Graph.from_edge_list(edge_list) + # Since node labels are strings, they are sorted lexicographically. assert g.mapping.to_idx("a") == 0 assert g.mapping.to_idx("b") == 1 assert g.mapping.to_idx("c") == 2 @@ -86,12 +87,15 @@ def test_from_edge_list(): (2, 1), ] g = Graph.from_edge_list(edge_list) + # Since node labels are ints, they are sorted numerically. assert g.mapping.to_idx(1) == 0 assert g.mapping.to_idx(2) == 1 assert g.mapping.to_idx(12) == 2 edge_list = [("1", "12"), ("2", "1"), ("21", "3")] g = Graph.from_edge_list(edge_list) + # Node labels are strings, but since ALL of them are numeric, they are sorted + # numerically, not lexicographically. assert g.mapping.to_idx("1") == 0 assert g.mapping.to_idx("2") == 1 assert g.mapping.to_idx("3") == 2 @@ -280,8 +284,8 @@ def test_add_operator_wo_indices(): g1 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), num_nodes=4) g2 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), num_nodes=4) g = g1 + g2 - assert g.n == g1.n - assert g.m == g1.m + g2.m + assert g.n == g1.n # No relabeling - number of nodes stays the same + assert g.m == g1.m + g2.m # No deduplication - number of edges goes up assert torch.equal(g.data.edge_index, torch.tensor([[0, 0, 1, 1, 1, 1], [1, 1, 2, 3, 2, 3]])) g3 = Graph.from_edge_index(torch.IntTensor([[0, 2, 3], [2, 3, 4]]), num_nodes=5) @@ -296,8 +300,8 @@ def test_add_operator_complete_overlap(): g1 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), mapping=IndexMap(["a", "b", "c", "d"])) g2 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), mapping=IndexMap(["a", "b", "c", "d"])) g = g1 + g2 - assert g.n == g1.n - assert g.m == g1.m + g2.m + assert g.n == g1.n # No relabeling - number of nodes stays the same + assert g.m == g1.m + g2.m # No deduplication - number of edges goes up assert torch.equal(g.data.edge_index, torch.tensor([[0, 0, 1, 1, 1, 1], [1, 1, 2, 3, 2, 3]])) diff --git a/tests/core/test_index_map.py b/tests/core/test_index_map.py index 6cb91e1c..c7130730 100644 --- a/tests/core/test_index_map.py +++ b/tests/core/test_index_map.py @@ -122,7 +122,7 @@ def test_bulk_ids(): node_ids = np.array(["a", "c", "b", "d", "a", "e"]) with pytest.raises(ValueError): mapping = IndexMap(node_ids) - mapping = IndexMap(np.unique(node_ids)) + mapping = IndexMap(np.unique(node_ids)) # sorts assert mapping.to_idx("a") == 0 assert mapping.to_idx("c") == 2 From 10f273adf0499833e9cfd8c690d0ebbe5114ede8 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Thu, 9 Jul 2026 11:42:20 -0400 Subject: [PATCH 2/9] ruff fixes --- src/pathpyG/core/event_graph.py | 20 +++++++++++++++++++- tests/core/test_event_graph.py | 33 +++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py index 97402aa9..69909308 100644 --- a/src/pathpyG/core/event_graph.py +++ b/src/pathpyG/core/event_graph.py @@ -1,8 +1,12 @@ +"""Event graph representation of a temporal graph and related operations.""" from __future__ import annotations + from typing import Tuple, Union + import numpy as np import torch from torch_geometric.data import Data + from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths from pathpyG.core.graph import Graph from pathpyG.core.index_map import IndexMap @@ -10,6 +14,8 @@ class EventGraph(Graph): + """A directed acyclic graph whose nodes are time-stamped events.""" + def __init__( self, data: Data, @@ -18,7 +24,7 @@ def __init__( num_fo_nodes: int | None = None, mapping: IndexMap | None = None, ) -> None: - + """Create an EventGraph from a `Data` object carrying per-event `node_time`.""" if "node_time" not in data: raise ValueError("EventGraph requires a per-event `node_time` node attribute.") @@ -38,6 +44,7 @@ def __init__( @classmethod def from_temporal_graph(cls, g: TemporalGraph, delta: Union[int, float] = 1) -> "EventGraph": + """Build an EventGraph from a temporal graph by lifting its edges into events.""" ho_index = lift_order_temporal(g, delta) m = g.data.time.size(0) # number of events (== number of first-order edges) node_sequence = g.data.edge_index.as_tensor().t().contiguous() # [m, 2] @@ -57,6 +64,7 @@ def from_temporal_graph(cls, g: TemporalGraph, delta: Union[int, float] = 1) -> return eg def __str__(self) -> str: + """Return a human-readable summary listing the delta and all events.""" events_str = "" for i in range(self.n): u_id, v_id, t = self.event_endpoints(i) @@ -67,20 +75,24 @@ def __str__(self) -> str: ) def __len__(self): + """Return the number of events in the graph.""" return self.n def __getitem__(self, key): + """Return the (u, v, t) endpoints for an integer key, else delegate to `Graph`.""" if isinstance(key, (int, np.integer)) and not isinstance(key, bool): return self.event_endpoints(int(key)) return super().__getitem__(key) def to(self, device: torch.device) -> "EventGraph": + """Move the event graph and its underlying temporal graph to the given device.""" super().to(device) if self._temporal_graph is not None: self._temporal_graph.to(device) return self def to_temporal_graph(self) -> TemporalGraph: + """Return the underlying temporal graph, reconstructing it if necessary.""" if self._temporal_graph is None: edge_index = self.data.node_sequence.t().contiguous() # [2, num_events] self._temporal_graph = TemporalGraph( @@ -95,20 +107,25 @@ def to_temporal_graph(self) -> TemporalGraph: @property def num_fo_nodes(self) -> int: + """Number of distinct first-order nodes underlying the events.""" return self._num_fo_nodes @property def num_events(self) -> int: + """Number of events (nodes) in the event graph.""" return self.n def event_time(self, i: int) -> Union[int, float]: + """Return the timestamp of the i-th event.""" return self.data.node_time[i].item() def event_endpoints(self, i: int) -> Tuple: + """Return the (source id, target id, time) of the i-th event.""" u, v = self.data.node_sequence[i].tolist() return self.fo_mapping.to_id(u), self.fo_mapping.to_id(v), self.data.node_time[i].item() def continuations(self, i: int) -> list: + """Return each successor event of i paired with its time gap.""" out = [] for nxt in self.get_successors(i): nxt = int(nxt.item()) @@ -116,6 +133,7 @@ def continuations(self, i: int) -> list: return out def shortest_paths(self) -> Tuple[np.ndarray, np.ndarray]: + """Return first-order shortest-path distances and predecessors respecting delta.""" # TODO: This is wasteful, since we already have the lifted edge index # Modify `temporal_shortest_paths` to take in an optional pre-computed # edge_index? diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py index 600f143b..767cd915 100644 --- a/tests/core/test_event_graph.py +++ b/tests/core/test_event_graph.py @@ -8,11 +8,10 @@ from torch_geometric.utils import to_scipy_sparse_matrix from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths +from pathpyG.core.event_graph import EventGraph from pathpyG.core.index_map import IndexMap -from pathpyG.core.temporal_graph import TemporalGraph from pathpyG.core.multi_order_model import MultiOrderModel -from pathpyG.core.event_graph import EventGraph - +from pathpyG.core.temporal_graph import TemporalGraph DELTA = 2 @@ -88,6 +87,7 @@ def event_graph(temporal_graph) -> EventGraph: def test_basic(event_graph, existing): + """Basic counts (delta, events, nodes) match the source temporal graph.""" assert event_graph.delta == DELTA assert len(event_graph) == existing["m"] == 4 assert event_graph.num_events == existing["m"] == 4 @@ -96,6 +96,7 @@ def test_basic(event_graph, existing): def test_str(event_graph): + """The string representation lists the delta and all events.""" assert ( str(event_graph) == "EventGraph (delta=2)\na->b@1\nb->c@2\nc->e@3\nb->d@5" @@ -103,16 +104,19 @@ def test_str(event_graph): def test_node_time(event_graph, existing): + """Each event node carries the timestamp of its underlying edge.""" assert torch.equal(event_graph.data.node_time, existing["node_time"]) assert event_graph.data.node_time.tolist() == [1, 2, 3, 5] def test_node_sequence(event_graph, existing): + """Each event node stores the (source, target) first-order node pair.""" assert torch.equal(event_graph.data.node_sequence, existing["node_sequence"]) assert event_graph.data.node_sequence.tolist() == [[0, 1], [1, 2], [2, 4], [1, 3]] def test_fo_mapping(event_graph, temporal_graph): + """The first-order node mapping round-trips and matches the temporal graph.""" fo = event_graph.fo_mapping assert fo.num_ids() == 5 for node in "abcde": @@ -121,6 +125,7 @@ def test_fo_mapping(event_graph, temporal_graph): def test_continuation_edge_index_matches_existing(event_graph, existing): + """The continuation edge index matches the existing lift-order result.""" got = event_graph.data.edge_index.as_tensor() got_set = {tuple(c) for c in got.t().tolist()} assert got_set == {tuple(c) for c in existing["ho"].t().tolist()} @@ -128,12 +133,13 @@ def test_continuation_edge_index_matches_existing(event_graph, existing): def test_event_time(event_graph, existing): + """event_time(i) returns the timestamp of the i-th event.""" for i in range(len(event_graph)): assert event_graph.event_time(i) == existing["node_time"][i].item() def test_getitem(event_graph): - # (u, v, t) for each event + """Indexing an EventGraph yields the (u, v, t) tuple for each event.""" assert event_graph[0] == ("a", "b", 1) assert event_graph[1] == ("b", "c", 2) assert event_graph[2] == ("c", "e", 3) @@ -141,6 +147,7 @@ def test_getitem(event_graph): def test_isolated_events(event_graph): + """Events with no predecessors or successors are correctly identified.""" isolated = [ i for i in range(event_graph.num_events) @@ -150,6 +157,7 @@ def test_isolated_events(event_graph): def test_continuations_and_gaps(event_graph): + """continuations(i) returns each successor event with its time gap.""" cont = {i: event_graph.continuations(i) for i in range(event_graph.num_events)} assert cont[0] == [(1, 1)] # (a->b)@1 -> (b->c)@2, gap 1 assert cont[1] == [(2, 1)] # (b->c)@2 -> (c->e)@3, gap 1 @@ -158,12 +166,14 @@ def test_continuations_and_gaps(event_graph): def test_continuation_deltas(event_graph): + """Every continuation gap lies within (0, delta].""" for i in range(event_graph.num_events): for _nxt, gap in event_graph.continuations(i): assert 0 < gap <= event_graph.delta def test_edge_delta_matches_existing(event_graph, existing): + """Per-edge time deltas match the existing lift-order result.""" got = { tuple(c): d for c, d in zip( @@ -180,28 +190,31 @@ def test_edge_delta_matches_existing(event_graph, existing): def test_shortest_paths_distances(event_graph, existing): + """shortest_paths() distances match the existing first-order result.""" dist, _pred = event_graph.shortest_paths() np.testing.assert_array_equal(dist, existing["dist_fo"]) def test_shortest_paths_predecessors(event_graph, existing): + """shortest_paths() predecessors match the existing first-order result.""" _dist, pred = event_graph.shortest_paths() np.testing.assert_array_equal(pred, existing["pred_fo"]) def test_shortest_paths_a_to_d_is_unreachable(event_graph): - """a -> d needs a->b@1 then b->d@5, gap of 4 > delta""" + """Transition a->d needs a->b@1 then b->d@5, gap of 4 > delta.""" dist, _pred = event_graph.shortest_paths() assert dist[0, 3] == np.inf def test_fastest_path_distances(event_graph, existing): + """Fastest-path distances over edge deltas match the existing result.""" fastest = dijkstra(event_graph.sparse_adj_matrix(edge_attr="edge_delta"), directed=True) np.testing.assert_array_equal(fastest, existing["fastest"]) def test_to_temporal_graph_round_trip(event_graph, temporal_graph): - # An EventGraph can be converted to a TemporalGraph and back again. + """An EventGraph converts back to an equivalent TemporalGraph.""" rebuilt = event_graph.to_temporal_graph() assert isinstance(rebuilt, TemporalGraph) assert torch.equal( @@ -215,7 +228,7 @@ def test_to_temporal_graph_round_trip(event_graph, temporal_graph): def test_multi_order_model_construction(event_graph, temporal_graph): - # A MultiOrderModel can be constructed from an EventGraph or a TemporalGraph. + """A MultiOrderModel built from an EventGraph matches one from a TemporalGraph.""" with pytest.raises(AttributeError): # no such attribute yet mom_eg = MultiOrderModel.from_event_graph(event_graph, max_order=2) @@ -233,7 +246,7 @@ def test_multi_order_model_construction(event_graph, temporal_graph): def test_to_device(event_graph): - # Moving an EventGraph to a different device moves the underlying TemporalGraph too. + """Moving an EventGraph moves its underlying TemporalGraph too.""" moved = event_graph.to(torch.device("cpu")) assert isinstance(moved, EventGraph) assert moved is event_graph @@ -255,6 +268,7 @@ def event_data() -> Data: def test_construct_from_data(event_data): + """An EventGraph can be built from a raw Data object with correct edge deltas.""" eg = EventGraph(event_data, delta=DELTA) got = { tuple(c): d @@ -266,8 +280,7 @@ def test_construct_from_data(event_data): def test_construct_from_data_to_temporal_graph(event_data): - # An EventGraph constructed from raw `torch_geometric.data.Data` can still give us - # a `TemporalGraph` using `.to_temporal_graph()` + """An EventGraph built from raw Data still converts to a TemporalGraph.""" fo = IndexMap(["a", "b", "c", "d", "e"]) eg = EventGraph(event_data, delta=DELTA, fo_mapping=fo, num_fo_nodes=5) tg = eg.to_temporal_graph() From 65623c6ab4cb1adbcdb0530c985008edd16ea76a Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Thu, 9 Jul 2026 11:50:12 -0400 Subject: [PATCH 3/9] mypy fixes --- src/pathpyG/core/event_graph.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py index 69909308..14525b4f 100644 --- a/src/pathpyG/core/event_graph.py +++ b/src/pathpyG/core/event_graph.py @@ -1,7 +1,7 @@ """Event graph representation of a temporal graph and related operations.""" from __future__ import annotations -from typing import Tuple, Union +from typing import Tuple import numpy as np import torch @@ -19,7 +19,7 @@ class EventGraph(Graph): def __init__( self, data: Data, - delta: Union[int, float], + delta: int, fo_mapping: IndexMap | None = None, num_fo_nodes: int | None = None, mapping: IndexMap | None = None, @@ -43,7 +43,7 @@ def __init__( self._temporal_graph: TemporalGraph | None = None @classmethod - def from_temporal_graph(cls, g: TemporalGraph, delta: Union[int, float] = 1) -> "EventGraph": + def from_temporal_graph(cls, g: TemporalGraph, delta: int = 1) -> "EventGraph": """Build an EventGraph from a temporal graph by lifting its edges into events.""" ho_index = lift_order_temporal(g, delta) m = g.data.time.size(0) # number of events (== number of first-order edges) @@ -115,7 +115,7 @@ def num_events(self) -> int: """Number of events (nodes) in the event graph.""" return self.n - def event_time(self, i: int) -> Union[int, float]: + def event_time(self, i: int) -> int: """Return the timestamp of the i-th event.""" return self.data.node_time[i].item() From 12ec6e4cff6fac146e420f3a1d751605c2a61941 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Thu, 9 Jul 2026 13:09:58 -0400 Subject: [PATCH 4/9] lift_order_temporal moved as a staticmethod inside EventGraph (better namespace); continuations method removed from EventGraph --- src/pathpyG/algorithms/__init__.py | 5 +- src/pathpyG/algorithms/centrality.py | 5 +- src/pathpyG/algorithms/temporal.py | 44 +-------- src/pathpyG/core/event_graph.py | 57 ++++++++++-- src/pathpyG/core/multi_order_model.py | 4 +- tests/algorithms/test_temporal.py | 14 +-- tests/core/test_event_graph.py | 128 +++++++++++--------------- 7 files changed, 111 insertions(+), 146 deletions(-) diff --git a/src/pathpyG/algorithms/__init__.py b/src/pathpyG/algorithms/__init__.py index d2deda91..0cf041a0 100644 --- a/src/pathpyG/algorithms/__init__.py +++ b/src/pathpyG/algorithms/__init__.py @@ -21,7 +21,7 @@ ]) # Extract DAG capturing causal interaction sequences in temporal graph. - e_i = pp.algorithms.lift_order_temporal(g, delta=1) + e_i = EventGraph.build_edge_index(g, delta=1) dag = pp.Graph.from_edge_index(e_i) print(dag) @@ -33,11 +33,10 @@ from pathpyG.algorithms import centrality, generative_models, shortest_paths from pathpyG.algorithms.components import connected_components, largest_connected_component from pathpyG.algorithms.rolling_time_window import RollingTimeWindow -from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths +from pathpyG.algorithms.temporal import temporal_shortest_paths from pathpyG.algorithms.weisfeiler_leman import WeisfeilerLeman_test __all__ = [ - "lift_order_temporal", "temporal_shortest_paths", "centrality", "generative_models", diff --git a/src/pathpyG/algorithms/centrality.py b/src/pathpyG/algorithms/centrality.py index c65a4a7e..394afdcd 100644 --- a/src/pathpyG/algorithms/centrality.py +++ b/src/pathpyG/algorithms/centrality.py @@ -42,7 +42,8 @@ from torch_geometric.utils import to_networkx from tqdm import tqdm -from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths +from pathpyG.algorithms.temporal import temporal_shortest_paths +from pathpyG.core.event_graph import EventGraph from pathpyG.core.graph import Graph from pathpyG.core.path_data import PathData from pathpyG.core.temporal_graph import TemporalGraph @@ -193,7 +194,7 @@ def temporal_betweenness_centrality(graph: TemporalGraph, delta: int = 1) -> dic ``` """ # generate temporal event DAG - edge_index = lift_order_temporal(graph, delta) + edge_index = EventGraph.build_edge_index(graph, delta) # Add indices of first-order nodes as src of paths in augmented # temporal event DAG diff --git a/src/pathpyG/algorithms/temporal.py b/src/pathpyG/algorithms/temporal.py index 108268be..c46b6583 100644 --- a/src/pathpyG/algorithms/temporal.py +++ b/src/pathpyG/algorithms/temporal.py @@ -7,53 +7,13 @@ import numpy as np import torch from scipy.sparse.csgraph import dijkstra -from tqdm import tqdm from pathpyG import Graph +from pathpyG.core.event_graph import EventGraph from pathpyG.core.temporal_graph import TemporalGraph from pathpyG.utils import to_numpy -def lift_order_temporal(g: TemporalGraph, delta: float | int = 1): - """Lift a temporal graph to a second-order temporal event graph. - - Args: - g: Temporal graph to lift. - delta: Maximum time difference between events to consider them connected. - - Returns: - ho_index: Edge index of the second-order temporal event graph. - """ - # first-order edge index - edge_index, timestamps = g.data.edge_index, g.data.time - - delta = torch.tensor(delta, device=edge_index.device) # type: ignore[assignment] - indices = torch.arange(0, edge_index.size(1), device=edge_index.device) - - unique_t = torch.unique(timestamps, sorted=True) - second_order = [] - - # lift order: find possible continuations for edges in each time stamp - for t in tqdm(unique_t): - # find indices of all source edges that occur at unique timestamp t - src_time_mask = timestamps == t - src_edge_idx = indices[src_time_mask] - - # find indices of all edges that can possibly continue edges occurring at time t for the given delta - dst_time_mask = (timestamps > t) & (timestamps <= t + delta) - dst_edge_idx = indices[dst_time_mask] - - if dst_edge_idx.size(0) > 0 and src_edge_idx.size(0) > 0: - # compute second-order edges between src and dst idx - # for all edges where dst in src_edges (edge_index[1, x[:, 0]]) matches src in dst_edges (edge_index[0, x[:, 1]]) - x = torch.cartesian_prod(src_edge_idx, dst_edge_idx) - ho_edge_index = x[edge_index[1, x[:, 0]] == edge_index[0, x[:, 1]]] - second_order.append(ho_edge_index) - - ho_index = torch.cat(second_order, dim=0).t().contiguous() - return ho_index - - def temporal_shortest_paths(g: TemporalGraph, delta: int) -> Tuple[np.ndarray, np.ndarray]: """Compute shortest time-respecting paths in a temporal graph. @@ -67,7 +27,7 @@ def temporal_shortest_paths(g: TemporalGraph, delta: int) -> Tuple[np.ndarray, n - pred: Predecessor matrix for shortest time-respecting paths between all first-order nodes. """ # generate temporal event DAG - edge_index = lift_order_temporal(g, delta) + edge_index = EventGraph.build_edge_index(g, delta) # Add indices of first-order nodes as src and dst of paths in augmented # temporal event DAG diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py index 14525b4f..9491de0b 100644 --- a/src/pathpyG/core/event_graph.py +++ b/src/pathpyG/core/event_graph.py @@ -6,8 +6,8 @@ import numpy as np import torch from torch_geometric.data import Data +from tqdm import tqdm -from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths from pathpyG.core.graph import Graph from pathpyG.core.index_map import IndexMap from pathpyG.core.temporal_graph import TemporalGraph @@ -42,10 +42,53 @@ def __init__( self._temporal_graph: TemporalGraph | None = None + @staticmethod + def build_edge_index(g: TemporalGraph, delta: float | int = 1): + """Build the event-graph edge index by lifting a temporal graph to second order. + + Each temporal edge of `g` becomes an event (node); two events are connected + when the second can continue the first within the time window `delta`. + + Args: + g: Temporal graph to lift. + delta: Maximum time difference between events to consider them connected. + + Returns: + ho_index: Edge index of the second-order temporal event graph. + """ + # first-order edge index + edge_index, timestamps = g.data.edge_index, g.data.time + + delta = torch.tensor(delta, device=edge_index.device) # type: ignore[assignment] + indices = torch.arange(0, edge_index.size(1), device=edge_index.device) + + unique_t = torch.unique(timestamps, sorted=True) + second_order = [] + + # lift order: find possible continuations for edges in each time stamp + for t in tqdm(unique_t): + # find indices of all source edges that occur at unique timestamp t + src_time_mask = timestamps == t + src_edge_idx = indices[src_time_mask] + + # find indices of all edges that can possibly continue edges occurring at time t for the given delta + dst_time_mask = (timestamps > t) & (timestamps <= t + delta) + dst_edge_idx = indices[dst_time_mask] + + if dst_edge_idx.size(0) > 0 and src_edge_idx.size(0) > 0: + # compute second-order edges between src and dst idx + # for all edges where dst in src_edges (edge_index[1, x[:, 0]]) matches src in dst_edges (edge_index[0, x[:, 1]]) + x = torch.cartesian_prod(src_edge_idx, dst_edge_idx) + ho_edge_index = x[edge_index[1, x[:, 0]] == edge_index[0, x[:, 1]]] + second_order.append(ho_edge_index) + + ho_index = torch.cat(second_order, dim=0).t().contiguous() + return ho_index + @classmethod def from_temporal_graph(cls, g: TemporalGraph, delta: int = 1) -> "EventGraph": """Build an EventGraph from a temporal graph by lifting its edges into events.""" - ho_index = lift_order_temporal(g, delta) + ho_index = cls.build_edge_index(g, delta) m = g.data.time.size(0) # number of events (== number of first-order edges) node_sequence = g.data.edge_index.as_tensor().t().contiguous() # [m, 2] node_time = g.data.time.clone() # [m] @@ -124,17 +167,11 @@ def event_endpoints(self, i: int) -> Tuple: u, v = self.data.node_sequence[i].tolist() return self.fo_mapping.to_id(u), self.fo_mapping.to_id(v), self.data.node_time[i].item() - def continuations(self, i: int) -> list: - """Return each successor event of i paired with its time gap.""" - out = [] - for nxt in self.get_successors(i): - nxt = int(nxt.item()) - out.append((nxt, self.data.edge_delta[self.edge_to_index[(i, nxt)]].item())) - return out - def shortest_paths(self) -> Tuple[np.ndarray, np.ndarray]: """Return first-order shortest-path distances and predecessors respecting delta.""" # TODO: This is wasteful, since we already have the lifted edge index # Modify `temporal_shortest_paths` to take in an optional pre-computed # edge_index? + from pathpyG.algorithms.temporal import temporal_shortest_paths + return temporal_shortest_paths(self.to_temporal_graph(), self.delta) \ No newline at end of file diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py index 38647c2a..b6b1e459 100644 --- a/src/pathpyG/core/multi_order_model.py +++ b/src/pathpyG/core/multi_order_model.py @@ -16,7 +16,7 @@ lift_order_edge_index, lift_order_edge_index_weighted, ) -from pathpyG.algorithms.temporal import lift_order_temporal +from pathpyG.core.event_graph import EventGraph from pathpyG.core.graph import Graph from pathpyG.core.index_map import IndexMap from pathpyG.core.path_data import PathData @@ -164,7 +164,7 @@ def from_temporal_graph( if max_order > 1: node_sequence = torch.cat([node_sequence[edge_index[0]], node_sequence[edge_index[1]][:, -1:]], dim=1) if event_graph is None: - edge_index = lift_order_temporal(g, delta) + edge_index = EventGraph.build_edge_index(g, delta) else: edge_index = event_graph edge_weight = aggregate_node_attributes(edge_index, edge_weight, "src") diff --git a/tests/algorithms/test_temporal.py b/tests/algorithms/test_temporal.py index c09c00dd..8bc26af3 100644 --- a/tests/algorithms/test_temporal.py +++ b/tests/algorithms/test_temporal.py @@ -1,20 +1,8 @@ from __future__ import annotations import numpy as np -import torch -from torch_geometric import EdgeIndex -from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths -from pathpyG.core.graph import Graph - - -def test_lift_order_temporal(simple_temporal_graph): - edge_index = lift_order_temporal(simple_temporal_graph, delta=5) - event_graph = Graph.from_edge_index(edge_index) - assert event_graph.n == simple_temporal_graph.m - # for delta=5 we have three time-respecting paths (a,b,1) -> (b,c,5), (b,c,5) -> (c,d,9) and (b,c,5) -> (c,e,9) - assert event_graph.m == 3 - assert torch.equal(event_graph.data.edge_index, EdgeIndex([[0, 1, 1], [1, 2, 3]])) +from pathpyG.algorithms.temporal import temporal_shortest_paths def test_temporal_shortest_paths(long_temporal_graph): diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py index 767cd915..8995b9c0 100644 --- a/tests/core/test_event_graph.py +++ b/tests/core/test_event_graph.py @@ -5,9 +5,7 @@ import torch from scipy.sparse.csgraph import dijkstra from torch_geometric.data import Data -from torch_geometric.utils import to_scipy_sparse_matrix -from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths from pathpyG.core.event_graph import EventGraph from pathpyG.core.index_map import IndexMap from pathpyG.core.multi_order_model import MultiOrderModel @@ -53,46 +51,18 @@ def temporal_graph() -> TemporalGraph: ) -@pytest.fixture -def existing(temporal_graph): - # Properties of `temporal_graph` computed using the existing API. - ho = lift_order_temporal(temporal_graph, DELTA) # (2, 2) - m = temporal_graph.data.time.numel() # 4 - number of events - n = temporal_graph.n # 5 - number of FO nodes - node_time = temporal_graph.data.time - node_sequence = temporal_graph.data.edge_index.as_tensor().t() # (m, 2) - - edge_delta = node_time[ho[1]] - node_time[ho[0]] - adj = to_scipy_sparse_matrix(ho, edge_attr=edge_delta, num_nodes=m) - fastest = dijkstra(adj, directed=True) # (m, m) - - dist_fo, pred_fo = temporal_shortest_paths(temporal_graph, DELTA) # (n, n) - - return { - "ho": ho, - "m": m, - "n": n, - "node_time": node_time, - "node_sequence": node_sequence, - "edge_delta": edge_delta, - "fastest": fastest, - "dist_fo": dist_fo, - "pred_fo": pred_fo, - } - - @pytest.fixture def event_graph(temporal_graph) -> EventGraph: return EventGraph.from_temporal_graph(temporal_graph, delta=DELTA) -def test_basic(event_graph, existing): +def test_basic(event_graph): """Basic counts (delta, events, nodes) match the source temporal graph.""" assert event_graph.delta == DELTA - assert len(event_graph) == existing["m"] == 4 - assert event_graph.num_events == existing["m"] == 4 - assert event_graph.n == existing["m"] == 4 - assert event_graph.num_fo_nodes == existing["n"] == 5 + assert len(event_graph) == 4 + assert event_graph.num_events == 4 + assert event_graph.n == 4 + assert event_graph.num_fo_nodes == 5 def test_str(event_graph): @@ -103,15 +73,13 @@ def test_str(event_graph): ) -def test_node_time(event_graph, existing): +def test_node_time(event_graph): """Each event node carries the timestamp of its underlying edge.""" - assert torch.equal(event_graph.data.node_time, existing["node_time"]) assert event_graph.data.node_time.tolist() == [1, 2, 3, 5] -def test_node_sequence(event_graph, existing): +def test_node_sequence(event_graph): """Each event node stores the (source, target) first-order node pair.""" - assert torch.equal(event_graph.data.node_sequence, existing["node_sequence"]) assert event_graph.data.node_sequence.tolist() == [[0, 1], [1, 2], [2, 4], [1, 3]] @@ -124,18 +92,16 @@ def test_fo_mapping(event_graph, temporal_graph): assert fo.to_idx(node) == temporal_graph.mapping.to_idx(node) -def test_continuation_edge_index_matches_existing(event_graph, existing): - """The continuation edge index matches the existing lift-order result.""" +def test_continuation_edge_index(event_graph): + """The continuation edge index matches the expected result.""" got = event_graph.data.edge_index.as_tensor() got_set = {tuple(c) for c in got.t().tolist()} - assert got_set == {tuple(c) for c in existing["ho"].t().tolist()} assert got_set == {(0, 1), (1, 2)} -def test_event_time(event_graph, existing): +def test_event_time(event_graph, ): """event_time(i) returns the timestamp of the i-th event.""" - for i in range(len(event_graph)): - assert event_graph.event_time(i) == existing["node_time"][i].item() + assert [event_graph.event_time(i) for i in range(event_graph.num_events)] == [1, 2, 3, 5] def test_getitem(event_graph): @@ -156,24 +122,17 @@ def test_isolated_events(event_graph): assert isolated == [3] -def test_continuations_and_gaps(event_graph): - """continuations(i) returns each successor event with its time gap.""" - cont = {i: event_graph.continuations(i) for i in range(event_graph.num_events)} - assert cont[0] == [(1, 1)] # (a->b)@1 -> (b->c)@2, gap 1 - assert cont[1] == [(2, 1)] # (b->c)@2 -> (c->e)@3, gap 1 - assert cont[2] == [] - assert cont[3] == [] - - -def test_continuation_deltas(event_graph): - """Every continuation gap lies within (0, delta].""" +def test_edge_deltas(event_graph): + """Every edge_delta lies within (0, delta].""" for i in range(event_graph.num_events): - for _nxt, gap in event_graph.continuations(i): - assert 0 < gap <= event_graph.delta + for nxt in event_graph.get_successors(i): + nxt = int(nxt.item()) + delta = event_graph.data.edge_delta[event_graph.edge_to_index[(i, nxt)]].item() + assert 0 < delta <= event_graph.delta -def test_edge_delta_matches_existing(event_graph, existing): - """Per-edge time deltas match the existing lift-order result.""" +def test_edge_delta(event_graph): + """Per-edge time deltas match the expected value.""" got = { tuple(c): d for c, d in zip( @@ -181,24 +140,37 @@ def test_edge_delta_matches_existing(event_graph, existing): event_graph.data.edge_delta.tolist(), ) } - expected = { - tuple(c): d - for c, d in zip(existing["ho"].t().tolist(), existing["edge_delta"].tolist()) - } - assert got == expected assert got == {(0, 1): 1, (1, 2): 1} -def test_shortest_paths_distances(event_graph, existing): - """shortest_paths() distances match the existing first-order result.""" +def test_shortest_paths_distances(event_graph): + """shortest_paths() distances match the expected result.""" dist, _pred = event_graph.shortest_paths() - np.testing.assert_array_equal(dist, existing["dist_fo"]) + expected = np.array( + [ + [0, 1, 2, np.inf, 3], + [np.inf, 0, 1, 1, 2], + [np.inf, np.inf, 0, np.inf, 1], + [np.inf, np.inf, np.inf, 0, np.inf], + [np.inf, np.inf, np.inf, np.inf, 0], + ] + ) + np.testing.assert_array_equal(dist, expected) -def test_shortest_paths_predecessors(event_graph, existing): - """shortest_paths() predecessors match the existing first-order result.""" +def test_shortest_paths_predecessors(event_graph): + """shortest_paths() predecessors match the expected result.""" _dist, pred = event_graph.shortest_paths() - np.testing.assert_array_equal(pred, existing["pred_fo"]) + expected = np.array( + [ + [0, 0, 1, -1, 2], + [-1, 1, 1, 1, 2], + [-1, -1, 2, -1, 2], + [-1, -1, -1, 3, -1], + [-1, -1, -1, -1, 4], + ] + ) + np.testing.assert_array_equal(pred, expected) def test_shortest_paths_a_to_d_is_unreachable(event_graph): @@ -207,10 +179,18 @@ def test_shortest_paths_a_to_d_is_unreachable(event_graph): assert dist[0, 3] == np.inf -def test_fastest_path_distances(event_graph, existing): - """Fastest-path distances over edge deltas match the existing result.""" +def test_fastest_path_distances(event_graph): + """Fastest-path distances over edge deltas match the expected result.""" fastest = dijkstra(event_graph.sparse_adj_matrix(edge_attr="edge_delta"), directed=True) - np.testing.assert_array_equal(fastest, existing["fastest"]) + expected = np.array( + [ + [0, 1, 2, np.inf], + [np.inf, 0, 1, np.inf], + [np.inf, np.inf, 0, np.inf], + [np.inf, np.inf, np.inf, 0], + ] + ) + np.testing.assert_array_equal(fastest, expected) def test_to_temporal_graph_round_trip(event_graph, temporal_graph): From 1a77eac9a0a9da159176a1793a41846c8f62b633 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Fri, 10 Jul 2026 09:40:55 -0400 Subject: [PATCH 5/9] lift_order_temporal -> EventGraph.build_edge_index in docs --- .../archive/_scalability_analysis.ipynb | 16 ++++---- docs/tutorial/implementation_concepts.ipynb | 39 +++++++++---------- docs/tutorial/temporal_graphs.ipynb | 4 +- docs/tutorial/trp_higher_order.ipynb | 14 +++---- 4 files changed, 36 insertions(+), 37 deletions(-) diff --git a/docs/tutorial/archive/_scalability_analysis.ipynb b/docs/tutorial/archive/_scalability_analysis.ipynb index 799c0550..6c83ac7f 100644 --- a/docs/tutorial/archive/_scalability_analysis.ipynb +++ b/docs/tutorial/archive/_scalability_analysis.ipynb @@ -36,7 +36,7 @@ " res['temp_net_events'] = g.data.edge_index.size(1)\n", "\n", " start_time = time.time()\n", - " eg = pp.algorithms.lift_order_temporal(g, delta=exp['delta'])\n", + " eg = pp.core.event_graph.EventGraph.build_edge_index(g, delta=exp['delta'])\n", " eg.to(exp['device'])\n", " res['lift_event_graph_time'] = time.time() - start_time\n", " res['event_graph_edges'] = eg.size(1)\n", @@ -1392,13 +1392,13 @@ "evalue": "", "output_type": "error", "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mKeyboardInterrupt\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[4], line 13\u001b[0m\n\u001b[1;32m 11\u001b[0m exp[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmax_order\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m k\n\u001b[1;32m 12\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[0;32m---> 13\u001b[0m res \u001b[38;5;241m=\u001b[39m \u001b[43mtest_mo_scalability\u001b[49m\u001b[43m(\u001b[49m\u001b[43mg\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mexp\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 14\u001b[0m printer\u001b[38;5;241m.\u001b[39mpprint(res)\n\u001b[1;32m 15\u001b[0m results_rm[delta][k] \u001b[38;5;241m=\u001b[39m res\n", - "Cell \u001b[0;32mIn[2], line 16\u001b[0m, in \u001b[0;36mtest_mo_scalability\u001b[0;34m(g, exp)\u001b[0m\n\u001b[1;32m 13\u001b[0m res[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mevent_graph_edges\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m eg\u001b[38;5;241m.\u001b[39msize(\u001b[38;5;241m1\u001b[39m)\n\u001b[1;32m 15\u001b[0m start_time \u001b[38;5;241m=\u001b[39m time\u001b[38;5;241m.\u001b[39mtime()\n\u001b[0;32m---> 16\u001b[0m m \u001b[38;5;241m=\u001b[39m \u001b[43mpp\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mMultiOrderModel\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mfrom_temporal_graph\u001b[49m\u001b[43m(\u001b[49m\u001b[43mg\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mdelta\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mexp\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mdelta\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mmax_order\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mexp\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mmax_order\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 17\u001b[0m res[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmo_time\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m time\u001b[38;5;241m.\u001b[39mtime() \u001b[38;5;241m-\u001b[39m start_time\n\u001b[1;32m 18\u001b[0m res[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmax_order_nodes\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m m\u001b[38;5;241m.\u001b[39mlayers[exp[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmax_order\u001b[39m\u001b[38;5;124m'\u001b[39m]]\u001b[38;5;241m.\u001b[39mN\n", - "File \u001b[0;32m/workspaces/pathpyG/src/pathpyG/core/multi_order_model.py:108\u001b[0m, in \u001b[0;36mMultiOrderModel.from_temporal_graph\u001b[0;34m(g, delta, max_order, weight, cached)\u001b[0m\n\u001b[1;32m 106\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m max_order \u001b[38;5;241m>\u001b[39m \u001b[38;5;241m1\u001b[39m:\n\u001b[1;32m 107\u001b[0m node_sequence \u001b[38;5;241m=\u001b[39m torch\u001b[38;5;241m.\u001b[39mcat([node_sequence[edge_index[\u001b[38;5;241m0\u001b[39m]], node_sequence[edge_index[\u001b[38;5;241m1\u001b[39m]][:, \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m1\u001b[39m:]], dim\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m1\u001b[39m)\n\u001b[0;32m--> 108\u001b[0m edge_index \u001b[38;5;241m=\u001b[39m \u001b[43mlift_order_temporal\u001b[49m\u001b[43m(\u001b[49m\u001b[43mg\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mdelta\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 109\u001b[0m edge_weight \u001b[38;5;241m=\u001b[39m aggregate_node_attributes(edge_index, edge_weight, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124msrc\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 111\u001b[0m \u001b[38;5;66;03m# Aggregate\u001b[39;00m\n", - "File \u001b[0;32m/workspaces/pathpyG/src/pathpyG/algorithms/temporal.py:39\u001b[0m, in \u001b[0;36mlift_order_temporal\u001b[0;34m(g, delta)\u001b[0m\n\u001b[1;32m 36\u001b[0m dst_node_mask \u001b[38;5;241m=\u001b[39m torch\u001b[38;5;241m.\u001b[39misin(edge_index[\u001b[38;5;241m0\u001b[39m], edge_index[\u001b[38;5;241m1\u001b[39m, src_edge_idx])\n\u001b[1;32m 37\u001b[0m dst_edge_idx \u001b[38;5;241m=\u001b[39m indices[dst_time_mask \u001b[38;5;241m&\u001b[39m dst_node_mask]\n\u001b[0;32m---> 39\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[43mdst_edge_idx\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43msize\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m0\u001b[39;49m\u001b[43m)\u001b[49m \u001b[38;5;241m>\u001b[39m \u001b[38;5;241m0\u001b[39m \u001b[38;5;129;01mand\u001b[39;00m src_edge_idx\u001b[38;5;241m.\u001b[39msize(\u001b[38;5;241m0\u001b[39m) \u001b[38;5;241m>\u001b[39m \u001b[38;5;241m0\u001b[39m:\n\u001b[1;32m 40\u001b[0m \n\u001b[1;32m 41\u001b[0m \u001b[38;5;66;03m# compute second-order edges between src and dst idx for all edges where dst in src_edges matches src in dst_edges\u001b[39;00m\n\u001b[1;32m 42\u001b[0m x \u001b[38;5;241m=\u001b[39m torch\u001b[38;5;241m.\u001b[39mcartesian_prod(src_edge_idx, dst_edge_idx)\u001b[38;5;241m.\u001b[39mt()\n\u001b[1;32m 43\u001b[0m \u001b[38;5;66;03m# print(x.size(1))\u001b[39;00m\n", - "\u001b[0;31mKeyboardInterrupt\u001b[0m: " + "\u001B[0;31m---------------------------------------------------------------------------\u001B[0m", + "\u001B[0;31mKeyboardInterrupt\u001B[0m Traceback (most recent call last)", + "Cell \u001B[0;32mIn[4], line 13\u001B[0m\n\u001B[1;32m 11\u001B[0m exp[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmax_order\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m k\n\u001B[1;32m 12\u001B[0m \u001B[38;5;28;01mtry\u001B[39;00m:\n\u001B[0;32m---> 13\u001B[0m res \u001B[38;5;241m=\u001B[39m \u001B[43mtest_mo_scalability\u001B[49m\u001B[43m(\u001B[49m\u001B[43mg\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mexp\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 14\u001B[0m printer\u001B[38;5;241m.\u001B[39mpprint(res)\n\u001B[1;32m 15\u001B[0m results_rm[delta][k] \u001B[38;5;241m=\u001B[39m res\n", + "Cell \u001B[0;32mIn[2], line 16\u001B[0m, in \u001B[0;36mtest_mo_scalability\u001B[0;34m(g, exp)\u001B[0m\n\u001B[1;32m 13\u001B[0m res[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mevent_graph_edges\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m eg\u001B[38;5;241m.\u001B[39msize(\u001B[38;5;241m1\u001B[39m)\n\u001B[1;32m 15\u001B[0m start_time \u001B[38;5;241m=\u001B[39m time\u001B[38;5;241m.\u001B[39mtime()\n\u001B[0;32m---> 16\u001B[0m m \u001B[38;5;241m=\u001B[39m \u001B[43mpp\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mMultiOrderModel\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mfrom_temporal_graph\u001B[49m\u001B[43m(\u001B[49m\u001B[43mg\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mdelta\u001B[49m\u001B[38;5;241;43m=\u001B[39;49m\u001B[43mexp\u001B[49m\u001B[43m[\u001B[49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[38;5;124;43mdelta\u001B[39;49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[43m]\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mmax_order\u001B[49m\u001B[38;5;241;43m=\u001B[39;49m\u001B[43mexp\u001B[49m\u001B[43m[\u001B[49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[38;5;124;43mmax_order\u001B[39;49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[43m]\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 17\u001B[0m res[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmo_time\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m time\u001B[38;5;241m.\u001B[39mtime() \u001B[38;5;241m-\u001B[39m start_time\n\u001B[1;32m 18\u001B[0m res[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmax_order_nodes\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m m\u001B[38;5;241m.\u001B[39mlayers[exp[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmax_order\u001B[39m\u001B[38;5;124m'\u001B[39m]]\u001B[38;5;241m.\u001B[39mN\n", + "File \u001B[0;32m/workspaces/pathpyG/src/pathpyG/core/multi_order_model.py:108\u001B[0m, in \u001B[0;36mMultiOrderModel.from_temporal_graph\u001B[0;34m(g, delta, max_order, weight, cached)\u001B[0m\n\u001B[1;32m 106\u001B[0m \u001B[38;5;28;01mif\u001B[39;00m max_order \u001B[38;5;241m>\u001B[39m \u001B[38;5;241m1\u001B[39m:\n\u001B[1;32m 107\u001B[0m node_sequence \u001B[38;5;241m=\u001B[39m torch\u001B[38;5;241m.\u001B[39mcat([node_sequence[edge_index[\u001B[38;5;241m0\u001B[39m]], node_sequence[edge_index[\u001B[38;5;241m1\u001B[39m]][:, \u001B[38;5;241m-\u001B[39m\u001B[38;5;241m1\u001B[39m:]], dim\u001B[38;5;241m=\u001B[39m\u001B[38;5;241m1\u001B[39m)\n\u001B[0;32m--> 108\u001B[0m edge_index \u001B[38;5;241m=\u001B[39m \u001B[43mlift_order_temporal\u001B[49m\u001B[43m(\u001B[49m\u001B[43mg\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mdelta\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 109\u001B[0m edge_weight \u001B[38;5;241m=\u001B[39m aggregate_node_attributes(edge_index, edge_weight, \u001B[38;5;124m\"\u001B[39m\u001B[38;5;124msrc\u001B[39m\u001B[38;5;124m\"\u001B[39m)\n\u001B[1;32m 111\u001B[0m \u001B[38;5;66;03m# Aggregate\u001B[39;00m\n", + "File \u001B[0;32m/workspaces/pathpyG/src/pathpyG/algorithms/temporal.py:39\u001B[0m, in \u001B[0;36mlift_order_temporal\u001B[0;34m(g, delta)\u001B[0m\n\u001B[1;32m 36\u001B[0m dst_node_mask \u001B[38;5;241m=\u001B[39m torch\u001B[38;5;241m.\u001B[39misin(edge_index[\u001B[38;5;241m0\u001B[39m], edge_index[\u001B[38;5;241m1\u001B[39m, src_edge_idx])\n\u001B[1;32m 37\u001B[0m dst_edge_idx \u001B[38;5;241m=\u001B[39m indices[dst_time_mask \u001B[38;5;241m&\u001B[39m dst_node_mask]\n\u001B[0;32m---> 39\u001B[0m \u001B[38;5;28;01mif\u001B[39;00m \u001B[43mdst_edge_idx\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43msize\u001B[49m\u001B[43m(\u001B[49m\u001B[38;5;241;43m0\u001B[39;49m\u001B[43m)\u001B[49m \u001B[38;5;241m>\u001B[39m \u001B[38;5;241m0\u001B[39m \u001B[38;5;129;01mand\u001B[39;00m src_edge_idx\u001B[38;5;241m.\u001B[39msize(\u001B[38;5;241m0\u001B[39m) \u001B[38;5;241m>\u001B[39m \u001B[38;5;241m0\u001B[39m:\n\u001B[1;32m 40\u001B[0m \n\u001B[1;32m 41\u001B[0m \u001B[38;5;66;03m# compute second-order edges between src and dst idx for all edges where dst in src_edges matches src in dst_edges\u001B[39;00m\n\u001B[1;32m 42\u001B[0m x \u001B[38;5;241m=\u001B[39m torch\u001B[38;5;241m.\u001B[39mcartesian_prod(src_edge_idx, dst_edge_idx)\u001B[38;5;241m.\u001B[39mt()\n\u001B[1;32m 43\u001B[0m \u001B[38;5;66;03m# print(x.size(1))\u001B[39;00m\n", + "\u001B[0;31mKeyboardInterrupt\u001B[0m: " ] } ], diff --git a/docs/tutorial/implementation_concepts.ipynb b/docs/tutorial/implementation_concepts.ipynb index 8bcdad2f..d6e1de83 100644 --- a/docs/tutorial/implementation_concepts.ipynb +++ b/docs/tutorial/implementation_concepts.ipynb @@ -2023,7 +2023,7 @@ "source": [ "### Temporal Order Lifting\n", "\n", - "One of the core functionalities of PathpyG is the ability to create temporal higher-order models. For this, an extension of the `lift_order_edge_index` function to temporal graphs is needed. We implement this in the `lift_order_temporal` function. This function works similarly to the `lift_order_edge_index` function, but with some additional steps to account for the temporal aspect of the graph. The main difference is that we need to ensure that the higher-order edges respect the temporal ordering of the original edges. Let us take a look at an example:" + "One of the core functionalities of PathpyG is the ability to create temporal higher-order models. For this, an extension of the `lift_order_edge_index` function to temporal graphs is needed. We implement this in the `EventGraph.build_edge_index` function. This function works similarly to the `lift_order_edge_index` function, but with some additional steps to account for the temporal aspect of the graph. The main difference is that we need to ensure that the higher-order edges respect the temporal ordering of the original edges. Let us take a look at an example:" ] }, { @@ -2690,9 +2690,7 @@ "cell_type": "markdown", "id": "951a8ffa", "metadata": {}, - "source": [ - "We can create a second-order graph from this temporal graph using the `lift_order_temporal` function. This second-order graph is typically referred to as an event graph. Each node in the graph is an event (edge) in the original temporal graph and two events are connected if they can follow each other in time respecting a maximum time difference `delta`. Here, we set `delta=2` which means that two events can be connected if the time difference between them is at most 2 time units." - ] + "source": "We can create a second-order graph from this temporal graph using the `EventGraph.build_edge_index` function. This second-order graph is typically referred to as an event graph. Each node in the graph is an event (edge) in the original temporal graph and two events are connected if they can follow each other in time respecting a maximum time difference `delta`. Here, we set `delta=2` which means that two events can be connected if the time difference between them is at most 2 time units." }, { "cell_type": "code", @@ -3223,7 +3221,7 @@ } ], "source": [ - "event_edge_index = pp.algorithms.temporal.lift_order_temporal(t, delta=2)\n", + "event_edge_index = pp.core.event_graph.EventGraph.build_edge_index(t, delta=2)\n", "event_mapping = pp.IndexMap(t.temporal_edges)\n", "event_data = Data(edge_index=event_edge_index, node_sequence=graph.data.edge_index.t())\n", "event_graph = pp.Graph(data=event_data, mapping=event_mapping)\n", @@ -3237,9 +3235,9 @@ "source": [ "Starting with the event graph, we have a static higher-order representation of the temporal graph that we can use to create higher-order models. For each following lift-order transformations, we can use the same principles as described in the previous section on order-lifting and line graph transformations. \n", "\n", - "#### Internals of the `lift_order_temporal` Function\n", + "#### Internals of the `EventGraph.build_edge_index` Function\n", "\n", - "The simplest way to implement the `lift_order_temporal` function would be to first create the full higher-order edge index using the `lift_order_edge_index` function and then filter out the edges that do not respect the temporal ordering. The filter function could look as follows:" + "The simplest way to implement the `EventGraph.build_edge_index` function would be to first create the full higher-order edge index using the `lift_order_edge_index` function and then filter out the edges that do not respect the temporal ordering. The filter function could look as follows:" ] }, { @@ -3825,7 +3823,7 @@ "

\n", "\n", "\n", - "However, the above implementation has a large memory consumption for graphs with many edges because the full higher-order edge index is created before filtering. Therefore, we implement a more memory-efficient version in PathpyG that constructs the higher-order edges from the temporal graph sequentially for each timestamp. This implementation looks as follows:" + "However, the above implementation has a large memory consumption for graphs with many edges because the full higher-order edge index is created before filtering. Therefore, we implement a more memory-efficient version in PathpyG that constructs the higher-order edges from the temporal graph sequentially for each timestamp. This implementation (available as `EventGraph.build_edge_index`) looks as follows:" ] }, { @@ -3835,30 +3833,31 @@ "metadata": {}, "outputs": [], "source": [ - "def lift_order_temporal(g: pp.TemporalGraph, delta: int = 1): # noqa: D103\n", - " indices = torch.arange(0, g.data.edge_index.size(1))\n", + "def build_edge_index(g: pp.TemporalGraph, delta: int = 1): # noqa: D103\n", + " # first-order edge index\n", + " edge_index, timestamps = g.data.edge_index, g.data.time\n", "\n", - " unique_t = torch.unique(g.data.time)\n", + " delta = torch.tensor(delta, device=edge_index.device) # type: ignore[assignment]\n", + " indices = torch.arange(0, edge_index.size(1), device=edge_index.device)\n", + "\n", + " unique_t = torch.unique(timestamps, sorted=True)\n", " second_order = []\n", "\n", " # lift order: find possible continuations for edges in each time stamp\n", " for t in unique_t:\n", - "\n", " # find indices of all source edges that occur at unique timestamp t\n", - " src_time_mask = g.data.time == t\n", + " src_time_mask = timestamps == t\n", " src_edge_idx = indices[src_time_mask]\n", "\n", " # find indices of all edges that can possibly continue edges occurring at time t for the given delta\n", - " dst_time_mask = (g.data.time > t) & (g.data.time <= t + delta)\n", + " dst_time_mask = (timestamps > t) & (timestamps <= t + delta)\n", " dst_edge_idx = indices[dst_time_mask]\n", "\n", " if dst_edge_idx.size(0) > 0 and src_edge_idx.size(0) > 0:\n", " # compute second-order edges between src and dst idx\n", - " # create all possible combinations of src and dst edges\n", + " # for all edges where dst in src_edges (edge_index[1, x[:, 0]]) matches src in dst_edges (edge_index[0, x[:, 1]])\n", " x = torch.cartesian_prod(src_edge_idx, dst_edge_idx)\n", - " # filter combinations for real higher-order edges\n", - " # for all edges where dst in src_edges (g.data.edge_index[1, x[:, 0]]) matches src in dst_edges (g.data.edge_index[0, x[:, 1]])\n", - " ho_edge_index = x[g.data.edge_index[1, x[:, 0]] == g.data.edge_index[0, x[:, 1]]]\n", + " ho_edge_index = x[edge_index[1, x[:, 0]] == edge_index[0, x[:, 1]]]\n", " second_order.append(ho_edge_index)\n", "\n", " ho_index = torch.cat(second_order, dim=0).t().contiguous()\n", @@ -5691,7 +5690,7 @@ "id": "94f6db38", "metadata": {}, "source": [ - "We can see that the second-order graph created by the `MultiOrderModel` is different from the one created by the `lift_order_temporal` function directly. This is because the `MultiOrderModel` higher-order DeBruijn graph representation. This representation merges higher-order nodes that correspond to the same path in the original graph. This means that temporal edges that appear in the event graph as different nodes will be merged into one node in the DeBruijn graph if they correspond to the same path in the original graph. This results in a more compact representation of the higher-order graph.\n", + "We can see that the second-order graph created by the `MultiOrderModel` is different from the one created by the `EventGraph.build_edge_index` function directly. This is because the `MultiOrderModel` higher-order DeBruijn graph representation. This representation merges higher-order nodes that correspond to the same path in the original graph. This means that temporal edges that appear in the event graph as different nodes will be merged into one node in the DeBruijn graph if they correspond to the same path in the original graph. This results in a more compact representation of the higher-order graph.\n", "\n", "\n", "The same is true for paths. We can create a multi-order model from a collection of paths as follows:" @@ -6241,7 +6240,7 @@ "Let us now take a closer look at how the `MultiOrderModel` class works under the hood. We already saw that the `MultiOrderModel` merges higher-order nodes from the line/event graph transformations. \n", "\n", "This is done in 3 distinct steps which we will go through using the paths example above:\n", - "1. **Order Lifting**: First, we create the higher-order edge index using the appropriate lift-order function (`lift_order_edge_index` or `lift_order_temporal`) depending on whether we are working with paths or temporal graphs in the first order and `lift_order_edge_index` for the second order and beyond regardless of the input type.\n", + "1. **Order Lifting**: First, we create the higher-order edge index using the appropriate lift-order function (`EventGraph.build_edge_index` or `lift_order_temporal`) depending on whether we are working with paths or temporal graphs in the first order and `lift_order_edge_index` for the second order and beyond regardless of the input type.\n", "\n", "
\n", "

Note

\n", diff --git a/docs/tutorial/temporal_graphs.ipynb b/docs/tutorial/temporal_graphs.ipynb index 31b1de8b..6ba57852 100644 --- a/docs/tutorial/temporal_graphs.ipynb +++ b/docs/tutorial/temporal_graphs.ipynb @@ -1117,7 +1117,7 @@ "\n", "To calculate time-respecting paths in a temporal graph, we can construct a directed acyclic graph (DAG), where each time-stamped edge $(u,v;t)$ in the temporal graph is represented by a node and two nodes representing time-stamped edges $(u,v;t_1)$ and $(v,w;t_2)$ are connected by an edge iff $0 < t_2-t_1 \\leq \\delta$. This implies that (i) each edge in the resulting DAG represents a time-respecting path of length two, and (ii) time-respecting paths of any lenghts are represented by paths in this DAG.\n", "\n", - "We can construct such a DAG using the function `pp.algorithms.lift_order_temporal`, which returns an edge_index. We can pass this to the constructor of a `Graph` object, which we can use to visualize the resulting DAG." + "We can construct such a DAG using the function `pp.core.event_graph.EventGraph.build_edge_index`, which returns an edge_index. We can pass this to the constructor of a `Graph` object, which we can use to visualize the resulting DAG." ] }, { @@ -1127,7 +1127,7 @@ "outputs": [], "source": [ "%%capture\n", - "e_i = pp.algorithms.lift_order_temporal(t, delta=1)" + "e_i = pp.core.event_graph.EventGraph.build_edge_index(t, delta=1)" ] }, { diff --git a/docs/tutorial/trp_higher_order.ipynb b/docs/tutorial/trp_higher_order.ipynb index 0a9a5605..e2023eef 100644 --- a/docs/tutorial/trp_higher_order.ipynb +++ b/docs/tutorial/trp_higher_order.ipynb @@ -605,7 +605,7 @@ } ], "source": [ - "e_i = pp.algorithms.lift_order_temporal(t, delta=1)\n", + "e_i = pp.core.event_graph.EventGraph.build_edge_index(t, delta=1)\n", "mapping = pp.IndexMap([f'{v}-{w}-{time}' for v, w, time in t.temporal_edges])\n", "dag = pp.Graph.from_edge_index(e_i, mapping=mapping)\n", "pp.plot(dag);" @@ -3484,9 +3484,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "tags": [ - "skip-execution" - ] + "tags": [ + "skip-execution" + ] }, "outputs": [], "source": [ @@ -3498,9 +3498,9 @@ "cell_type": "code", "execution_count": 13, "metadata": { - "tags": [ - "skip-execution" - ] + "tags": [ + "skip-execution" + ] }, "outputs": [ { From e419e0b5c85a4f3f8d95563cce37cafbd3723f4f Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Fri, 10 Jul 2026 10:09:42 -0400 Subject: [PATCH 6/9] Added an eg: EventGraph argument to temporal_shortest_paths --- src/pathpyG/algorithms/temporal.py | 15 +++++++++++---- src/pathpyG/core/event_graph.py | 5 +---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/pathpyG/algorithms/temporal.py b/src/pathpyG/algorithms/temporal.py index c46b6583..3218ff43 100644 --- a/src/pathpyG/algorithms/temporal.py +++ b/src/pathpyG/algorithms/temporal.py @@ -14,20 +14,27 @@ from pathpyG.utils import to_numpy -def temporal_shortest_paths(g: TemporalGraph, delta: int) -> Tuple[np.ndarray, np.ndarray]: +def temporal_shortest_paths(g: TemporalGraph | None, delta: int, eg: EventGraph | None = None) -> Tuple[np.ndarray, np.ndarray]: """Compute shortest time-respecting paths in a temporal graph. Args: - g: Temporal graph to compute shortest paths on. + g: Temporal graph to compute shortest paths on. If None, `eg` must be provided. delta: Maximum time difference between events in a path. + eg: Event graph to compute shortest paths on. If None, `g` must be provided. Returns: Tuple of two numpy arrays: - dist: Shortest time-respecting path distances between all first-order nodes. - pred: Predecessor matrix for shortest time-respecting paths between all first-order nodes. """ - # generate temporal event DAG - edge_index = EventGraph.build_edge_index(g, delta) + assert g is None or eg is None, "Only one of g or eg can be provided" + if g is None: + assert eg is not None, "If g is None, eg must be provided" + edge_index = eg.data.edge_index + g = eg.to_temporal_graph() + else: + # generate temporal event DAG + edge_index = EventGraph.build_edge_index(g, delta) # Add indices of first-order nodes as src and dst of paths in augmented # temporal event DAG diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py index 9491de0b..316c635d 100644 --- a/src/pathpyG/core/event_graph.py +++ b/src/pathpyG/core/event_graph.py @@ -169,9 +169,6 @@ def event_endpoints(self, i: int) -> Tuple: def shortest_paths(self) -> Tuple[np.ndarray, np.ndarray]: """Return first-order shortest-path distances and predecessors respecting delta.""" - # TODO: This is wasteful, since we already have the lifted edge index - # Modify `temporal_shortest_paths` to take in an optional pre-computed - # edge_index? from pathpyG.algorithms.temporal import temporal_shortest_paths - return temporal_shortest_paths(self.to_temporal_graph(), self.delta) \ No newline at end of file + return temporal_shortest_paths(g=None, delta=self.delta, eg=self) \ No newline at end of file From 719e861232f9d2a71758e730129453a69b453f97 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Fri, 10 Jul 2026 11:44:03 -0400 Subject: [PATCH 7/9] mapping defined for EventGraph; no using the term fo in methods --- src/pathpyG/core/event_graph.py | 45 ++++++++++++++++----------------- tests/core/test_event_graph.py | 10 ++++---- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py index 316c635d..67577890 100644 --- a/src/pathpyG/core/event_graph.py +++ b/src/pathpyG/core/event_graph.py @@ -20,8 +20,8 @@ def __init__( self, data: Data, delta: int, - fo_mapping: IndexMap | None = None, - num_fo_nodes: int | None = None, + first_order_mapping: IndexMap | None = None, + n_first_order: int | None = None, mapping: IndexMap | None = None, ) -> None: """Create an EventGraph from a `Data` object carrying per-event `node_time`.""" @@ -31,11 +31,11 @@ def __init__( super().__init__(data, mapping=mapping) self.delta = delta - self.fo_mapping = fo_mapping if fo_mapping is not None else IndexMap() - if num_fo_nodes is not None: - self._num_fo_nodes = int(num_fo_nodes) + self.first_order_mapping = first_order_mapping if first_order_mapping is not None else IndexMap() + if n_first_order is not None: + self._n_first_order = int(n_first_order) else: - self._num_fo_nodes = int(self.data.node_sequence.max().item()) + 1 + self._n_first_order = int(self.data.node_sequence.max().item()) + 1 ei = self.data.edge_index self.data.edge_delta = self.data.node_time[ei[1]] - self.data.node_time[ei[0]] @@ -93,13 +93,20 @@ def from_temporal_graph(cls, g: TemporalGraph, delta: int = 1) -> "EventGraph": node_sequence = g.data.edge_index.as_tensor().t().contiguous() # [m, 2] node_time = g.data.time.clone() # [m] + # Build an event mapping with IDs of the form "a->b@t" for each edge node + event_ids = [ + f"{g.mapping.to_id(u)}->{g.mapping.to_id(v)}@{t}" + for (u, v), t in zip(node_sequence.tolist(), node_time.tolist()) + ] + mapping = IndexMap(event_ids) + data = Data( edge_index=ho_index, num_nodes=m, node_sequence=node_sequence, node_time=node_time, ) - eg = cls(data, delta=delta, fo_mapping=g.mapping, num_fo_nodes=g.n) + eg = cls(data, delta=delta, first_order_mapping=g.mapping, n_first_order=g.n, mapping=mapping) # Attach a clone of the temporal graph since we already have it eg._temporal_graph = TemporalGraph(g.data.clone(), mapping=g.mapping) @@ -108,13 +115,9 @@ def from_temporal_graph(cls, g: TemporalGraph, delta: int = 1) -> "EventGraph": def __str__(self) -> str: """Return a human-readable summary listing the delta and all events.""" - events_str = "" - for i in range(self.n): - u_id, v_id, t = self.event_endpoints(i) - events_str += f"\n{u_id}->{v_id}@{t}" return ( - f"EventGraph (delta={self.delta})" - f"{events_str}" + f"EventGraph (delta={self.delta})\n" + + "\n".join(f"{self.mapping.to_id(i)}" for i in range(self.n)) ) def __len__(self): @@ -124,7 +127,8 @@ def __len__(self): def __getitem__(self, key): """Return the (u, v, t) endpoints for an integer key, else delegate to `Graph`.""" if isinstance(key, (int, np.integer)) and not isinstance(key, bool): - return self.event_endpoints(int(key)) + u, v = self.data.node_sequence[key].tolist() + return self.first_order_mapping.to_id(u), self.first_order_mapping.to_id(v), self.data.node_time[key].item() return super().__getitem__(key) def to(self, device: torch.device) -> "EventGraph": @@ -142,16 +146,16 @@ def to_temporal_graph(self) -> TemporalGraph: Data( edge_index=edge_index, time=self.data.node_time.clone(), - num_nodes=self.num_fo_nodes, + num_nodes=self.n_first_order, ), - mapping=self.fo_mapping, + mapping=self.first_order_mapping, ) return self._temporal_graph @property - def num_fo_nodes(self) -> int: + def n_first_order(self) -> int: """Number of distinct first-order nodes underlying the events.""" - return self._num_fo_nodes + return self._n_first_order @property def num_events(self) -> int: @@ -162,11 +166,6 @@ def event_time(self, i: int) -> int: """Return the timestamp of the i-th event.""" return self.data.node_time[i].item() - def event_endpoints(self, i: int) -> Tuple: - """Return the (source id, target id, time) of the i-th event.""" - u, v = self.data.node_sequence[i].tolist() - return self.fo_mapping.to_id(u), self.fo_mapping.to_id(v), self.data.node_time[i].item() - def shortest_paths(self) -> Tuple[np.ndarray, np.ndarray]: """Return first-order shortest-path distances and predecessors respecting delta.""" from pathpyG.algorithms.temporal import temporal_shortest_paths diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py index 8995b9c0..f546a1ea 100644 --- a/tests/core/test_event_graph.py +++ b/tests/core/test_event_graph.py @@ -62,7 +62,7 @@ def test_basic(event_graph): assert len(event_graph) == 4 assert event_graph.num_events == 4 assert event_graph.n == 4 - assert event_graph.num_fo_nodes == 5 + assert event_graph.n_first_order == 5 def test_str(event_graph): @@ -83,9 +83,9 @@ def test_node_sequence(event_graph): assert event_graph.data.node_sequence.tolist() == [[0, 1], [1, 2], [2, 4], [1, 3]] -def test_fo_mapping(event_graph, temporal_graph): +def test_first_order_mapping(event_graph, temporal_graph): """The first-order node mapping round-trips and matches the temporal graph.""" - fo = event_graph.fo_mapping + fo = event_graph.first_order_mapping assert fo.num_ids() == 5 for node in "abcde": assert fo.to_id(fo.to_idx(node)) == node @@ -261,8 +261,8 @@ def test_construct_from_data(event_data): def test_construct_from_data_to_temporal_graph(event_data): """An EventGraph built from raw Data still converts to a TemporalGraph.""" - fo = IndexMap(["a", "b", "c", "d", "e"]) - eg = EventGraph(event_data, delta=DELTA, fo_mapping=fo, num_fo_nodes=5) + first_order_mapping = IndexMap(["a", "b", "c", "d", "e"]) + eg = EventGraph(event_data, delta=DELTA, first_order_mapping=first_order_mapping, n_first_order=5) tg = eg.to_temporal_graph() assert isinstance(tg, TemporalGraph) assert tg.n == 5 From a00e0bb5a97ed93efac417d512c6550c75206766 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Fri, 10 Jul 2026 13:55:00 -0400 Subject: [PATCH 8/9] A reduce_delta method; A convenience edge_delta_map method --- src/pathpyG/core/event_graph.py | 39 ++++++++++++++++++++++++++++++++- tests/core/test_event_graph.py | 36 +++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py index 67577890..e979eb3b 100644 --- a/src/pathpyG/core/event_graph.py +++ b/src/pathpyG/core/event_graph.py @@ -166,8 +166,45 @@ def event_time(self, i: int) -> int: """Return the timestamp of the i-th event.""" return self.data.node_time[i].item() + def edge_delta_map(self) -> dict[tuple[int, int], int]: + """Return a mapping from each transition edge (src, dst) to its time delta.""" + return { + tuple(c): d + for c, d in zip( + self.data.edge_index.as_tensor().t().tolist(), + self.data.edge_delta.tolist(), + ) + } + def shortest_paths(self) -> Tuple[np.ndarray, np.ndarray]: """Return first-order shortest-path distances and predecessors respecting delta.""" from pathpyG.algorithms.temporal import temporal_shortest_paths - return temporal_shortest_paths(g=None, delta=self.delta, eg=self) \ No newline at end of file + return temporal_shortest_paths(g=None, delta=self.delta, eg=self) + + def reduce_delta(self, decrement: int = 1) -> "EventGraph": + """Return a new EventGraph with a reduced time window `delta - decrement`.""" + new_delta = self.delta - decrement + if new_delta < 0: + raise ValueError( + f"decrement={decrement} exceeds current delta={self.delta}" + ) + + ei = self.data.edge_index + edge_delta = self.data.node_time[ei[1]] - self.data.node_time[ei[0]] + mask = edge_delta <= new_delta + new_edge_index = ei[:, mask].contiguous() + + data = Data( + edge_index=new_edge_index, + num_nodes=self.n, + node_sequence=self.data.node_sequence.clone(), + node_time=self.data.node_time.clone(), + ) + return EventGraph( + data, + delta=new_delta, + first_order_mapping=self.first_order_mapping, + n_first_order=self.n_first_order, + mapping=self.mapping, + ) \ No newline at end of file diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py index f546a1ea..9f173286 100644 --- a/tests/core/test_event_graph.py +++ b/tests/core/test_event_graph.py @@ -131,16 +131,9 @@ def test_edge_deltas(event_graph): assert 0 < delta <= event_graph.delta -def test_edge_delta(event_graph): - """Per-edge time deltas match the expected value.""" - got = { - tuple(c): d - for c, d in zip( - event_graph.data.edge_index.as_tensor().t().tolist(), - event_graph.data.edge_delta.tolist(), - ) - } - assert got == {(0, 1): 1, (1, 2): 1} +def test_edge_delta_map(event_graph): + """Per-edge time delta map matches the expected value.""" + assert event_graph.edge_delta_map() == {(0, 1): 1, (1, 2): 1} def test_shortest_paths_distances(event_graph): @@ -233,6 +226,29 @@ def test_to_device(event_graph): assert moved.to_temporal_graph().data.edge_index.device.type == "cpu" +def test_reduce_delta(temporal_graph, event_graph): + """Reducing delta reproduces the graph built directly with the smaller delta.""" + eg_delta4 = EventGraph.from_temporal_graph(temporal_graph, delta=4) + eg_delta2 = eg_delta4.reduce_delta(decrement=2) + + assert eg_delta2.delta == 2 + assert eg_delta2.num_events == event_graph.num_events + assert eg_delta2.n_first_order == event_graph.n_first_order + assert torch.equal(eg_delta2.data.node_time, event_graph.data.node_time) + assert torch.equal(eg_delta2.data.node_sequence, event_graph.data.node_sequence) + assert eg_delta2.edge_delta_map() == event_graph.edge_delta_map() + + +def test_reduce_delta_to_zero_removes_all_edges(event_graph): + """Reducing delta 2->0 leaves the events but drops every continuation edge.""" + reduced = event_graph.reduce_delta(2) + assert reduced.delta == 0 + assert reduced.num_events == event_graph.num_events + assert reduced.data.edge_index.as_tensor().numel() == 0 + with pytest.raises(ValueError): + event_graph.reduce_delta(3) # would make delta negative + + """ The following tests illustrate that an EventGraph can be constructed from a raw `torch_geometric.data.Data` object. From 45f5765be3f80c4567f03eb6bb26fa6b2c95a688 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Fri, 10 Jul 2026 14:00:42 -0400 Subject: [PATCH 9/9] MultiOrderModel can be constructed from an EventGraph --- src/pathpyG/core/multi_order_model.py | 28 +++++++++++++++++++++++++++ tests/core/test_event_graph.py | 26 ++++++++++++------------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py index b6b1e459..f7739d27 100644 --- a/src/pathpyG/core/multi_order_model.py +++ b/src/pathpyG/core/multi_order_model.py @@ -191,6 +191,34 @@ def from_temporal_graph( m.layers[k] = gk # type: ignore[assignment] return m + @classmethod + def from_event_graph( + cls, + eg: EventGraph, + max_order: int = 2, + cached: bool = True, + ) -> "MultiOrderModel": + """Create a multi-order model from a pre-built event graph. + + Args: + eg: The second-order temporal `EventGraph` to build the model from. + max_order: The maximum order of the model to compute. + cached: Whether to also keep the aggregated layers below `max_order`. + + Returns: + MultiOrderModel2: A multi-order model equivalent to + `MultiOrderModel.from_temporal_graph(eg.to_temporal_graph(), delta=eg.delta, ...)`. + """ + m = cls() + m.layers = MultiOrderModel.from_temporal_graph( + eg.to_temporal_graph(), + delta=eg.delta, + max_order=max_order, + cached=cached, + event_graph=eg.data.edge_index.as_tensor(), + ).layers + return m + @staticmethod def from_path_data( path_data: PathData, max_order: int = 1, mode: str = "propagation", cached: bool = True diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py index 9f173286..deb1a466 100644 --- a/tests/core/test_event_graph.py +++ b/tests/core/test_event_graph.py @@ -202,20 +202,18 @@ def test_to_temporal_graph_round_trip(event_graph, temporal_graph): def test_multi_order_model_construction(event_graph, temporal_graph): """A MultiOrderModel built from an EventGraph matches one from a TemporalGraph.""" - with pytest.raises(AttributeError): - # no such attribute yet - mom_eg = MultiOrderModel.from_event_graph(event_graph, max_order=2) - mom_tg = MultiOrderModel.from_temporal_graph(temporal_graph, delta=DELTA, max_order=2) - - for k in (1, 2): - assert torch.equal( - mom_eg.layers[k].data.edge_index.as_tensor(), - mom_tg.layers[k].data.edge_index.as_tensor(), - ) - assert torch.equal( - mom_eg.layers[k].data.edge_weight, - mom_tg.layers[k].data.edge_weight, - ) + mom_eg = MultiOrderModel.from_event_graph(event_graph, max_order=2) + mom_tg = MultiOrderModel.from_temporal_graph(temporal_graph, delta=DELTA, max_order=2) + + for k in (1, 2): + assert torch.equal( + mom_eg.layers[k].data.edge_index.as_tensor(), + mom_tg.layers[k].data.edge_index.as_tensor(), + ) + assert torch.equal( + mom_eg.layers[k].data.edge_weight, + mom_tg.layers[k].data.edge_weight, + ) def test_to_device(event_graph):