From f0aa3e7b60baf028e60a463bffb9800a2a6f7ff3 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Wed, 8 Jul 2026 15:42:26 -0400
Subject: [PATCH 01/25] 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 c9ac3709a6cc40302c5f6117b00b802e755111e5 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Thu, 9 Jul 2026 11:42:20 -0400
Subject: [PATCH 02/25] 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 1140196bb666ffdd573b03b897220b19740a16ef Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Thu, 9 Jul 2026 11:50:12 -0400
Subject: [PATCH 03/25] 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 3845f52917e47867b6513c34b0dc466eb57610ee Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Thu, 9 Jul 2026 13:09:58 -0400
Subject: [PATCH 04/25] 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 8f3c75287bd4cc8993cea49c7f4a6133ae526068 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Fri, 10 Jul 2026 09:40:55 -0400
Subject: [PATCH 05/25] 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 2e8366561807d69636a637990626cfd196ea0edd Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Fri, 10 Jul 2026 10:09:42 -0400
Subject: [PATCH 06/25] 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 f97ba97291a1f141aed109fa4299c84fbf0401d6 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Fri, 10 Jul 2026 11:44:03 -0400
Subject: [PATCH 07/25] 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 84a58e9442af8ce391f18d259da27dbb67f5603d Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Fri, 10 Jul 2026 13:55:00 -0400
Subject: [PATCH 08/25] 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 a0fd316768ccae37656de89d9793735eea292d36 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Fri, 10 Jul 2026 14:00:42 -0400
Subject: [PATCH 09/25] 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):
From 1b032c2dc9096ef431309a796bd271f46533aac9 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Mon, 27 Jul 2026 10:07:42 -0400
Subject: [PATCH 10/25] Added tests for node labels for an EventGraph; making
EventGraph available from package root
---
src/pathpyG/__init__.py | 2 ++
tests/core/test_event_graph.py | 8 ++++++++
tests/visualisations/test_network_plot.py | 10 ++++++++++
3 files changed, 20 insertions(+)
diff --git a/src/pathpyG/__init__.py b/src/pathpyG/__init__.py
index 4f591847..4c365455 100644
--- a/src/pathpyG/__init__.py
+++ b/src/pathpyG/__init__.py
@@ -12,6 +12,7 @@
from pathpyG.core.multi_order_model import MultiOrderModel
from pathpyG.core.path_data import PathData
from pathpyG.core.temporal_graph import TemporalGraph
+from pathpyG.core.event_graph import EventGraph
from pathpyG import algorithms, io, nn, statistics
from pathpyG.utils.config import config
from pathpyG.utils.logger import logger
@@ -21,6 +22,7 @@
__all__ = [
"Graph",
"TemporalGraph",
+ "EventGraph",
"PathData",
"MultiOrderModel",
"IndexMap",
diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py
index deb1a466..1f294099 100644
--- a/tests/core/test_event_graph.py
+++ b/tests/core/test_event_graph.py
@@ -112,6 +112,14 @@ def test_getitem(event_graph):
assert event_graph[3] == ("b", "d", 5)
+def test_event_labels(event_graph):
+ """Events are labeled with as "u->v@t"."""
+ assert event_graph.nodes == ["a->b@1", "b->c@2", "c->e@3", "b->d@5"]
+ assert event_graph.edges == [("a->b@1", "b->c@2"), ("b->c@2", "c->e@3")]
+ assert event_graph.successors("a->b@1") == ["b->c@2"]
+ assert event_graph.predecessors("c->e@3") == ["b->c@2"]
+
+
def test_isolated_events(event_graph):
"""Events with no predecessors or successors are correctly identified."""
isolated = [
diff --git a/tests/visualisations/test_network_plot.py b/tests/visualisations/test_network_plot.py
index d2971056..c584767a 100644
--- a/tests/visualisations/test_network_plot.py
+++ b/tests/visualisations/test_network_plot.py
@@ -4,10 +4,12 @@
import pandas as pd
import pytest
+from pathpyG.core.event_graph import EventGraph
from pathpyG.core.graph import Graph
from pathpyG.core.index_map import IndexMap
from pathpyG.core.multi_order_model import MultiOrderModel
from pathpyG.core.path_data import PathData
+from pathpyG.core.temporal_graph import TemporalGraph
from pathpyG.visualisations.network_plot import NetworkPlot
@@ -202,6 +204,14 @@ def test_higher_order_network(self):
# Index should be stringified tuples
assert list(nodes.index) == ["a->b", "a->d", "b->c", "c->a"]
+ def test_event_graph(self):
+ # An event graph has order 2, but its node IDs are plain strings
+ tg = TemporalGraph.from_edge_list([("a", "b", 1), ("b", "c", 2), ("b", "d", 5), ("c", "e", 3)])
+ eg = EventGraph.from_temporal_graph(tg, delta=2)
+ plot = NetworkPlot(eg)
+ assert list(plot.data["nodes"].index) == ["a->b@1", "b->c@2", "c->e@3", "b->d@5"]
+ assert list(plot.data["edges"].index) == [("a->b@1", "b->c@2"), ("b->c@2", "c->e@3")]
+
def test_invalid_image_path_raises(self):
with pytest.raises(AttributeError):
NetworkPlot(self.g, node_image="/nonexistent/path/to/image.png")
From d55b174af40a625d2045cd1c4df5a075f9e62599 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Mon, 27 Jul 2026 13:58:50 -0400
Subject: [PATCH 11/25] variable renaming; pushed common __str__ code up for
Graph
---
docs/tutorial/implementation_concepts.ipynb | 2 +-
src/pathpyG/algorithms/temporal.py | 38 ++++++-------
src/pathpyG/core/event_graph.py | 60 +++++++++++++--------
src/pathpyG/core/graph.py | 25 +++++----
src/pathpyG/core/multi_order_model.py | 2 +-
src/pathpyG/core/temporal_graph.py | 32 ++---------
tests/core/test_event_graph.py | 10 ++--
7 files changed, 81 insertions(+), 88 deletions(-)
diff --git a/docs/tutorial/implementation_concepts.ipynb b/docs/tutorial/implementation_concepts.ipynb
index d6e1de83..ee224589 100644
--- a/docs/tutorial/implementation_concepts.ipynb
+++ b/docs/tutorial/implementation_concepts.ipynb
@@ -6240,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 (`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",
+ "1. **Order Lifting**: First, we create the higher-order edge index using the appropriate lift-order function (`lift_order_edge_index` or `EventGraph.build_edge_index`) 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/src/pathpyG/algorithms/temporal.py b/src/pathpyG/algorithms/temporal.py
index 3218ff43..6d332fe7 100644
--- a/src/pathpyG/algorithms/temporal.py
+++ b/src/pathpyG/algorithms/temporal.py
@@ -14,35 +14,35 @@
from pathpyG.utils import to_numpy
-def temporal_shortest_paths(g: TemporalGraph | None, delta: int, eg: EventGraph | None = None) -> Tuple[np.ndarray, np.ndarray]:
+def temporal_shortest_paths(temporal_graph: TemporalGraph | None, delta: int, event_graph: 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. If None, `eg` must be provided.
+ temporal_graph: 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.
+ event_graph: 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.
"""
- 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()
+ assert temporal_graph is None or event_graph is None, "Only one of g or eg can be provided"
+ if temporal_graph is None:
+ assert event_graph is not None, "If g is None, eg must be provided"
+ edge_index = event_graph.data.edge_index
+ temporal_graph = event_graph.to_temporal_graph()
else:
# generate temporal event DAG
- edge_index = EventGraph.build_edge_index(g, delta)
+ edge_index = EventGraph.build_edge_index(temporal_graph, delta)
# Add indices of first-order nodes as src and dst of paths in augmented
# temporal event DAG
- src_edges_src = g.data.edge_index[0] + g.m
- src_edges_dst = torch.arange(0, g.data.edge_index.size(1), device=g.data.edge_index.device)
+ src_edges_src = temporal_graph.data.edge_index[0] + temporal_graph.m
+ src_edges_dst = torch.arange(0, temporal_graph.data.edge_index.size(1), device=temporal_graph.data.edge_index.device)
- dst_edges_src = torch.arange(0, g.data.edge_index.size(1), device=g.data.edge_index.device)
- dst_edges_dst = g.data.edge_index[1] + g.m + g.n
+ dst_edges_src = torch.arange(0, temporal_graph.data.edge_index.size(1), device=temporal_graph.data.edge_index.device)
+ dst_edges_dst = temporal_graph.data.edge_index[1] + temporal_graph.m + temporal_graph.n
# add edges from source to edges and from edges to destinations
src_edges = torch.stack([src_edges_src, src_edges_dst])
@@ -50,25 +50,25 @@ def temporal_shortest_paths(g: TemporalGraph | None, delta: int, eg: EventGraph
edge_index = torch.cat([edge_index, src_edges, dst_edges], dim=1)
# create sparse scipy matrix
- event_graph = Graph.from_edge_index(edge_index, num_nodes=g.m + 2 * g.n)
+ event_graph = Graph.from_edge_index(edge_index, num_nodes=temporal_graph.m + 2 * temporal_graph.n)
m = event_graph.sparse_adj_matrix()
# print(f"Created temporal event DAG with {event_graph.n} nodes and {event_graph.m} edges")
# run disjktra for all source nodes
dist, pred = dijkstra(
- m, directed=True, indices=np.arange(g.m, g.m + g.n), return_predecessors=True, unweighted=True
+ m, directed=True, indices=np.arange(temporal_graph.m, temporal_graph.m + temporal_graph.n), return_predecessors=True, unweighted=True
)
# limit to first-order destinations and correct distances
- dist_fo = dist[:, g.m + g.n :] - 1
+ dist_fo = dist[:, temporal_graph.m + temporal_graph.n:] - 1
np.fill_diagonal(dist_fo, 0)
# limit to first-order destinations and correct predecessors
- pred_fo = pred[:, g.n + g.m :]
+ pred_fo = pred[:, temporal_graph.n + temporal_graph.m:]
pred_fo[pred_fo == -9999] = -1
- idx_map = np.concatenate([to_numpy(g.data.edge_index[0].cpu()), [-1]])
+ idx_map = np.concatenate([to_numpy(temporal_graph.data.edge_index[0].cpu()), [-1]])
pred_fo = idx_map[pred_fo]
- np.fill_diagonal(pred_fo, np.arange(g.n))
+ np.fill_diagonal(pred_fo, np.arange(temporal_graph.n))
return dist_fo, pred_fo
diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py
index e979eb3b..c4a08aa2 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
+from typing import Tuple, Union
import numpy as np
import torch
@@ -43,21 +43,21 @@ def __init__(
self._temporal_graph: TemporalGraph | None = None
@staticmethod
- def build_edge_index(g: TemporalGraph, delta: float | int = 1):
+ def build_edge_index(temporal_graph: 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`.
+ Each temporal edge of `temporal_graph` 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.
+ temporal_graph: 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
+ edge_index, timestamps = temporal_graph.data.edge_index, temporal_graph.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)
@@ -86,16 +86,16 @@ def build_edge_index(g: TemporalGraph, delta: float | int = 1):
return ho_index
@classmethod
- def from_temporal_graph(cls, g: TemporalGraph, delta: int = 1) -> "EventGraph":
+ def from_temporal_graph(cls, temporal_graph: TemporalGraph, delta: int = 1) -> "EventGraph":
"""Build an EventGraph from a temporal graph by lifting its edges into events."""
- 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]
+ ho_index = cls.build_edge_index(temporal_graph, delta)
+ m = temporal_graph.data.time.size(0) # number of events (== number of first-order edges)
+ node_sequence = temporal_graph.data.edge_index.as_tensor().t().contiguous() # [m, 2]
+ node_time = temporal_graph.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}"
+ f"{temporal_graph.mapping.to_id(u)}->{temporal_graph.mapping.to_id(v)}@{t}"
for (u, v), t in zip(node_sequence.tolist(), node_time.tolist())
]
mapping = IndexMap(event_ids)
@@ -106,18 +106,22 @@ def from_temporal_graph(cls, g: TemporalGraph, delta: int = 1) -> "EventGraph":
node_sequence=node_sequence,
node_time=node_time,
)
- eg = cls(data, delta=delta, first_order_mapping=g.mapping, n_first_order=g.n, mapping=mapping)
+ event_graph = cls(data, delta=delta, first_order_mapping=temporal_graph.mapping, n_first_order=temporal_graph.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)
-
- return eg
-
- def __str__(self) -> str:
- """Return a human-readable summary listing the delta and all events."""
- return (
- f"EventGraph (delta={self.delta})\n" +
- "\n".join(f"{self.mapping.to_id(i)}" for i in range(self.n))
+ event_graph._temporal_graph = TemporalGraph(temporal_graph.data.clone(), mapping=temporal_graph.mapping)
+
+ return event_graph
+
+ def _summary(self) -> str:
+ """Return a one-line summary of the event graph."""
+ return "Event Graph (delta={0}) with {1} first-order nodes, {2} events and {3} edges in [{4}, {5}]".format(
+ self.delta,
+ self.n_first_order,
+ self.num_events,
+ self.m,
+ self.start_time,
+ self.end_time,
)
def __len__(self):
@@ -162,6 +166,16 @@ def num_events(self) -> int:
"""Number of events (nodes) in the event graph."""
return self.n
+ @property
+ def start_time(self) -> Union[int, float]:
+ """Return the timestamp of the first event in the event graph."""
+ return self.data.node_time.min().item()
+
+ @property
+ def end_time(self) -> Union[int, float]:
+ """Return the timestamp of the last event in the event graph."""
+ return self.data.node_time.max().item()
+
def event_time(self, i: int) -> int:
"""Return the timestamp of the i-th event."""
return self.data.node_time[i].item()
@@ -180,7 +194,7 @@ 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)
+ return temporal_shortest_paths(temporal_graph=None, delta=self.delta, event_graph=self)
def reduce_delta(self, decrement: int = 1) -> "EventGraph":
"""Return a new EventGraph with a reduced time window `delta - decrement`."""
diff --git a/src/pathpyG/core/graph.py b/src/pathpyG/core/graph.py
index 52d15470..84a29ca0 100644
--- a/src/pathpyG/core/graph.py
+++ b/src/pathpyG/core/graph.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from pprint import pformat
from typing import (
Any,
Dict,
@@ -785,8 +786,14 @@ def __add__(self, other: Graph, reduce: str = "sum") -> Graph:
raise ValueError("Node attribute " + k + " is not a tensor and cannot be reduced.")
return Graph(d, mapping=mapping)
- def __str__(self) -> str:
- """Return a string representation of the graph."""
+ def _summary(self) -> str:
+ """Return a one-line summary of the graph, to be overridden by subclasses."""
+ if self.is_undirected():
+ return "Undirected graph with {0} nodes and {1} edges".format(self.n, self.m)
+ return "Directed graph with {0} nodes and {1} edges".format(self.n, self.m)
+
+ def _attribute_summary(self) -> str:
+ """Return a pretty-printed summary of node-, edge- and graph-level attributes."""
attr = self.data.to_dict()
attr_types = {}
for k in attr:
@@ -796,13 +803,6 @@ def __str__(self) -> str:
else:
attr_types[k] = str(t)
- from pprint import pformat
-
- if self.is_undirected():
- s = "Undirected graph with {0} nodes and {1} edges\n".format(self.n, self.m)
- else:
- s = "Directed graph with {0} nodes and {1} edges\n".format(self.n, self.m)
-
attribute_info: dict[str, dict[str, str]] = {
"Node Attributes": {},
"Edge Attributes": {},
@@ -815,5 +815,8 @@ def __str__(self) -> str:
for a in self.data.keys():
if not self.data.is_node_attr(a) and not self.data.is_edge_attr(a):
attribute_info["Graph Attributes"][a] = attr_types[a]
- s += pformat(attribute_info, indent=4, width=160)
- return s
+ return pformat(attribute_info, indent=4, width=160)
+
+ def __str__(self) -> str:
+ """Return a string representation of the graph."""
+ return self._summary() + "\n" + self._attribute_summary()
diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py
index f7739d27..542156ac 100644
--- a/src/pathpyG/core/multi_order_model.py
+++ b/src/pathpyG/core/multi_order_model.py
@@ -206,7 +206,7 @@ def from_event_graph(
cached: Whether to also keep the aggregated layers below `max_order`.
Returns:
- MultiOrderModel2: A multi-order model equivalent to
+ MultiOrderModel: A multi-order model equivalent to
`MultiOrderModel.from_temporal_graph(eg.to_temporal_graph(), delta=eg.delta, ...)`.
"""
m = cls()
diff --git a/src/pathpyG/core/temporal_graph.py b/src/pathpyG/core/temporal_graph.py
index 588d82ac..2c09ad45 100644
--- a/src/pathpyG/core/temporal_graph.py
+++ b/src/pathpyG/core/temporal_graph.py
@@ -365,38 +365,12 @@ def __getitem__(self, key: Union[tuple, str]) -> Any:
else:
raise KeyError(key[0] + " is not a node or edge attribute")
- def __str__(self) -> str:
- """Return a string representation of the graph."""
- s = "Temporal Graph with {0} nodes, {1} unique edges and {2} events in [{3}, {4}]\n".format(
+ def _summary(self) -> str:
+ """Return a one-line summary of the temporal graph."""
+ return "Temporal Graph with {0} nodes, {1} unique edges and {2} events in [{3}, {4}]".format(
self.data.num_nodes,
self.data.edge_index.unique(dim=1).size(dim=1),
self.data.edge_index.size(1),
self.start_time,
self.end_time,
)
-
- attr = self.data.to_dict()
- attr_types = {}
- for k in attr:
- t = type(attr[k])
- if t == torch.Tensor:
- attr_types[k] = str(t) + " -> " + str(attr[k].size())
- else:
- attr_types[k] = str(t)
-
- from pprint import pformat
-
- attribute_info: dict[str, dict[str, Any]] = {
- "Node Attributes": {},
- "Edge Attributes": {},
- "Graph Attributes": {},
- }
- for a in self.node_attrs():
- attribute_info["Node Attributes"][a] = attr_types[a]
- for a in self.edge_attrs():
- attribute_info["Edge Attributes"][a] = attr_types[a]
- for a in self.data.keys():
- if not self.data.is_node_attr(a) and not self.data.is_edge_attr(a):
- attribute_info["Graph Attributes"][a] = attr_types[a]
- s += pformat(attribute_info, indent=4, width=160)
- return s
diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py
index 1f294099..b8cbaf51 100644
--- a/tests/core/test_event_graph.py
+++ b/tests/core/test_event_graph.py
@@ -66,11 +66,13 @@ def test_basic(event_graph):
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"
+ """The string representation summarizes counts, time range, delta and attributes."""
+ s = str(event_graph)
+ assert s.startswith(
+ "Event Graph (delta=2) with 5 first-order nodes, 4 events and 2 edges in [1, 5]\n"
)
+ assert "'node_time'" in s
+ assert "'edge_delta'" in s
def test_node_time(event_graph):
From f388bfaf5195c6fae25db4e43ca92fde9688d9a5 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Mon, 27 Jul 2026 14:18:58 -0400
Subject: [PATCH 12/25] variable renaming to get around mypy failures
---
src/pathpyG/algorithms/temporal.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/pathpyG/algorithms/temporal.py b/src/pathpyG/algorithms/temporal.py
index 6d332fe7..d0ab2c1c 100644
--- a/src/pathpyG/algorithms/temporal.py
+++ b/src/pathpyG/algorithms/temporal.py
@@ -50,10 +50,10 @@ def temporal_shortest_paths(temporal_graph: TemporalGraph | None, delta: int, ev
edge_index = torch.cat([edge_index, src_edges, dst_edges], dim=1)
# create sparse scipy matrix
- event_graph = Graph.from_edge_index(edge_index, num_nodes=temporal_graph.m + 2 * temporal_graph.n)
- m = event_graph.sparse_adj_matrix()
+ augmented_dag = Graph.from_edge_index(edge_index, num_nodes=temporal_graph.m + 2 * temporal_graph.n)
+ m = augmented_dag.sparse_adj_matrix()
- # print(f"Created temporal event DAG with {event_graph.n} nodes and {event_graph.m} edges")
+ # print(f"Created temporal event DAG with {augmented_dag.n} nodes and {augmented_dag.m} edges")
# run disjktra for all source nodes
dist, pred = dijkstra(
From 6ac7d448a634402fa76cf30d18c67cd85356ec9e Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Mon, 27 Jul 2026 15:55:04 -0400
Subject: [PATCH 13/25] Add failing test for edge attributes on a pre-sorted
edge index
---
tests/core/test_graph.py | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/tests/core/test_graph.py b/tests/core/test_graph.py
index 2ae1c060..03fc0f04 100644
--- a/tests/core/test_graph.py
+++ b/tests/core/test_graph.py
@@ -35,6 +35,17 @@ def test_init_with_edge_index():
assert isinstance(g.edge_to_index, dict)
+@pytest.mark.xfail(reason="edge attributes are indexed with a None permutation", strict=True)
+def test_init_with_presorted_edge_index_keeps_edge_attrs():
+ # An EdgeIndex that already carries sort_order="row" is returned by sort_by without a
+ # permutation, in which case the edge attributes must be left as they are.
+ edge_index = EdgeIndex([[0, 0, 1], [1, 2, 2]], sparse_size=(3, 3), sort_order="row")
+ edge_weight = torch.tensor([1.0, 2.0, 3.0])
+ g = Graph(Data(edge_index=edge_index, num_nodes=3, edge_weight=edge_weight))
+ assert g.data.edge_weight.shape == edge_weight.shape
+ assert torch.equal(g.data.edge_weight, edge_weight)
+
+
def test_init_with_mapping():
edge_index = get_random_edge_index(100, 100, 1000)
data = Data(edge_index=edge_index, num_nodes=100)
From 54463e13482d79e1d892b14bfd2f2b0ec747f3b8 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Mon, 27 Jul 2026 15:56:49 -0400
Subject: [PATCH 14/25] Keep edge attributes intact for an already-sorted edge
index
---
src/pathpyG/core/graph.py | 6 ++++--
tests/core/test_graph.py | 1 -
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/pathpyG/core/graph.py b/src/pathpyG/core/graph.py
index 84a29ca0..42864d04 100644
--- a/src/pathpyG/core/graph.py
+++ b/src/pathpyG/core/graph.py
@@ -117,8 +117,10 @@ def __init__(self, data: Data, mapping: Optional[IndexMap] = None):
# sort EdgeIndex and validate
data.edge_index, sorted_idx = data.edge_index.sort_by("row")
- for edge_attr in self.edge_attrs():
- data[edge_attr] = self.data[edge_attr][sorted_idx]
+ # an edge index that is already sorted is returned without a permutation
+ if sorted_idx is not None:
+ for edge_attr in self.edge_attrs():
+ data[edge_attr] = self.data[edge_attr][sorted_idx]
data.edge_index.validate()
diff --git a/tests/core/test_graph.py b/tests/core/test_graph.py
index 03fc0f04..897b4e0b 100644
--- a/tests/core/test_graph.py
+++ b/tests/core/test_graph.py
@@ -35,7 +35,6 @@ def test_init_with_edge_index():
assert isinstance(g.edge_to_index, dict)
-@pytest.mark.xfail(reason="edge attributes are indexed with a None permutation", strict=True)
def test_init_with_presorted_edge_index_keeps_edge_attrs():
# An EdgeIndex that already carries sort_order="row" is returned by sort_by without a
# permutation, in which case the edge attributes must be left as they are.
From 8beed83858d16ee71db3a83bf15988de03f980aa Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Mon, 27 Jul 2026 16:03:34 -0400
Subject: [PATCH 15/25] Promoting edge attributes in TemporalGraph to node
attributes in EventGraph.
---
src/pathpyG/core/event_graph.py | 77 +++++++++++++++++++++++++++++++--
tests/core/test_event_graph.py | 58 +++++++++++++++++++++++++
2 files changed, 132 insertions(+), 3 deletions(-)
diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py
index c4a08aa2..7a903516 100644
--- a/src/pathpyG/core/event_graph.py
+++ b/src/pathpyG/core/event_graph.py
@@ -1,7 +1,8 @@
"""Event graph representation of a temporal graph and related operations."""
from __future__ import annotations
-from typing import Tuple, Union
+import logging
+from typing import Any, Tuple, Union
import numpy as np
import torch
@@ -12,10 +13,25 @@
from pathpyG.core.index_map import IndexMap
from pathpyG.core.temporal_graph import TemporalGraph
+logger = logging.getLogger("root")
+
+
+def _copy_attr(value: Any) -> Any:
+ """Return an independent copy of an attribute value, leaving immutable values as they are."""
+ if isinstance(value, torch.Tensor):
+ return value.clone()
+ if isinstance(value, np.ndarray):
+ return value.copy()
+ return value
+
class EventGraph(Graph):
"""A directed acyclic graph whose nodes are time-stamped events."""
+ # Attributes that are constructed explicitly when lifting a temporal graph and that
+ # must therefore not be overwritten by propagated attributes.
+ _RESERVED_ATTRS = frozenset({"edge_index", "num_nodes", "node_sequence", "node_time"})
+
def __init__(
self,
data: Data,
@@ -85,9 +101,49 @@ def build_edge_index(temporal_graph: TemporalGraph, delta: float | int = 1):
ho_index = torch.cat(second_order, dim=0).t().contiguous()
return ho_index
+ @staticmethod
+ def lift_attrs(temporal_graph: TemporalGraph) -> dict[str, Any]:
+ """Map the attributes of a temporal graph to attributes of the lifted event graph.
+
+ Since every temporal edge becomes an event, edge attributes of the temporal graph
+ become node attributes of the event graph and are renamed from `edge_x` to `node_x`
+ accordingly. Node attributes refer to first-order nodes, which have no counterpart
+ among the events, so they are dropped. Graph attributes are kept unchanged.
+
+ Attributes that are constructed explicitly while lifting (`edge_index`, `num_nodes`,
+ `node_sequence` and the timestamps in `time`) are excluded.
+
+ Args:
+ temporal_graph: Temporal graph whose attributes shall be lifted.
+
+ Returns:
+ dict: mapping from attribute names in the event graph to their values.
+ """
+ attrs: dict[str, Any] = {}
+ for key in temporal_graph.data.keys():
+ if key in EventGraph._RESERVED_ATTRS or key == "time":
+ continue
+ if key.startswith("node_"):
+ # attributes of first-order nodes have no counterpart in the event graph
+ continue
+ if key.startswith("edge_"):
+ event_key = "node_" + key[len("edge_") :]
+ if event_key in EventGraph._RESERVED_ATTRS:
+ logger.error("Edge attribute %s cannot be lifted to reserved attribute %s", key, event_key)
+ raise ValueError(f"edge attribute '{key}' would be lifted to reserved attribute '{event_key}'")
+ attrs[event_key] = _copy_attr(temporal_graph.data[key])
+ else:
+ # graph-level attributes are kept as they are
+ attrs[key] = _copy_attr(temporal_graph.data[key])
+ return attrs
+
@classmethod
def from_temporal_graph(cls, temporal_graph: TemporalGraph, delta: int = 1) -> "EventGraph":
- """Build an EventGraph from a temporal graph by lifting its edges into events."""
+ """Build an EventGraph from a temporal graph by lifting its edges into events.
+
+ Attributes of the temporal graph are propagated to the event graph as described in
+ [`lift_attrs`][pathpyG.core.event_graph.EventGraph.lift_attrs].
+ """
ho_index = cls.build_edge_index(temporal_graph, delta)
m = temporal_graph.data.time.size(0) # number of events (== number of first-order edges)
node_sequence = temporal_graph.data.edge_index.as_tensor().t().contiguous() # [m, 2]
@@ -106,6 +162,9 @@ def from_temporal_graph(cls, temporal_graph: TemporalGraph, delta: int = 1) -> "
node_sequence=node_sequence,
node_time=node_time,
)
+ for key, value in cls.lift_attrs(temporal_graph).items():
+ data[key] = value
+
event_graph = cls(data, delta=delta, first_order_mapping=temporal_graph.mapping, n_first_order=temporal_graph.n, mapping=mapping)
# Attach a clone of the temporal graph since we already have it
@@ -197,7 +256,11 @@ def shortest_paths(self) -> Tuple[np.ndarray, np.ndarray]:
return temporal_shortest_paths(temporal_graph=None, delta=self.delta, event_graph=self)
def reduce_delta(self, decrement: int = 1) -> "EventGraph":
- """Return a new EventGraph with a reduced time window `delta - decrement`."""
+ """Return a new EventGraph with a reduced time window `delta - decrement`.
+
+ The events are unchanged, so node and graph attributes are carried over as they are,
+ while edge attributes are restricted to the edges that remain for the smaller `delta`.
+ """
new_delta = self.delta - decrement
if new_delta < 0:
raise ValueError(
@@ -215,6 +278,14 @@ def reduce_delta(self, decrement: int = 1) -> "EventGraph":
node_sequence=self.data.node_sequence.clone(),
node_time=self.data.node_time.clone(),
)
+ for key in self.data.keys():
+ if key in self._RESERVED_ATTRS:
+ continue
+ if key.startswith("edge_"):
+ data[key] = self.data[key][mask]
+ else:
+ data[key] = _copy_attr(self.data[key])
+
return EventGraph(
data,
delta=new_delta,
diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py
index b8cbaf51..2cf70693 100644
--- a/tests/core/test_event_graph.py
+++ b/tests/core/test_event_graph.py
@@ -247,6 +247,64 @@ def test_reduce_delta(temporal_graph, event_graph):
assert eg_delta2.edge_delta_map() == event_graph.edge_delta_map()
+@pytest.fixture
+def attributed_temporal_graph(temporal_graph) -> TemporalGraph:
+ """Temporal graph carrying one edge-, node- and graph-level attribute."""
+ # edges are sorted by time, i.e. (a,b)@1, (b,c)@2, (c,e)@3, (b,d)@5
+ temporal_graph.data.edge_weight = torch.tensor([10.0, 20.0, 30.0, 40.0])
+ temporal_graph.data.edge_temperature = np.array([4, 3, 2, 1]) # arbitrary edge-level attribute
+ temporal_graph.data.node_color = torch.arange(5)
+ temporal_graph.data.dataset_name = "toy"
+ return temporal_graph
+
+
+def test_lift_attrs(attributed_temporal_graph):
+ """Edge attributes become node attributes, node attributes are dropped, graph attributes stay."""
+ attrs = EventGraph.lift_attrs(attributed_temporal_graph)
+
+ assert set(attrs) == {"node_temperature", "node_weight", "dataset_name"}
+ assert torch.equal(attrs["node_weight"], torch.tensor([10.0, 20.0, 30.0, 40.0]))
+ np.testing.assert_array_equal(attrs["node_temperature"], np.array([4, 3, 2, 1]))
+ assert attrs["dataset_name"] == "toy"
+
+
+def test_lift_attrs_copies(attributed_temporal_graph):
+ """Lifted ndarray/tensor attributes are copies, so the event graph does not alias the temporal graph."""
+ attrs = EventGraph.lift_attrs(attributed_temporal_graph)
+ attrs["node_weight"][0] = -1.0
+ attrs["node_temperature"][0] = -1.0
+
+ assert attributed_temporal_graph.data.edge_weight[0].item() == 10.0
+ assert attributed_temporal_graph.data.edge_temperature[0].item() == 4
+
+
+def test_from_temporal_graph_propagates_attrs(attributed_temporal_graph):
+ """Building an event graph propagates the attributes of the temporal graph."""
+ eg = EventGraph.from_temporal_graph(attributed_temporal_graph, delta=DELTA)
+
+ assert torch.equal(eg.data.node_weight, torch.tensor([10.0, 20.0, 30.0, 40.0]))
+ assert eg.data.dataset_name == "toy"
+ assert "node_color" not in eg.data
+ # the lifted attribute is recognized as a node attribute of the event graph
+ assert "node_weight" in eg.node_attrs()
+ assert "node_temperature" in eg.node_attrs()
+ assert "node_weight" not in eg.edge_attrs()
+
+
+def test_reduce_delta_keeps_attrs(attributed_temporal_graph):
+ """Reducing delta keeps node and graph attributes and masks edge attributes."""
+ eg = EventGraph.from_temporal_graph(attributed_temporal_graph, delta=DELTA)
+ eg.data.edge_label = torch.arange(eg.m)
+ kept = eg.data.edge_delta <= 1
+
+ reduced = eg.reduce_delta(1)
+
+ assert torch.equal(reduced.data.node_weight, eg.data.node_weight)
+ assert reduced.data.dataset_name == "toy"
+ assert torch.equal(reduced.data.edge_label, eg.data.edge_label[kept])
+ assert reduced.data.edge_label.size(0) == reduced.m
+
+
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)
From 920a2fb730fcf7f6b7898e742b5904cab88c152d Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Wed, 12 Aug 2026 14:53:04 -0400
Subject: [PATCH 16/25] not implementing a custom getitem on EventGraph, and
testing on default behavior
---
src/pathpyG/core/event_graph.py | 7 -------
tests/core/test_event_graph.py | 18 ++++++++++--------
2 files changed, 10 insertions(+), 15 deletions(-)
diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py
index 7a903516..40815c15 100644
--- a/src/pathpyG/core/event_graph.py
+++ b/src/pathpyG/core/event_graph.py
@@ -187,13 +187,6 @@ 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):
- 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":
"""Move the event graph and its underlying temporal graph to the given device."""
super().to(device)
diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py
index 2cf70693..94dbf523 100644
--- a/tests/core/test_event_graph.py
+++ b/tests/core/test_event_graph.py
@@ -106,14 +106,6 @@ def test_event_time(event_graph, ):
assert [event_graph.event_time(i) for i in range(event_graph.num_events)] == [1, 2, 3, 5]
-def test_getitem(event_graph):
- """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)
- assert event_graph[3] == ("b", "d", 5)
-
-
def test_event_labels(event_graph):
"""Events are labeled with as "u->v@t"."""
assert event_graph.nodes == ["a->b@1", "b->c@2", "c->e@3", "b->d@5"]
@@ -291,6 +283,16 @@ def test_from_temporal_graph_propagates_attrs(attributed_temporal_graph):
assert "node_weight" not in eg.edge_attrs()
+def test_from_temporal_graph_getitem(attributed_temporal_graph):
+ """Indexing an EventGraph works as expected."""
+ eg = EventGraph.from_temporal_graph(attributed_temporal_graph, delta=DELTA)
+
+ assert eg["dataset_name"] == "toy"
+ assert eg["node_weight"][0].item() == 10.0
+ assert eg["node_temperature"][0].item() == 4
+ assert eg[("node_temperature", "b->c@2")] == 3
+
+
def test_reduce_delta_keeps_attrs(attributed_temporal_graph):
"""Reducing delta keeps node and graph attributes and masks edge attributes."""
eg = EventGraph.from_temporal_graph(attributed_temporal_graph, delta=DELTA)
From 4c774a3ab24c79f2d1d87925cd76b7c4ff031f98 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Wed, 12 Aug 2026 16:25:31 -0400
Subject: [PATCH 17/25] MultiOrderModel now holds HigherOrderGraph in its
layers
---
src/pathpyG/__init__.py | 2 +
src/pathpyG/core/multi_order_model.py | 69 +++++++++++++++++++--------
2 files changed, 50 insertions(+), 21 deletions(-)
diff --git a/src/pathpyG/__init__.py b/src/pathpyG/__init__.py
index 4c365455..c4c23c9a 100644
--- a/src/pathpyG/__init__.py
+++ b/src/pathpyG/__init__.py
@@ -8,6 +8,7 @@
__version__ = get_version("pathpyG")
from pathpyG.core.graph import Graph
+from pathpyG.core.higher_order_graph import HigherOrderGraph
from pathpyG.core.index_map import IndexMap
from pathpyG.core.multi_order_model import MultiOrderModel
from pathpyG.core.path_data import PathData
@@ -21,6 +22,7 @@
__all__ = [
"Graph",
+ "HigherOrderGraph",
"TemporalGraph",
"EventGraph",
"PathData",
diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py
index 542156ac..88d068f7 100644
--- a/src/pathpyG/core/multi_order_model.py
+++ b/src/pathpyG/core/multi_order_model.py
@@ -17,11 +17,10 @@
lift_order_edge_index_weighted,
)
from pathpyG.core.event_graph import EventGraph
-from pathpyG.core.graph import Graph
+from pathpyG.core.higher_order_graph import HigherOrderGraph
from pathpyG.core.index_map import IndexMap
from pathpyG.core.path_data import PathData
from pathpyG.core.temporal_graph import TemporalGraph
-from pathpyG.utils.dbgnn import generate_bipartite_edge_index
logger = logging.getLogger("root")
@@ -31,13 +30,17 @@ class MultiOrderModel:
This class stores multiple higher-order De Bruijn graphs as layers in a dictionary.
Each layer corresponds to a De Bruijn graph of order k, where k is the key in the dictionary.
- Each graph layer is represented as a [pathpyG.Graph][] object.
+ Each graph layer is represented as a
+ [HigherOrderGraph][pathpyG.core.higher_order_graph.HigherOrderGraph] object, layer 1
+ included. Each layer therefore knows its own order and the first-order nodes it was
+ built from, so results can be projected back onto entities without the caller keeping
+ track of it.
This class provides methods to search for the optimal order of the model based on likelihood ratio tests,
as well as methods to compute the log-likelihood of observed paths given the model.
Attributes:
- layers (dict[int, Graph]): A dictionary mapping the order k to the corresponding
- higher-order De Bruijn graph of order k.
+ layers (dict[int, HigherOrderGraph]): A dictionary mapping the order k to the
+ corresponding higher-order De Bruijn graph of order k.
Examples:
Example where the optimal order is 1:
@@ -56,11 +59,15 @@ class MultiOrderModel:
>>> m = MultiOrderModel.from_path_data(paths, max_order=2)
>>> print(m.estimate_order(paths, max_order=2))
2
+
+ Each layer knows the first-order path that each of its nodes represents:
+ >>> print(m.layers[2].order, m.layers[2].nodes)
+ 2 [('a', 'c'), ('b', 'c'), ('c', 'd'), ('c', 'e')]
"""
def __init__(self) -> None:
"""Initialize an empty MultiOrderModel."""
- self.layers: dict[int, Graph] = {}
+ self.layers: dict[int, HigherOrderGraph] = {}
def __str__(self) -> str:
"""Return a string representation of the higher-order graph."""
@@ -88,7 +95,8 @@ def iterate_lift_order(
edge_weight: torch.Tensor | None = None,
aggr: str = "src",
save: bool = True,
- ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None, Graph | None]:
+ n_first_order: Optional[int] = None,
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None, HigherOrderGraph | None]:
"""Lift order by one and save the result in the layers dictionary of the object.
This is a helper function that should not be called directly.
@@ -103,6 +111,8 @@ def iterate_lift_order(
k: The order of the graph that should be computed.
aggr: The aggregation method to use. One of "src", "dst", "max", "mul".
save: Whether to compute the aggregated graph and later save it in the layers dictionary.
+ n_first_order: The number of first-order nodes the node sequences refer to.
+ Defaults to the number of IDs in `mapping`.
"""
# Lift order
if edge_weight is None:
@@ -115,8 +125,13 @@ def iterate_lift_order(
# Aggregate
if save:
- gk = aggregate_edge_index(ho_index, node_sequence, edge_weight)
- gk.mapping = IndexMap([tuple(mapping.to_ids(v.cpu())) for v in gk.data.node_sequence])
+ gk = HigherOrderGraph.from_aggregated(
+ ho_index,
+ node_sequence,
+ first_order_mapping=mapping,
+ edge_weight=edge_weight,
+ n_first_order=n_first_order,
+ )
else:
gk = None
return ho_index, node_sequence, edge_weight, gk
@@ -156,10 +171,13 @@ def from_temporal_graph(
else:
edge_weight = torch.ones(edge_index.size(1), device=edge_index.device)
if cached or max_order == 1:
- m.layers[1] = aggregate_edge_index(
- edge_index=edge_index, node_sequence=node_sequence, edge_weight=edge_weight
+ m.layers[1] = HigherOrderGraph.from_aggregated(
+ edge_index=edge_index,
+ node_sequence=node_sequence,
+ edge_weight=edge_weight,
+ first_order_mapping=g.mapping,
+ n_first_order=g.n,
)
- m.layers[1].mapping = g.mapping
if max_order > 1:
node_sequence = torch.cat([node_sequence[edge_index[0]], node_sequence[edge_index[1]][:, -1:]], dim=1)
@@ -171,11 +189,12 @@ def from_temporal_graph(
# Aggregate
if cached or max_order == 2:
- m.layers[2] = aggregate_edge_index(
- edge_index=edge_index, node_sequence=node_sequence, edge_weight=edge_weight
- )
- m.layers[2].mapping = IndexMap(
- [tuple(g.mapping.to_ids(v.cpu())) for v in m.layers[2].data.node_sequence]
+ m.layers[2] = HigherOrderGraph.from_aggregated(
+ edge_index=edge_index,
+ node_sequence=node_sequence,
+ edge_weight=edge_weight,
+ first_order_mapping=g.mapping,
+ n_first_order=g.n,
)
for k in range(3, max_order + 1):
@@ -186,6 +205,7 @@ def from_temporal_graph(
edge_weight=edge_weight,
aggr="src",
save=cached or k == max_order,
+ n_first_order=g.n,
)
if cached or k == max_order:
m.layers[k] = gk # type: ignore[assignment]
@@ -251,17 +271,24 @@ def from_path_data(
elif mode == "propagation":
aggr = "src"
- m.layers[1] = aggregate_edge_index(edge_index=edge_index, node_sequence=node_sequence, edge_weight=edge_weight)
- m.layers[1].mapping = path_data.mapping
+ g1 = aggregate_edge_index(edge_index=edge_index, node_sequence=node_sequence, edge_weight=edge_weight)
+ g1.mapping = path_data.mapping
+ # Nodes that are not traversed by any path are not part of the aggregated graph,
+ # so the first-order node set can be larger than the order-1 layer.
+ n_first_order = max(path_data.mapping.num_ids(), g1.n)
+ m.layers[1] = HigherOrderGraph.from_aggregated_graph(
+ g1, first_order_mapping=path_data.mapping, n_first_order=n_first_order
+ )
for k in range(2, max_order + 1):
edge_index, node_sequence, edge_weight, gk = MultiOrderModel.iterate_lift_order(
edge_index=edge_index,
node_sequence=node_sequence,
- mapping=m.layers[1].mapping,
+ mapping=path_data.mapping,
edge_weight=edge_weight,
aggr=aggr,
save=cached or k == max_order,
+ n_first_order=n_first_order,
)
if cached or k == max_order:
m.layers[k] = gk # type: ignore[assignment]
@@ -563,7 +590,7 @@ def to_dbgnn_data(self, max_order: int = 2, mapping: str = "last") -> Data:
edge_index_max_order = g_max_order.data.edge_index
edge_weight = g.data.edge_weight
edge_weight_max_order = g_max_order.data.edge_weight
- bipartite_edge_index = generate_bipartite_edge_index(g, g_max_order, mapping=mapping, device=edge_index.device)
+ bipartite_edge_index = g_max_order.bipartite_edge_index(g, mapping=mapping, device=edge_index.device)
if g.data.y is not None:
y = g.data.y
From 59bfa8b695f9adc587ce180810ff96077297fc1d Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Wed, 12 Aug 2026 16:59:02 -0400
Subject: [PATCH 18/25] added missing HigherOrderGraph class
---
src/pathpyG/core/higher_order_graph.py | 521 +++++++++++++++++++++++++
1 file changed, 521 insertions(+)
create mode 100644 src/pathpyG/core/higher_order_graph.py
diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py
new file mode 100644
index 00000000..56b7bebc
--- /dev/null
+++ b/src/pathpyG/core/higher_order_graph.py
@@ -0,0 +1,521 @@
+"""Higher-order De Bruijn graph representation and related operations."""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional, Union
+
+import torch
+from torch_geometric import EdgeIndex
+from torch_geometric.data import Data
+from torch_geometric.utils import coalesce
+
+from pathpyG.algorithms.lift_order import aggregate_edge_index, lift_order_edge_index_weighted
+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
+from pathpyG.core.temporal_graph import TemporalGraph
+
+logger = logging.getLogger("root")
+
+
+class HigherOrderGraph(Graph):
+ """A De Bruijn graph of order `k`, whose nodes are paths of `k` first-order nodes.
+
+ Where a [`Graph`][pathpyG.Graph] has one node per entity and an
+ [`EventGraph`][pathpyG.core.event_graph.EventGraph] has one node per observed
+ interaction, a `HigherOrderGraph` has one node per *distinct* path of length `k`
+ in the underlying first-order graph. Repeated observations of the same path are
+ aggregated into an `edge_weight`, so timestamps are no longer represented: this
+ is a model of how paths flow rather than a record of what happened.
+
+ Order 1 is the degenerate case and is simply the weighted first-order graph, with
+ plain node IDs rather than tuples.
+
+ Info:
+ In addition to the attributes of [`Graph`][pathpyG.Graph], the `data` object holds:
+
+ - `node_sequence`: [Tensor][torch.Tensor] of shape `(num_nodes, order)`, the
+ first-order node indices making up the path each higher-order node represents.
+ - `edge_weight`: [Tensor][torch.Tensor] with the aggregated weight of each transition.
+ - `inverse_idx`: [Tensor][torch.Tensor] mapping each row of the *pre-aggregation*
+ node sequence to the index of the higher-order node it was merged into.
+
+ Attributes:
+ data (Data): PyG Data object containing edges and attributes.
+ mapping (IndexMap): Mapping from higher-order node IDs (tuples, for order > 1) to indices.
+ first_order_mapping (IndexMap): Mapping of the underlying first-order node IDs to indices.
+ n_first_order (int): Number of first-order nodes the higher-order nodes are built from.
+
+ Examples:
+ >>> import pathpyG as pp
+ >>> from pathpyG.core.higher_order_graph import HigherOrderGraph
+ >>> g = pp.Graph.from_edge_list([("a", "c"), ("c", "d")])
+ >>> h = HigherOrderGraph.from_graph(g)
+ >>> print(h.order, h.nodes)
+ 1 ['a', 'c', 'd']
+ """
+
+ def __init__(
+ self,
+ data: Data,
+ order: Optional[int] = None,
+ first_order_mapping: Optional[IndexMap] = None,
+ n_first_order: Optional[int] = None,
+ mapping: Optional[IndexMap] = None,
+ ) -> None:
+ """Create a HigherOrderGraph from a `Data` object carrying a `node_sequence`.
+
+ Args:
+ data: PyG `Data` object with an `edge_index` and a `node_sequence` of shape
+ `(num_nodes, order)`. For order 1, the `node_sequence` may be omitted and
+ is then taken to be the identity.
+ order: Expected order `k`. If given, it is validated against the width of the
+ node sequence; if omitted, the order is inferred from it.
+ first_order_mapping: Mapping of the underlying first-order node IDs. Defaults
+ to an empty mapping.
+ n_first_order: Number of first-order nodes. Defaults to the number of IDs in
+ `first_order_mapping`, or the largest index in the node sequence plus one.
+ mapping: Mapping of higher-order node IDs to indices. For order > 1 this must
+ use tuple IDs; for order 1 it must not.
+
+ Raises:
+ ValueError: If the order, the node sequence, and the mapping disagree, or if
+ the node sequence refers to first-order nodes that do not exist.
+ """
+ if "node_sequence" not in data and order not in (None, 1):
+ raise ValueError(f"A HigherOrderGraph of order {order} requires a `node_sequence` node attribute.")
+
+ if isinstance(data.edge_index, EdgeIndex):
+ # `Graph.__init__` re-sorts the edge index and reindexes every edge attribute by
+ # the returned permutation - but `EdgeIndex.sort_by` returns `None` for an index
+ # already known to be sorted, and `attr[None]` would add a dimension. Higher-order
+ # graphs are routinely built from already-aggregated (hence sorted) data, so hand
+ # the base class a plain tensor and let it derive a real permutation.
+ data.edge_index = data.edge_index.as_tensor()
+
+ super().__init__(data, mapping=mapping)
+
+ # `Graph` creates an identity node sequence if none is given, so `self.order`
+ # (inherited: the width of the node sequence) is now well-defined.
+ if order is not None and order != self.order:
+ raise ValueError(f"order={order} does not match node sequence of width {self.order}")
+
+ if first_order_mapping is not None:
+ self.first_order_mapping = first_order_mapping
+ elif self.order == 1:
+ # For order 1 the higher-order nodes *are* the first-order nodes.
+ self.first_order_mapping = self.mapping
+ else:
+ self.first_order_mapping = IndexMap()
+
+ if n_first_order is not None:
+ self._n_first_order = int(n_first_order)
+ elif self.first_order_mapping.has_ids:
+ self._n_first_order = self.first_order_mapping.num_ids()
+ elif self.data.node_sequence.numel() > 0:
+ self._n_first_order = int(self.data.node_sequence.max().item()) + 1
+ else:
+ self._n_first_order = 0
+
+ self._validate()
+
+ def _validate(self) -> None:
+ """Check that order, node sequence, mapping and first-order node set agree."""
+ if self.data.node_sequence.numel() > 0:
+ max_idx = int(self.data.node_sequence.max().item())
+ if max_idx >= self._n_first_order:
+ raise ValueError(
+ f"node sequence refers to first-order node {max_idx}, "
+ f"but there are only {self._n_first_order} first-order nodes"
+ )
+
+ if self.mapping.has_ids:
+ # Higher-order nodes are paths and are identified by tuples; first-order
+ # nodes are entities and are identified by plain IDs.
+ if self.mapping.has_tuple_ids != (self.order > 1):
+ raise ValueError(
+ f"a mapping for a graph of order {self.order} must "
+ f"{'use' if self.order > 1 else 'not use'} tuple IDs"
+ )
+ if self.mapping.num_ids() != self.n:
+ logger.warning(
+ "mapping has %s IDs but graph has %s nodes", self.mapping.num_ids(), self.n
+ )
+
+ @staticmethod
+ def _validate_order(order: int) -> None:
+ """Reject orders for which no De Bruijn graph is defined."""
+ if order < 1:
+ logger.error("order must be at least 1, got %s", order)
+ raise ValueError(f"order must be at least 1, got {order}")
+
+ @staticmethod
+ def _build_mapping(node_sequence: torch.Tensor, first_order_mapping: IndexMap) -> IndexMap:
+ """Build the higher-order `IndexMap` naming each node by the path it represents."""
+ # TODO: Is it better to have a single HigherOrderMapping class?
+ order = node_sequence.size(1)
+ if node_sequence.size(0) == 0:
+ # An order beyond the longest observed path yields a graph without nodes,
+ # and `IndexMap` cannot be built from an empty list of IDs.
+ return IndexMap()
+ if order == 1:
+ # Order-1 node indices are first-order node indices, so the mapping carries over.
+ return first_order_mapping
+ if first_order_mapping.has_ids:
+ return IndexMap([tuple(first_order_mapping.to_ids(v.cpu())) for v in node_sequence])
+ return IndexMap([tuple(v.tolist()) for v in node_sequence])
+
+ @classmethod
+ def from_aggregated(
+ cls,
+ edge_index: torch.Tensor,
+ node_sequence: torch.Tensor,
+ first_order_mapping: Optional[IndexMap] = None,
+ edge_weight: Optional[torch.Tensor] = None,
+ n_first_order: Optional[int] = None,
+ aggr: str = "sum",
+ ) -> HigherOrderGraph:
+ """Aggregate a (possibly duplicated) higher-order edge index into a De Bruijn graph.
+
+ This is the single place where higher-order nodes get their identity: duplicate
+ node sequences are merged, edge weights are aggregated, and the higher-order
+ `IndexMap` naming each node by its path is built.
+
+ Args:
+ edge_index: Edge index whose nodes are indices into `node_sequence`.
+ node_sequence: Tensor of shape `(num_nodes, order)` with the first-order path
+ each (not yet aggregated) node represents.
+ first_order_mapping: Mapping of the underlying first-order node IDs.
+ edge_weight: Weight of each edge prior to aggregation. Defaults to ones.
+ n_first_order: Number of first-order nodes, including isolated ones.
+ aggr: Reduction used for the edge weights. One of "sum", "mean", "min", "max".
+
+ Returns:
+ HigherOrderGraph: The aggregated higher-order graph.
+ """
+ if isinstance(edge_index, torch.Tensor) and hasattr(edge_index, "as_tensor"):
+ edge_index = edge_index.as_tensor()
+
+ order = node_sequence.size(1)
+ if first_order_mapping is None:
+ first_order_mapping = IndexMap()
+ if n_first_order is None:
+ if first_order_mapping.has_ids:
+ n_first_order = first_order_mapping.num_ids()
+ else:
+ n_first_order = int(node_sequence.max().item()) + 1 if node_sequence.numel() > 0 else 0
+
+ data = aggregate_edge_index(edge_index, node_sequence, edge_weight, aggr=aggr).data
+
+ if order == 1 and n_first_order > data.num_nodes:
+ # Order-1 indices are first-order indices, so first-order nodes that are not
+ # traversed by any path are simply isolated nodes of the order-1 graph.
+ data.num_nodes = n_first_order
+ data.node_sequence = torch.arange(n_first_order, device=edge_index.device).unsqueeze(1)
+
+ return cls(
+ data,
+ order=order,
+ first_order_mapping=first_order_mapping,
+ n_first_order=n_first_order,
+ mapping=cls._build_mapping(data.node_sequence, first_order_mapping),
+ )
+
+ @classmethod
+ def from_aggregated_graph(
+ cls,
+ g: Graph,
+ first_order_mapping: Optional[IndexMap] = None,
+ n_first_order: Optional[int] = None,
+ ) -> HigherOrderGraph:
+ """Adopt an already-aggregated [`Graph`][pathpyG.Graph] as a higher-order graph.
+
+ Used to give the layers computed by a multi-order model their proper type. The
+ underlying `data` object is shared, not copied.
+
+ Args:
+ g: Aggregated graph carrying a `node_sequence` of shape `(num_nodes, order)`.
+ first_order_mapping: Mapping of the underlying first-order node IDs.
+ n_first_order: Number of first-order nodes.
+
+ Returns:
+ HigherOrderGraph: The same graph, typed as a higher-order graph.
+ """
+ if isinstance(g, HigherOrderGraph):
+ return g
+ return cls(
+ g.data,
+ first_order_mapping=first_order_mapping,
+ n_first_order=n_first_order,
+ mapping=g.mapping,
+ )
+
+ @classmethod
+ def from_graph(cls, g: Graph, weight: str = "edge_weight") -> HigherOrderGraph:
+ """Create the order-1 graph corresponding to a first-order graph.
+
+ Multi-edges are coalesced into a single weighted edge.
+
+ Args:
+ g: First-order graph.
+ weight: Name of the edge attribute to use as edge weight. If absent, each
+ edge counts once.
+
+ Returns:
+ HigherOrderGraph: A higher-order graph of order 1.
+ """
+ edge_index = g.data.edge_index.as_tensor()
+ if weight in g.data:
+ edge_weight = g.data[weight]
+ else:
+ edge_weight = torch.ones(edge_index.size(1), device=edge_index.device)
+ node_sequence = torch.arange(g.n, device=edge_index.device).unsqueeze(1)
+
+ return cls.from_aggregated(
+ edge_index,
+ node_sequence,
+ first_order_mapping=g.mapping,
+ edge_weight=edge_weight,
+ n_first_order=g.n,
+ )
+
+ @classmethod
+ def from_temporal_graph(
+ cls,
+ g: TemporalGraph,
+ order: int = 1,
+ delta: float | int = 1,
+ weight: str = "edge_weight",
+ ) -> HigherOrderGraph:
+ """Create the De Bruijn graph of order `k` for time-respecting paths in a temporal graph.
+
+ Order 1 is simply the weighted static graph and ignores `delta`; for higher orders
+ the nodes are the time-respecting paths of `k` nodes, i.e. those whose consecutive
+ interactions are at most `delta` apart. Orders above 2 are reached by repeatedly
+ lifting the *unaggregated* data, so the edge weights count observed paths rather
+ than being implied by lower-order statistics (unlike [`lift`][pathpyG.HigherOrderGraph.lift]).
+
+ Args:
+ g: The temporal graph.
+ order: The order `k` of the graph to compute.
+ delta: The maximum time difference between two consecutive interactions of a path.
+ weight: The edge attribute of `g` to use as edge weight.
+
+ Returns:
+ HigherOrderGraph: A higher-order graph of order `order`. It has no nodes if
+ there is no time-respecting path of that length.
+
+ Examples:
+ >>> import pathpyG as pp
+ >>> t = pp.TemporalGraph.from_edge_list([("a", "c", 1), ("c", "d", 2)])
+ >>> print(pp.HigherOrderGraph.from_temporal_graph(t, order=2, delta=1).nodes)
+ [('a', 'c'), ('c', 'd')]
+ """
+ cls._validate_order(order)
+ # Imported here because `MultiOrderModel` builds `HigherOrderGraph` layers itself.
+ from pathpyG.core.multi_order_model import MultiOrderModel
+
+ return MultiOrderModel.from_temporal_graph(
+ g, delta=delta, max_order=order, weight=weight, cached=False
+ ).layers[order]
+
+ @classmethod
+ def from_path_data(cls, path_data: PathData, order: int = 1, mode: str = "propagation") -> HigherOrderGraph:
+ """Create the De Bruijn graph of order `k` modelling paths in [`PathData`][pathpyG.PathData].
+
+ Args:
+ path_data: The observed paths.
+ order: The order `k` of the graph to compute.
+ mode: The process that we assume. Either "diffusion" or "propagation".
+
+ Returns:
+ HigherOrderGraph: A higher-order graph of order `order`. It has no nodes if
+ no observed path is that long.
+
+ Examples:
+ >>> import pathpyG as pp
+ >>> paths = pp.PathData(pp.IndexMap(list("acd")))
+ >>> paths.append_walk(("a", "c", "d"), weight=2)
+ >>> print(pp.HigherOrderGraph.from_path_data(paths, order=2).nodes)
+ [('a', 'c'), ('c', 'd')]
+ """
+ cls._validate_order(order)
+ from pathpyG.core.multi_order_model import MultiOrderModel
+
+ return MultiOrderModel.from_path_data(path_data, max_order=order, mode=mode, cached=False).layers[order]
+
+ @classmethod
+ def from_event_graph(cls, eg: EventGraph, order: int = 2) -> HigherOrderGraph:
+ """Aggregate an [`EventGraph`][pathpyG.core.event_graph.EventGraph] into an order-`k` graph.
+
+ For the default order 2, every event whose underlying `(u, v)` pair is the same
+ collapses into a single second-order node, and repeated continuations become an
+ edge weight. Timestamps and the time window `delta` are not represented in the
+ result. Other orders are computed from the time-respecting paths that the event
+ graph encodes, which for orders above 2 means lifting its continuations further.
+
+ Args:
+ eg: The second-order temporal event graph to aggregate.
+ order: The order `k` of the graph to compute.
+
+ Returns:
+ HigherOrderGraph: A higher-order graph of order `order`. It has no nodes if
+ there is no time-respecting path of that length.
+ """
+ if order != 2:
+ cls._validate_order(order)
+ from pathpyG.core.multi_order_model import MultiOrderModel
+
+ return MultiOrderModel.from_event_graph(eg, max_order=order, cached=False).layers[order]
+
+ edge_index = eg.data.edge_index.as_tensor()
+ # Each continuation carries the weight of the event it starts from, matching the
+ # "src" aggregation used when building order-2 layers from a temporal graph.
+ edge_weight = torch.ones(edge_index.size(1), device=edge_index.device)
+
+ return cls.from_aggregated(
+ edge_index,
+ eg.data.node_sequence,
+ first_order_mapping=eg.first_order_mapping,
+ edge_weight=edge_weight,
+ n_first_order=eg.n_first_order,
+ )
+
+ def lift(self, aggr: str = "src") -> HigherOrderGraph:
+ """Return the De Bruijn graph of order `k + 1` obtained by lifting this graph.
+
+ Nodes of the result are the edges of this graph, i.e. the paths of length `k + 1`
+ that exist in this graph's topology.
+
+ Warning:
+ This lifts an *aggregated* graph, so the resulting edge weights are those
+ implied by the order-`k` statistics rather than counts of observed paths of
+ length `k + 1`. To fit a layer to observations, use
+ [`MultiOrderModel`][pathpyG.MultiOrderModel], which lifts the unaggregated data.
+
+ Args:
+ aggr: Aggregation used for the lifted edge weights. One of "src", "dst",
+ "max", "mul" or "add".
+
+ Returns:
+ HigherOrderGraph: A higher-order graph of order `k + 1`.
+ """
+ edge_index = self.data.edge_index.as_tensor()
+ if "edge_weight" in self.data:
+ edge_weight = self.data.edge_weight
+ else:
+ edge_weight = torch.ones(edge_index.size(1), device=edge_index.device)
+
+ ho_index, ho_weight = lift_order_edge_index_weighted(
+ edge_index, edge_weight=edge_weight, num_nodes=self.n, aggr=aggr
+ )
+ node_sequence = torch.cat(
+ [self.data.node_sequence[edge_index[0]], self.data.node_sequence[edge_index[1]][:, -1:]], dim=1
+ )
+
+ return HigherOrderGraph.from_aggregated(
+ ho_index,
+ node_sequence,
+ first_order_mapping=self.first_order_mapping,
+ edge_weight=ho_weight,
+ n_first_order=self.n_first_order,
+ )
+
+ def to_first_order(self, mode: str = "last") -> Graph:
+ """Project the higher-order graph back onto the first-order nodes.
+
+ Each higher-order node is replaced by one of the first-order nodes of its path,
+ and the weights of higher-order edges mapping to the same first-order edge are
+ summed. First-order nodes not traversed by any path remain as isolated nodes.
+
+ Args:
+ mode: Which first-order node of the path represents it. Either "last" or "first".
+
+ Returns:
+ Graph: A weighted first-order graph.
+ """
+ if mode == "last":
+ projection = self.data.node_sequence[:, -1]
+ elif mode == "first":
+ projection = self.data.node_sequence[:, 0]
+ else:
+ raise ValueError(f"Unknown mode {mode}. Only 'last' and 'first' are accepted.")
+
+ edge_index = projection[self.data.edge_index.as_tensor()]
+ if "edge_weight" in self.data:
+ edge_weight = self.data.edge_weight
+ else:
+ edge_weight = torch.ones(edge_index.size(1), device=edge_index.device)
+ edge_index, edge_weight = coalesce(
+ edge_index, edge_attr=edge_weight, num_nodes=self.n_first_order, reduce="sum"
+ )
+
+ return Graph(
+ Data(edge_index=edge_index, edge_weight=edge_weight, num_nodes=self.n_first_order),
+ mapping=self.first_order_mapping,
+ )
+
+ def bipartite_edge_index(
+ self,
+ first_order_graph: Optional[Graph] = None,
+ mapping: str = "last",
+ device: Optional[torch.device] = None,
+ ) -> torch.Tensor:
+ """Return the edge index connecting higher-order nodes to first-order nodes.
+
+ Used by the [DBGNN][pathpyG.nn.dbgnn.DBGNN] model to pass messages from
+ higher-order node representations to first-order ones. Unlike the free function
+ [`generate_bipartite_edge_index`][pathpyG.utils.dbgnn.generate_bipartite_edge_index],
+ this works for any order: "last" refers to the last node of the path, whatever
+ its length.
+
+ Args:
+ first_order_graph: The first-order graph. Optional; accepted so that call
+ sites read symmetrically, and used only for its device.
+ mapping: Which first-order nodes to connect to. One of "last", "first" or "both".
+ device: Device on which to create the tensor.
+
+ Returns:
+ torch.Tensor: Edge index of shape `(2, ยท)`, higher-order nodes in the first row.
+ """
+ if device is None:
+ device = first_order_graph.device if first_order_graph is not None else self.device
+
+ node_sequence = self.data.node_sequence
+ ho_idx = torch.arange(self.n, device=device)
+
+ if mapping == "last":
+ fo_idx = node_sequence[:, -1].to(device)
+ elif mapping == "first":
+ fo_idx = node_sequence[:, 0].to(device)
+ elif mapping == "both":
+ fo_idx = torch.cat([node_sequence[:, 0], node_sequence[:, -1]]).to(device)
+ ho_idx = torch.cat([ho_idx, ho_idx])
+ else:
+ raise ValueError(f"Unknown mapping {mapping}. Only 'last', 'first' and 'both' are accepted.")
+
+ return torch.stack([ho_idx, fo_idx])
+
+ @property
+ def n_first_order(self) -> int:
+ """Number of first-order nodes underlying the higher-order nodes."""
+ return self._n_first_order
+
+ def node_id(self, idx: int) -> Union[str, int, tuple]:
+ """Return the first-order path represented by the higher-order node `idx`."""
+ seq = self.data.node_sequence[idx]
+ if self.order == 1:
+ return self.first_order_mapping.to_id(int(seq[0].item()))
+ if self.first_order_mapping.has_ids:
+ return tuple(self.first_order_mapping.to_ids(seq.cpu()).tolist())
+ return tuple(seq.tolist())
+
+ def __str__(self) -> str:
+ """Return a human-readable summary of the higher-order graph."""
+ s = (
+ f"Higher-order graph of order {self.order} with {self.n} nodes and {self.m} edges\n"
+ f"(over {self.n_first_order} first-order nodes)\n"
+ )
+ return s + "\n".join(super().__str__().split("\n")[1:])
From 304fe5779e938c26cf93135b9670cd4670cf7636 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Thu, 13 Aug 2026 08:06:07 -0400
Subject: [PATCH 19/25] removed some uneeded checks (base constructor already
does these)
---
src/pathpyG/core/higher_order_graph.py | 12 ------------
1 file changed, 12 deletions(-)
diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py
index 56b7bebc..7d3b6a0f 100644
--- a/src/pathpyG/core/higher_order_graph.py
+++ b/src/pathpyG/core/higher_order_graph.py
@@ -6,7 +6,6 @@
from typing import Optional, Union
import torch
-from torch_geometric import EdgeIndex
from torch_geometric.data import Data
from torch_geometric.utils import coalesce
@@ -87,14 +86,6 @@ def __init__(
if "node_sequence" not in data and order not in (None, 1):
raise ValueError(f"A HigherOrderGraph of order {order} requires a `node_sequence` node attribute.")
- if isinstance(data.edge_index, EdgeIndex):
- # `Graph.__init__` re-sorts the edge index and reindexes every edge attribute by
- # the returned permutation - but `EdgeIndex.sort_by` returns `None` for an index
- # already known to be sorted, and `attr[None]` would add a dimension. Higher-order
- # graphs are routinely built from already-aggregated (hence sorted) data, so hand
- # the base class a plain tensor and let it derive a real permutation.
- data.edge_index = data.edge_index.as_tensor()
-
super().__init__(data, mapping=mapping)
# `Graph` creates an identity node sequence if none is given, so `self.order`
@@ -195,9 +186,6 @@ def from_aggregated(
Returns:
HigherOrderGraph: The aggregated higher-order graph.
"""
- if isinstance(edge_index, torch.Tensor) and hasattr(edge_index, "as_tensor"):
- edge_index = edge_index.as_tensor()
-
order = node_sequence.size(1)
if first_order_mapping is None:
first_order_mapping = IndexMap()
From b76281bf6a79cc768da8331e76f3b234c2ef41f8 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Thu, 13 Aug 2026 08:10:42 -0400
Subject: [PATCH 20/25] removed unused utils.dbgnn module
---
src/pathpyG/core/higher_order_graph.py | 6 ++--
src/pathpyG/utils/dbgnn.py | 46 --------------------------
tests/nn/test_dbgnn.py | 5 ++-
3 files changed, 4 insertions(+), 53 deletions(-)
delete mode 100644 src/pathpyG/utils/dbgnn.py
diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py
index 7d3b6a0f..a5301215 100644
--- a/src/pathpyG/core/higher_order_graph.py
+++ b/src/pathpyG/core/higher_order_graph.py
@@ -454,10 +454,8 @@ def bipartite_edge_index(
"""Return the edge index connecting higher-order nodes to first-order nodes.
Used by the [DBGNN][pathpyG.nn.dbgnn.DBGNN] model to pass messages from
- higher-order node representations to first-order ones. Unlike the free function
- [`generate_bipartite_edge_index`][pathpyG.utils.dbgnn.generate_bipartite_edge_index],
- this works for any order: "last" refers to the last node of the path, whatever
- its length.
+ higher-order node representations to first-order ones. This works for any order:
+ "last" refers to the last node of the path, whatever its length.
Args:
first_order_graph: The first-order graph. Optional; accepted so that call
diff --git a/src/pathpyG/utils/dbgnn.py b/src/pathpyG/utils/dbgnn.py
deleted file mode 100644
index a70ad8a7..00000000
--- a/src/pathpyG/utils/dbgnn.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""Utils for DBGNN models."""
-
-from typing import Optional
-
-import torch
-
-from pathpyG.core.graph import Graph
-
-
-def generate_bipartite_edge_index(
- g: Graph, g2: Graph, mapping: str = "last", device: Optional[torch.device] = None
-) -> torch.Tensor:
- """Generate edge_index for bipartite graph connecting nodes of a second-order graph to first-order nodes.
-
- The mapping strategy determines to which first-order nodes the second-order nodes are connected:
- - "last": Connects each second-order node to the last node in its sequence.
- - "first": Connects each second-order node to the first node in its sequence.
- - "both": Connects each second-order node to both the first and last nodes in its sequence.
-
- !!! warning "Only for Second-Order Graphs"
- This function is intended to be used with second-order graphs only.
- It does not support the use of higher-order graphs, such as third-order graphs or beyond.
-
- Args:
- g (Graph): The first-order graph.
- g2 (Graph): The second-order graph.
- mapping (str, optional): The mapping strategy to use. Options are "last", "first", or "both". Defaults to "last".
- device (torch.device, optional): The device to place the tensor on. Defaults to None.
-
- Returns:
- torch.Tensor: The edge_index tensor for the bipartite graph.
- """
- if mapping == "last":
- bipartide_edge_index = torch.tensor([list(range(g2.n)), [v[1] for v in g2.data.node_sequence]], device=device)
- elif mapping == "first":
- bipartide_edge_index = torch.tensor([list(range(g2.n)), [v[0] for v in g2.data.node_sequence]], device=device)
- else:
- bipartide_edge_index = torch.tensor(
- [
- list(range(g2.n)) + list(range(g2.n)),
- [v[0] for v in g2.data.node_sequence] + [v[1] for v in g2.data.node_sequence],
- ],
- device=device,
- )
-
- return bipartide_edge_index
diff --git a/tests/nn/test_dbgnn.py b/tests/nn/test_dbgnn.py
index 0de6d8a4..418f8084 100644
--- a/tests/nn/test_dbgnn.py
+++ b/tests/nn/test_dbgnn.py
@@ -5,7 +5,6 @@
from pathpyG.core.multi_order_model import MultiOrderModel
from pathpyG.nn.dbgnn import DBGNN
-from pathpyG.utils.dbgnn import generate_bipartite_edge_index
def test_bipartite_edge_index(simple_walks):
@@ -17,13 +16,13 @@ def test_bipartite_edge_index(simple_walks):
print(g2.data.edge_index)
print(g2.mapping)
- bipartite_edge_index = generate_bipartite_edge_index(g, g2, mapping="last")
+ bipartite_edge_index = g2.bipartite_edge_index(g, mapping="last")
print(bipartite_edge_index)
# ensure that A,C and B,C are mapped to C, C,D is mapped to D and C,E is mapped to E
assert equal(bipartite_edge_index, tensor([[0, 1, 2, 3], [2, 2, 3, 4]]))
- bipartite_edge_index = generate_bipartite_edge_index(g, g2, mapping="first")
+ bipartite_edge_index = g2.bipartite_edge_index(g, mapping="first")
print(bipartite_edge_index)
# ensure that A,C is mapped A, B,C is mapped to B, and C,D and C,E are mapped to C
From e2e4f712db863ac5d9b30b6003978461d2d614ef Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Thu, 13 Aug 2026 08:15:01 -0400
Subject: [PATCH 21/25] removed duplicated logic in HigherOrderGraph's order 2
branch; now delegating to MultiOrderModel
---
src/pathpyG/core/higher_order_graph.py | 33 +++++++++-----------------
tests/core/test_event_graph.py | 30 +++++++++++++++++++++++
2 files changed, 41 insertions(+), 22 deletions(-)
diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py
index a5301215..85ec9e51 100644
--- a/src/pathpyG/core/higher_order_graph.py
+++ b/src/pathpyG/core/higher_order_graph.py
@@ -338,11 +338,14 @@ def from_path_data(cls, path_data: PathData, order: int = 1, mode: str = "propag
def from_event_graph(cls, eg: EventGraph, order: int = 2) -> HigherOrderGraph:
"""Aggregate an [`EventGraph`][pathpyG.core.event_graph.EventGraph] into an order-`k` graph.
- For the default order 2, every event whose underlying `(u, v)` pair is the same
- collapses into a single second-order node, and repeated continuations become an
- edge weight. Timestamps and the time window `delta` are not represented in the
- result. Other orders are computed from the time-respecting paths that the event
- graph encodes, which for orders above 2 means lifting its continuations further.
+ The nodes are the time-respecting paths of `k` first-order nodes that the event
+ graph encodes: events sharing the same underlying path collapse into a single
+ higher-order node, and repeated continuations become an edge weight. Timestamps
+ and the time window `delta` are not represented in the result.
+
+ Equivalent to [`from_temporal_graph`][pathpyG.HigherOrderGraph.from_temporal_graph]
+ on the underlying temporal graph with the event graph's `delta`, but reuses the
+ already-computed continuations instead of lifting the temporal graph again.
Args:
eg: The second-order temporal event graph to aggregate.
@@ -352,24 +355,10 @@ def from_event_graph(cls, eg: EventGraph, order: int = 2) -> HigherOrderGraph:
HigherOrderGraph: A higher-order graph of order `order`. It has no nodes if
there is no time-respecting path of that length.
"""
- if order != 2:
- cls._validate_order(order)
- from pathpyG.core.multi_order_model import MultiOrderModel
-
- return MultiOrderModel.from_event_graph(eg, max_order=order, cached=False).layers[order]
-
- edge_index = eg.data.edge_index.as_tensor()
- # Each continuation carries the weight of the event it starts from, matching the
- # "src" aggregation used when building order-2 layers from a temporal graph.
- edge_weight = torch.ones(edge_index.size(1), device=edge_index.device)
+ cls._validate_order(order)
+ from pathpyG.core.multi_order_model import MultiOrderModel
- return cls.from_aggregated(
- edge_index,
- eg.data.node_sequence,
- first_order_mapping=eg.first_order_mapping,
- edge_weight=edge_weight,
- n_first_order=eg.n_first_order,
- )
+ return MultiOrderModel.from_event_graph(eg, max_order=order, cached=False).layers[order]
def lift(self, aggr: str = "src") -> HigherOrderGraph:
"""Return the De Bruijn graph of order `k + 1` obtained by lifting this graph.
diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py
index 94dbf523..039bc734 100644
--- a/tests/core/test_event_graph.py
+++ b/tests/core/test_event_graph.py
@@ -7,6 +7,7 @@
from torch_geometric.data import Data
from pathpyG.core.event_graph import EventGraph
+from pathpyG.core.higher_order_graph import HigherOrderGraph
from pathpyG.core.index_map import IndexMap
from pathpyG.core.multi_order_model import MultiOrderModel
from pathpyG.core.temporal_graph import TemporalGraph
@@ -218,6 +219,35 @@ def test_multi_order_model_construction(event_graph, temporal_graph):
)
+def test_higher_order_graph_from_weighted_event_graph(temporal_graph):
+ """Aggregating an EventGraph respects the edge weights of the temporal graph.
+
+ Regression test: order 2 used to count each continuation once instead of carrying
+ the weight of the event it starts from, so it disagreed with the temporal-graph
+ route for every order but 2.
+ """
+ temporal_graph.data.edge_weight = torch.tensor([2.0, 5.0, 11.0, 7.0])
+ event_graph = EventGraph.from_temporal_graph(temporal_graph, delta=DELTA)
+
+ for k in (1, 2, 3):
+ from_eg = HigherOrderGraph.from_event_graph(event_graph, order=k)
+ from_tg = MultiOrderModel.from_temporal_graph(temporal_graph, delta=DELTA, max_order=k).layers[k]
+
+ assert from_eg.order == k
+ assert from_eg.nodes == from_tg.nodes
+ assert torch.equal(
+ from_eg.data.edge_index.as_tensor(),
+ from_tg.data.edge_index.as_tensor(),
+ )
+ assert torch.equal(from_eg.data.edge_weight, from_tg.data.edge_weight)
+
+ # The weights must actually reflect the temporal graph, not just agree with each other.
+ order_2 = HigherOrderGraph.from_event_graph(event_graph, order=2)
+ assert order_2.nodes == [("a", "b"), ("b", "c"), ("b", "d"), ("c", "e")]
+ # (a,b)->(b,c) carries the weight of event (a->b)@1, (b,c)->(c,e) that of (b->c)@2
+ assert order_2.data.edge_weight.tolist() == [2.0, 5.0]
+
+
def test_to_device(event_graph):
"""Moving an EventGraph moves its underlying TemporalGraph too."""
moved = event_graph.to(torch.device("cpu"))
From 018b6831869ad18bbb651cd0e56e265cba11991f Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Thu, 13 Aug 2026 08:18:26 -0400
Subject: [PATCH 22/25] added two helper functions to algorithms to reduce code
duplication
---
src/pathpyG/algorithms/lift_order.py | 51 ++++++++++++++++++++++++++
src/pathpyG/core/higher_order_graph.py | 9 ++---
src/pathpyG/core/multi_order_model.py | 15 +++-----
3 files changed, 60 insertions(+), 15 deletions(-)
diff --git a/src/pathpyG/algorithms/lift_order.py b/src/pathpyG/algorithms/lift_order.py
index 1b2269b9..1d95e4b2 100644
--- a/src/pathpyG/algorithms/lift_order.py
+++ b/src/pathpyG/algorithms/lift_order.py
@@ -106,6 +106,57 @@ def lift_order_edge_index_weighted(
return ho_index, ho_edge_weight
+def lift_node_sequence(edge_index: torch.Tensor, node_sequence: torch.Tensor) -> torch.Tensor:
+ """Extend node sequences by one order along an edge index.
+
+ Each edge `(u, v)` of the (k-1)-th order graph becomes a node of the k-th order graph,
+ representing the path of `u` followed by the last first-order node of `v`.
+
+ Args:
+ edge_index: A **sorted** edge index tensor of shape (2, num_edges).
+ node_sequence: The node sequences of the (k-1)-th order graph, of shape (num_nodes, k-1).
+
+ Returns:
+ The node sequences of the k-th order graph, of shape (num_edges, k).
+ """
+ return torch.cat([node_sequence[edge_index[0]], node_sequence[edge_index[1]][:, -1:]], dim=1)
+
+
+def lift_order_step(
+ edge_index: torch.Tensor,
+ node_sequence: torch.Tensor,
+ edge_weight: torch.Tensor | None = None,
+ aggr: str = "src",
+) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]:
+ """Lift an edge index together with its node sequences by one order.
+
+ Combines the line-graph transformation of the edge index with the corresponding
+ extension of the node sequences, so that the result again describes a graph whose
+ nodes are paths of first-order nodes. The result is **not** aggregated: duplicate
+ node sequences are left for [`aggregate_edge_index`][pathpyG.algorithms.lift_order.aggregate_edge_index]
+ (or [`HigherOrderGraph.from_aggregated`][pathpyG.HigherOrderGraph.from_aggregated]) to merge.
+
+ Args:
+ edge_index: A **sorted** edge index tensor of shape (2, num_edges).
+ node_sequence: The node sequences of the (k-1)-th order graph.
+ edge_weight: The edge weights of the (k-1)-th order graph. If None, the lifted
+ graph is returned without weights.
+ aggr: The aggregation method for the edge weights. One of "src", "dst", "max",
+ "mul" or "add". Ignored if `edge_weight` is None.
+
+ Returns:
+ A tuple of the lifted edge index, the lifted node sequences and the aggregated
+ edge weights (None if `edge_weight` was None).
+ """
+ if edge_weight is None:
+ ho_index = lift_order_edge_index(edge_index, num_nodes=node_sequence.size(0))
+ else:
+ ho_index, edge_weight = lift_order_edge_index_weighted(
+ edge_index, edge_weight=edge_weight, num_nodes=node_sequence.size(0), aggr=aggr
+ )
+ return ho_index, lift_node_sequence(edge_index, node_sequence), edge_weight
+
+
def aggregate_edge_index(
edge_index: torch.Tensor, node_sequence: torch.Tensor, edge_weight: torch.Tensor | None = None, aggr: str = "sum"
) -> Graph:
diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py
index 85ec9e51..1403bd5a 100644
--- a/src/pathpyG/core/higher_order_graph.py
+++ b/src/pathpyG/core/higher_order_graph.py
@@ -9,7 +9,7 @@
from torch_geometric.data import Data
from torch_geometric.utils import coalesce
-from pathpyG.algorithms.lift_order import aggregate_edge_index, lift_order_edge_index_weighted
+from pathpyG.algorithms.lift_order import aggregate_edge_index, lift_order_step
from pathpyG.core.event_graph import EventGraph
from pathpyG.core.graph import Graph
from pathpyG.core.index_map import IndexMap
@@ -385,11 +385,8 @@ def lift(self, aggr: str = "src") -> HigherOrderGraph:
else:
edge_weight = torch.ones(edge_index.size(1), device=edge_index.device)
- ho_index, ho_weight = lift_order_edge_index_weighted(
- edge_index, edge_weight=edge_weight, num_nodes=self.n, aggr=aggr
- )
- node_sequence = torch.cat(
- [self.data.node_sequence[edge_index[0]], self.data.node_sequence[edge_index[1]][:, -1:]], dim=1
+ ho_index, node_sequence, ho_weight = lift_order_step(
+ edge_index, self.data.node_sequence, edge_weight=edge_weight, aggr=aggr
)
return HigherOrderGraph.from_aggregated(
diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py
index 88d068f7..1da9fd33 100644
--- a/src/pathpyG/core/multi_order_model.py
+++ b/src/pathpyG/core/multi_order_model.py
@@ -13,8 +13,9 @@
from pathpyG.algorithms.lift_order import (
aggregate_edge_index,
aggregate_node_attributes,
+ lift_node_sequence,
lift_order_edge_index,
- lift_order_edge_index_weighted,
+ lift_order_step,
)
from pathpyG.core.event_graph import EventGraph
from pathpyG.core.higher_order_graph import HigherOrderGraph
@@ -115,13 +116,9 @@ def iterate_lift_order(
Defaults to the number of IDs in `mapping`.
"""
# Lift order
- if edge_weight is None:
- ho_index = lift_order_edge_index(edge_index, num_nodes=node_sequence.size(0))
- else:
- ho_index, edge_weight = lift_order_edge_index_weighted(
- edge_index, edge_weight=edge_weight, num_nodes=node_sequence.size(0), aggr=aggr
- )
- node_sequence = torch.cat([node_sequence[edge_index[0]], node_sequence[edge_index[1]][:, -1:]], dim=1)
+ ho_index, node_sequence, edge_weight = lift_order_step(
+ edge_index, node_sequence, edge_weight=edge_weight, aggr=aggr
+ )
# Aggregate
if save:
@@ -180,7 +177,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)
+ node_sequence = lift_node_sequence(edge_index, node_sequence)
if event_graph is None:
edge_index = EventGraph.build_edge_index(g, delta)
else:
From 2768cfea808083161f04d8f05715a0921fbdd8bf Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Thu, 13 Aug 2026 09:51:17 -0400
Subject: [PATCH 23/25] removed unhelpful comment
---
src/pathpyG/core/higher_order_graph.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py
index 1403bd5a..7fb7fc08 100644
--- a/src/pathpyG/core/higher_order_graph.py
+++ b/src/pathpyG/core/higher_order_graph.py
@@ -302,7 +302,6 @@ def from_temporal_graph(
[('a', 'c'), ('c', 'd')]
"""
cls._validate_order(order)
- # Imported here because `MultiOrderModel` builds `HigherOrderGraph` layers itself.
from pathpyG.core.multi_order_model import MultiOrderModel
return MultiOrderModel.from_temporal_graph(
From cb7bd2febc02e0c00b4c6818a148a8392a533e42 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Thu, 13 Aug 2026 10:07:43 -0400
Subject: [PATCH 24/25] comments
---
src/pathpyG/core/higher_order_graph.py | 34 +++-----------------------
src/pathpyG/core/multi_order_model.py | 3 +--
2 files changed, 5 insertions(+), 32 deletions(-)
diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py
index 7fb7fc08..2cb79773 100644
--- a/src/pathpyG/core/higher_order_graph.py
+++ b/src/pathpyG/core/higher_order_graph.py
@@ -22,11 +22,9 @@
class HigherOrderGraph(Graph):
"""A De Bruijn graph of order `k`, whose nodes are paths of `k` first-order nodes.
- Where a [`Graph`][pathpyG.Graph] has one node per entity and an
- [`EventGraph`][pathpyG.core.event_graph.EventGraph] has one node per observed
- interaction, a `HigherOrderGraph` has one node per *distinct* path of length `k`
+ A `HigherOrderGraph` has one node per distinct path of length `k`
in the underlying first-order graph. Repeated observations of the same path are
- aggregated into an `edge_weight`, so timestamps are no longer represented: this
+ aggregated into an `edge_weight`. Timestamps are not represented: this
is a model of how paths flow rather than a record of what happened.
Order 1 is the degenerate case and is simply the weighted first-order graph, with
@@ -170,10 +168,6 @@ def from_aggregated(
) -> HigherOrderGraph:
"""Aggregate a (possibly duplicated) higher-order edge index into a De Bruijn graph.
- This is the single place where higher-order nodes get their identity: duplicate
- node sequences are merged, edge weights are aggregated, and the higher-order
- `IndexMap` naming each node by its path is built.
-
Args:
edge_index: Edge index whose nodes are indices into `node_sequence`.
node_sequence: Tensor of shape `(num_nodes, order)` with the first-order path
@@ -220,9 +214,6 @@ def from_aggregated_graph(
) -> HigherOrderGraph:
"""Adopt an already-aggregated [`Graph`][pathpyG.Graph] as a higher-order graph.
- Used to give the layers computed by a multi-order model their proper type. The
- underlying `data` object is shared, not copied.
-
Args:
g: Aggregated graph carrying a `node_sequence` of shape `(num_nodes, order)`.
first_order_mapping: Mapping of the underlying first-order node IDs.
@@ -282,8 +273,7 @@ def from_temporal_graph(
Order 1 is simply the weighted static graph and ignores `delta`; for higher orders
the nodes are the time-respecting paths of `k` nodes, i.e. those whose consecutive
interactions are at most `delta` apart. Orders above 2 are reached by repeatedly
- lifting the *unaggregated* data, so the edge weights count observed paths rather
- than being implied by lower-order statistics (unlike [`lift`][pathpyG.HigherOrderGraph.lift]).
+ lifting the unaggregated data.
Args:
g: The temporal graph.
@@ -337,14 +327,8 @@ def from_path_data(cls, path_data: PathData, order: int = 1, mode: str = "propag
def from_event_graph(cls, eg: EventGraph, order: int = 2) -> HigherOrderGraph:
"""Aggregate an [`EventGraph`][pathpyG.core.event_graph.EventGraph] into an order-`k` graph.
- The nodes are the time-respecting paths of `k` first-order nodes that the event
- graph encodes: events sharing the same underlying path collapse into a single
- higher-order node, and repeated continuations become an edge weight. Timestamps
- and the time window `delta` are not represented in the result.
-
Equivalent to [`from_temporal_graph`][pathpyG.HigherOrderGraph.from_temporal_graph]
- on the underlying temporal graph with the event graph's `delta`, but reuses the
- already-computed continuations instead of lifting the temporal graph again.
+ on the underlying temporal graph with the event graph's `delta`.
Args:
eg: The second-order temporal event graph to aggregate.
@@ -365,12 +349,6 @@ def lift(self, aggr: str = "src") -> HigherOrderGraph:
Nodes of the result are the edges of this graph, i.e. the paths of length `k + 1`
that exist in this graph's topology.
- Warning:
- This lifts an *aggregated* graph, so the resulting edge weights are those
- implied by the order-`k` statistics rather than counts of observed paths of
- length `k + 1`. To fit a layer to observations, use
- [`MultiOrderModel`][pathpyG.MultiOrderModel], which lifts the unaggregated data.
-
Args:
aggr: Aggregation used for the lifted edge weights. One of "src", "dst",
"max", "mul" or "add".
@@ -438,10 +416,6 @@ def bipartite_edge_index(
) -> torch.Tensor:
"""Return the edge index connecting higher-order nodes to first-order nodes.
- Used by the [DBGNN][pathpyG.nn.dbgnn.DBGNN] model to pass messages from
- higher-order node representations to first-order ones. This works for any order:
- "last" refers to the last node of the path, whatever its length.
-
Args:
first_order_graph: The first-order graph. Optional; accepted so that call
sites read symmetrically, and used only for its device.
diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py
index 1da9fd33..6f75f843 100644
--- a/src/pathpyG/core/multi_order_model.py
+++ b/src/pathpyG/core/multi_order_model.py
@@ -34,8 +34,7 @@ class MultiOrderModel:
Each graph layer is represented as a
[HigherOrderGraph][pathpyG.core.higher_order_graph.HigherOrderGraph] object, layer 1
included. Each layer therefore knows its own order and the first-order nodes it was
- built from, so results can be projected back onto entities without the caller keeping
- track of it.
+ built from.
This class provides methods to search for the optimal order of the model based on likelihood ratio tests,
as well as methods to compute the log-likelihood of observed paths given the model.
From 4112444d0ca6b0620ca2923c1e5f89d860d5bb07 Mon Sep 17 00:00:00 2001
From: Vineet Bansal
Date: Thu, 13 Aug 2026 10:30:03 -0400
Subject: [PATCH 25/25] explanatory note
---
src/pathpyG/core/higher_order_graph.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py
index 2cb79773..b856a0c2 100644
--- a/src/pathpyG/core/higher_order_graph.py
+++ b/src/pathpyG/core/higher_order_graph.py
@@ -285,6 +285,11 @@ def from_temporal_graph(
HigherOrderGraph: A higher-order graph of order `order`. It has no nodes if
there is no time-respecting path of that length.
+ Note:
+ Each call rebuilds the whole chain of lifts from order 1. To obtain several
+ orders, build a [`MultiOrderModel`][pathpyG.MultiOrderModel] with
+ `cached=True` once and read its `layers` instead.
+
Examples:
>>> import pathpyG as pp
>>> t = pp.TemporalGraph.from_edge_list([("a", "c", 1), ("c", "d", 2)])
@@ -311,6 +316,11 @@ def from_path_data(cls, path_data: PathData, order: int = 1, mode: str = "propag
HigherOrderGraph: A higher-order graph of order `order`. It has no nodes if
no observed path is that long.
+ Note:
+ Each call rebuilds the whole chain of lifts from order 1. To obtain several
+ orders, build a [`MultiOrderModel`][pathpyG.MultiOrderModel] with
+ `cached=True` once and read its `layers` instead.
+
Examples:
>>> import pathpyG as pp
>>> paths = pp.PathData(pp.IndexMap(list("acd")))
@@ -337,6 +347,11 @@ def from_event_graph(cls, eg: EventGraph, order: int = 2) -> HigherOrderGraph:
Returns:
HigherOrderGraph: A higher-order graph of order `order`. It has no nodes if
there is no time-respecting path of that length.
+
+ Note:
+ Each call rebuilds the whole chain of lifts from order 1. To obtain several
+ orders, build a [`MultiOrderModel`][pathpyG.MultiOrderModel] with
+ `cached=True` once and read its `layers` instead.
"""
cls._validate_order(order)
from pathpyG.core.multi_order_model import MultiOrderModel