From 4c774a3ab24c79f2d1d87925cd76b7c4ff031f98 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Wed, 12 Aug 2026 16:25:31 -0400 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 From 809b49581a0935088573fe7a1210dbf5b21d774a Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Wed, 2 Sep 2026 12:17:18 -0400 Subject: [PATCH 10/17] from_aggregated -> aggregate renaming --- src/pathpyG/core/higher_order_graph.py | 6 +++--- src/pathpyG/core/multi_order_model.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py index b856a0c2..997dcbe1 100644 --- a/src/pathpyG/core/higher_order_graph.py +++ b/src/pathpyG/core/higher_order_graph.py @@ -157,7 +157,7 @@ def _build_mapping(node_sequence: torch.Tensor, first_order_mapping: IndexMap) - return IndexMap([tuple(v.tolist()) for v in node_sequence]) @classmethod - def from_aggregated( + def aggregate( cls, edge_index: torch.Tensor, node_sequence: torch.Tensor, @@ -252,7 +252,7 @@ def from_graph(cls, g: Graph, weight: str = "edge_weight") -> HigherOrderGraph: 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( + return cls.aggregate( edge_index, node_sequence, first_order_mapping=g.mapping, @@ -381,7 +381,7 @@ def lift(self, aggr: str = "src") -> HigherOrderGraph: edge_index, self.data.node_sequence, edge_weight=edge_weight, aggr=aggr ) - return HigherOrderGraph.from_aggregated( + return HigherOrderGraph.aggregate( ho_index, node_sequence, first_order_mapping=self.first_order_mapping, diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py index 6f75f843..30d02a1d 100644 --- a/src/pathpyG/core/multi_order_model.py +++ b/src/pathpyG/core/multi_order_model.py @@ -121,7 +121,7 @@ def iterate_lift_order( # Aggregate if save: - gk = HigherOrderGraph.from_aggregated( + gk = HigherOrderGraph.aggregate( ho_index, node_sequence, first_order_mapping=mapping, @@ -167,7 +167,7 @@ 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] = HigherOrderGraph.from_aggregated( + m.layers[1] = HigherOrderGraph.aggregate( edge_index=edge_index, node_sequence=node_sequence, edge_weight=edge_weight, @@ -185,7 +185,7 @@ def from_temporal_graph( # Aggregate if cached or max_order == 2: - m.layers[2] = HigherOrderGraph.from_aggregated( + m.layers[2] = HigherOrderGraph.aggregate( edge_index=edge_index, node_sequence=node_sequence, edge_weight=edge_weight, From 8d9bd34d03ffd0135962254165ac9d429037935b Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Tue, 8 Sep 2026 12:51:56 -0400 Subject: [PATCH 11/17] Update src/pathpyG/core/higher_order_graph.py Co-authored-by: Moritz Lampert --- src/pathpyG/core/higher_order_graph.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py index 997dcbe1..378f708d 100644 --- a/src/pathpyG/core/higher_order_graph.py +++ b/src/pathpyG/core/higher_order_graph.py @@ -395,6 +395,15 @@ def to_first_order(self, mode: str = "last") -> Graph: 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. + + Warning: This is a projection, not an inverse transformation + This method does not reconstruct the original first-order graph from + which this higher-order graph was built. Instead, it maps each higher- + order node to either the first or last first-order node in its represented path. + + Consequently, the result preserves flow encoded by the higher-order model + under the selected projection, but may differ from the original graph in its + edge set. In particular, isolated first-order edges cannot be recovered. Args: mode: Which first-order node of the path represents it. Either "last" or "first". From 32044e082bc3830114d09135389af728105c277d Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Mon, 21 Sep 2026 09:46:02 -0400 Subject: [PATCH 12/17] removed lift method from HigherOrderGraph --- src/pathpyG/core/higher_order_graph.py | 33 +------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py index 378f708d..79ce903c 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_step +from pathpyG.algorithms.lift_order import aggregate_edge_index from pathpyG.core.event_graph import EventGraph from pathpyG.core.graph import Graph from pathpyG.core.index_map import IndexMap @@ -358,37 +358,6 @@ def from_event_graph(cls, eg: EventGraph, order: int = 2) -> HigherOrderGraph: 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. - - 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. - - 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, node_sequence, ho_weight = lift_order_step( - edge_index, self.data.node_sequence, edge_weight=edge_weight, aggr=aggr - ) - - return HigherOrderGraph.aggregate( - 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. From ddb53c5a18c4dc05fff6586300f398ef6aa1641a Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Mon, 21 Sep 2026 14:40:51 -0400 Subject: [PATCH 13/17] Graph is order 1 only; HigherOrderGraph supports orders >= 0 --- docs/tutorial/basic_concepts.ipynb | 4 +- docs/tutorial/implementation_concepts.ipynb | 30 ++- docs/tutorial/netzschleuder.ipynb | 2 +- docs/tutorial/paths_higher_order.ipynb | 10 +- docs/tutorial/trp_higher_order.ipynb | 4 +- src/pathpyG/algorithms/lift_order.py | 43 ++-- src/pathpyG/core/event_graph.py | 11 +- src/pathpyG/core/graph.py | 82 +++---- src/pathpyG/core/higher_order_graph.py | 240 +++++++++++++++++--- src/pathpyG/core/index_map.py | 8 +- src/pathpyG/core/multi_order_model.py | 237 +++++++++---------- src/pathpyG/core/temporal_graph.py | 5 - tests/algorithms/test_lift_order.py | 8 +- tests/core/test_graph.py | 2 +- tests/core/test_multi_order_model.py | 20 +- 15 files changed, 442 insertions(+), 264 deletions(-) diff --git a/docs/tutorial/basic_concepts.ipynb b/docs/tutorial/basic_concepts.ipynb index cef2f819..a4ab274a 100644 --- a/docs/tutorial/basic_concepts.ipynb +++ b/docs/tutorial/basic_concepts.ipynb @@ -154,7 +154,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Data(edge_index=[2, 3], num_nodes=4, node_sequence=[4, 1])\n" + "Data(edge_index=[2, 3], num_nodes=4)\n" ] } ], @@ -774,7 +774,7 @@ { "data": { "text/plain": [ - "Data(edge_index=[2, 3], num_nodes=3, node_sequence=[3, 1], edge_weight=[3])" + "Data(edge_index=[2, 3], num_nodes=3, edge_weight=[3])" ] }, "execution_count": 31, diff --git a/docs/tutorial/implementation_concepts.ipynb b/docs/tutorial/implementation_concepts.ipynb index ee224589..565358b7 100644 --- a/docs/tutorial/implementation_concepts.ipynb +++ b/docs/tutorial/implementation_concepts.ipynb @@ -34,7 +34,7 @@ "source": [ "## Motivation and Learning Objectives\n", "\n", - "The inner workings of the core classes of PathpyG are based on tensor operations provided by PyTorch and PyTorch Geometric. Especially the creation of higher-order structures using the lift-order functions and the `MultiOderModel` heavily rely on tensor operations for efficiency reasons. While these implementations are highly optimized, they are very hard to read and understand for newcomers. This tutorial aims to explain the general concepts and ideas behind these implementations in a more accessible way. Additionally, we will provide step-by-step explanations of the core functions in the following sections." + "The inner workings of the core classes of PathpyG are based on tensor operations provided by PyTorch and PyTorch Geometric. Especially the creation of higher-order structures using the lift-order functions and the `MultiOrderModel` heavily rely on tensor operations for efficiency reasons. While these implementations are highly optimized, they are very hard to read and understand for newcomers. This tutorial aims to explain the general concepts and ideas behind these implementations in a more accessible way. Additionally, we will provide step-by-step explanations of the core functions in the following sections." ] }, { @@ -1123,7 +1123,7 @@ "second_order_edge_index = pp.algorithms.lift_order.lift_order_edge_index(edge_index=graph.data.edge_index, num_nodes=graph.n)\n", "second_order_mapping = pp.IndexMap(graph.edges)\n", "second_order_data = Data(edge_index=second_order_edge_index, node_sequence=graph.data.edge_index.t())\n", - "line_graph = pp.Graph(data=second_order_data, mapping=second_order_mapping)\n", + "line_graph = pp.HigherOrderGraph(data=second_order_data, first_order_mapping=graph.mapping, mapping=second_order_mapping)\n", "pp.plot(line_graph);" ] }, @@ -1132,7 +1132,7 @@ "id": "944a71b6", "metadata": {}, "source": [ - "To create the higher-order `PathpyG.Graph`, we needed to specify a `node_sequence` in the `Data` object. The node sequence above was given by the original edges of the graph. This `node_sequence` keeps track of which original nodes correspond to which higher-order nodes in the higher-order graph. In a second order graph, each higher-order node corresponds to an edge in the original graph. In a graph of order k, each higher-order node corresponds to a path of length k in the original graph. With this, we can always trace back which higher-order node corresponds to which original nodes.\n", + "To create the higher-order graph, we used a `HigherOrderGraph` and specified a `node_sequence` in the `Data` object. A plain `pp.Graph` is always a first-order graph and does not accept a `node_sequence`. The node sequence above was given by the original edges of the graph. This `node_sequence` keeps track of which original nodes correspond to which higher-order nodes in the higher-order graph. In a second order graph, each higher-order node corresponds to an edge in the original graph. In a graph of order k, each higher-order node corresponds to a path of k nodes (i.e. of length k-1) in the original graph. With this, we can always trace back which higher-order node corresponds to which original nodes.\n", "\n", "As long as we have this mapping from higher-order nodes to original nodes, we can always do an additional line graph transformation to create even higher order graphs. Below, we create a third-order graph:" ] @@ -1662,7 +1662,7 @@ "third_order_edge_index = pp.algorithms.lift_order.lift_order_edge_index(edge_index=line_graph.data.edge_index, num_nodes=line_graph.n)\n", "third_order_data = Data(edge_index=third_order_edge_index, node_sequence=torch.cat([line_graph.data.node_sequence[line_graph.data.edge_index[0]], line_graph.data.node_sequence[line_graph.data.edge_index[1]][:, -1:]], dim=1))\n", "third_order_mapping = pp.IndexMap([tuple(seq) for seq in graph.mapping.to_ids(third_order_data.node_sequence).tolist()])\n", - "third_order_graph = pp.Graph(data=third_order_data, mapping=third_order_mapping)\n", + "third_order_graph = pp.HigherOrderGraph(data=third_order_data, first_order_mapping=graph.mapping, mapping=third_order_mapping)\n", "pp.plot(third_order_graph);" ] }, @@ -3223,8 +3223,8 @@ "source": [ "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", + "event_data = Data(edge_index=event_edge_index, node_sequence=t.data.edge_index.t(), node_time=t.data.time)\n", + "event_graph = pp.EventGraph(data=event_data, delta=2, first_order_mapping=t.mapping, n_first_order=t.n, mapping=event_mapping)\n", "pp.plot(event_graph);" ] }, @@ -3804,10 +3804,12 @@ "second_order_edge_index = pp.algorithms.lift_order.lift_order_edge_index(edge_index=sorted_edge_index, num_nodes=t.n)\n", "# Filter the edges based on the lifted edge index\n", "filtered_edge_index = filter_time_respecting_edges(second_order_edge_index, timestamps=time, delta=2)\n", - "# Create `pp.Graph` from the filtered edge index\n", + "# Create an `EventGraph` from the filtered edge index\n", "filtered_event_mapping = pp.IndexMap([tuple([*t.mapping.to_ids(edge).tolist(), timestamp.item()]) for edge, timestamp in zip(sorted_edge_index.t(), time)])\n", - "filtered_event_data = Data(edge_index=filtered_edge_index, node_sequence=sorted_edge_index.t())\n", - "filtered_event_graph = pp.Graph(data=filtered_event_data, mapping=filtered_event_mapping)\n", + "filtered_event_data = Data(edge_index=filtered_edge_index, node_sequence=sorted_edge_index.t(), node_time=time)\n", + "filtered_event_graph = pp.EventGraph(\n", + " data=filtered_event_data, delta=2, first_order_mapping=t.mapping, n_first_order=t.n, mapping=filtered_event_mapping\n", + ")\n", "pp.plot(filtered_event_graph);" ] }, @@ -5690,7 +5692,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 `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", + "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` uses a higher-order De Bruijn 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:" @@ -6300,7 +6302,7 @@ "metadata": {}, "outputs": [], "source": [ - "third_order_paths = pp.algorithms.lift_order.aggregate_edge_index(\n", + "third_order_data = pp.algorithms.lift_order.aggregate_edge_index(\n", " edge_index=third_order_edge_index, node_sequence=third_order_node_sequence\n", ")" ] @@ -6835,7 +6837,11 @@ } ], "source": [ - "third_order_paths.mapping = pp.IndexMap([tuple(mapping.to_ids(v).tolist()) for v in third_order_paths.data.node_sequence])\n", + "third_order_paths = pp.HigherOrderGraph(\n", + " third_order_data,\n", + " first_order_mapping=path_mapping,\n", + " mapping=pp.IndexMap([tuple(path_mapping.to_ids(v).tolist()) for v in third_order_data.node_sequence]),\n", + ")\n", "pp.plot(third_order_paths);" ] }, diff --git a/docs/tutorial/netzschleuder.ipynb b/docs/tutorial/netzschleuder.ipynb index 2cb17cf8..519b6e0b 100644 --- a/docs/tutorial/netzschleuder.ipynb +++ b/docs/tutorial/netzschleuder.ipynb @@ -2369,7 +2369,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Data(edge_index=[2, 156], num_nodes=34, node_sequence=[34, 1], node_name=[34], node_groups=[34], node__pos=[34], analyses_average_degree=4.588235294117647, analyses_degree_assortativity=-0.47561309768461424, analyses_degree_std_dev=3.820360677912828, analyses_diameter=5, analyses_edge_properties=[0], analyses_edge_reciprocity=1.0, analyses_global_clustering=0.2556818181818182, analyses_hashimoto_radius=5.292780644548693, analyses_is_bipartite=False, analyses_is_directed=False, analyses_knn_proj_1=3.6123615105719784, analyses_knn_proj_2=1.4566019942625823, analyses_largest_component_fraction=1.0, analyses_mixing_time=7.04834107126513, analyses_num_edges=78, analyses_num_vertices=34, analyses_transition_gap=0.8677276709836416, analyses_vertex_properties=[3])\n" + "Data(edge_index=[2, 156], num_nodes=34, node_name=[34], node_groups=[34], node__pos=[34], analyses_average_degree=4.588235294117647, analyses_degree_assortativity=-0.47561309768461424, analyses_degree_std_dev=3.820360677912828, analyses_diameter=5, analyses_edge_properties=[0], analyses_edge_reciprocity=1.0, analyses_global_clustering=0.2556818181818182, analyses_hashimoto_radius=5.292780644548693, analyses_is_bipartite=False, analyses_is_directed=False, analyses_knn_proj_1=3.6123615105719784, analyses_knn_proj_2=1.4566019942625823, analyses_largest_component_fraction=1.0, analyses_mixing_time=7.04834107126513, analyses_num_edges=78, analyses_num_vertices=34, analyses_transition_gap=0.8677276709836416, analyses_vertex_properties=[3])\n" ] } ], diff --git a/docs/tutorial/paths_higher_order.ipynb b/docs/tutorial/paths_higher_order.ipynb index 0b8960eb..c723d2e0 100644 --- a/docs/tutorial/paths_higher_order.ipynb +++ b/docs/tutorial/paths_higher_order.ipynb @@ -748,7 +748,7 @@ "source": [ "We can actually see a collection of walks as a higher-order generalization of the usual way to define graphs as a collection of dyadic edges (which are simply walks of length one). From this point of view, a standard static (weighted) graph is simply a first-order model of node sequences, which only considers the frequency at which edges are traversed. \n", "\n", - "To generate such a first-order model, we can use the class `MultiOderModel` and use the first-layer of the model, which is simply a weighted static graph where edge weights count the number of times each edge is traversed by a path. We will explain the class `MultiOrderModel`, which generalizes this concept to higher-order graph models for any order $k$ in a moment. For now, we can just use it to generate a first-order weighted graph as follows.\n", + "To generate such a first-order model, we can use the class `MultiOrderModel` and use the order-1 layer of the model (`m.layers[1]`), which is simply a weighted static graph where edge weights count the number of times each edge is traversed by a path. We will explain the class `MultiOrderModel`, which generalizes this concept to higher-order graph models for any order $k$ in a moment. For now, we can just use it to generate a first-order weighted graph as follows.\n", "\n", "The generated graph is again based on a `pyG.Data` object that contains an edge_index and edge weights. As we can see, for the example above the edge_index is just a concatenation of the edge indices of individual walks, where the node indices have been mapped to the correct nodes." ] @@ -1882,7 +1882,7 @@ "\n", "A De Bruijn graph of order $k=1$ is simply a normal (weighted) static graph consisting of nodes and edges. Pairs of nodes connected by edges overlap in $k-1=0$ nodes and capture paths of length $k=1$, i.e. simple dyadic edges in the underlying path data.\n", "\n", - "For a De Bruijn graph with order $k=2$, in our example above, an edge connects a pair of nodes $(a,b)$ and $(b,c)$ that overlaps in the $k-1=1$ node $b$. Such an edge represents the path $a -> b -> c$ of length two. We can use the `MultiOderModel` class to generate a second-order De Bruijn graph representation of the path data above. We just have to set the `max_order` parameter to two and use the second layer of the resulting `MultiOrderModel` instance." + "For a De Bruijn graph with order $k=2$, in our example above, an edge connects a pair of nodes $(a,b)$ and $(b,c)$ that overlaps in the $k-1=1$ node $b$. Such an edge represents the path $a -> b -> c$ of length two. We can use the `MultiOrderModel` class to generate a second-order De Bruijn graph representation of the path data above. We just have to set the `max_order` parameter to two and use the second layer of the resulting `MultiOrderModel` instance." ] }, { @@ -3016,7 +3016,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Note that all higher-order graphs are simply `Graph` objects, which means that we can iterate through the nodes of a higher-order graph just like for normal graphs. Node indices are automatically mapped, yielding tuples of first-order node identifiers." + "Note that higher-order graphs are `HigherOrderGraph` objects, a subclass of `Graph`, which means that we can iterate through the nodes of a higher-order graph just like for normal graphs. Node indices are automatically mapped, yielding tuples of first-order node identifiers." ] }, { @@ -3643,7 +3643,7 @@ "\n", "The fact that we can model the same set of paths with higher-order De Bruijn graph models with different orders $k$ raises an important question: What is the **optimal order** to model a given `PathData` instance. It is actually easy to answer this question in our example above. \n", "\n", - "For the data contained in `paths`, we observe only two of the four possible paths, which is different from what we would expect based on a first-order graph model. To capture this pattern in the node sequenves, a first-order graph model is not sufficient and we need a second-order De Bruoijn graph model.\n", + "For the data contained in `paths`, we observe only two of the four possible paths, which is different from what we would expect based on a first-order graph model. To capture this pattern in the node sequences, a first-order graph model is not sufficient and we need a second-order De Bruijn graph model.\n", "\n", "For `paths_2` this is different: Here we observe all four paths of length two with the same frequency, which is exactly what we would expect based on the first-order weighted graph, where all edge weights are the same. Hence, for `paths_2` the second-order De Bruijn graph model contains no additional information compared to a first-order weighted graph, which means a first-order model is sufficient. \n", "\n", @@ -3651,7 +3651,7 @@ "\n", "[I Scholtes: When is a Network a Network?: Multi-Order Graphical Model Selection in Pathways and Temporal Networks, In Proc. of SIGKDD 2017, August 2017](https://dl.acm.org/doi/10.1145/3097983.3098145)\n", "\n", - "The method introduced in this paper is implemented in `pathpyG`. To determine the optimal order of a higher-order De Bruijn graph model for the node sequences contained in a given `PathData` instance, we can use the `MultiOderModel.estimate_order` method. Since the method is based on statistical hypothesis testing, we can also pass a significance threshold, which - in line with the interpretation of p-values - bounds the type I error rate of our test, i.e. the rate at which we wrongly reject the null hypothesis that the true optimal order of a data set is $k-1$ in favor of the alternative hypothesis that the order is $k$.\n", + "The method introduced in this paper is implemented in `pathpyG`. To determine the optimal order of a higher-order De Bruijn graph model for the node sequences contained in a given `PathData` instance, we can use the `MultiOrderModel.estimate_order` method. Since the method is based on statistical hypothesis testing, we can also pass a significance threshold, which - in line with the interpretation of p-values - bounds the type I error rate of our test, i.e. the rate at which we wrongly reject the null hypothesis that the true optimal order of a data set is $k-1$ in favor of the alternative hypothesis that the order is $k$.\n", "\n", "Let us test this for our toy example. Using a significance threshold of $0.01$, we determine the optimal order for the data set `paths` that should actually warrant a second-order model:" ] diff --git a/docs/tutorial/trp_higher_order.ipynb b/docs/tutorial/trp_higher_order.ipynb index e2023eef..c92e6466 100644 --- a/docs/tutorial/trp_higher_order.ipynb +++ b/docs/tutorial/trp_higher_order.ipynb @@ -645,7 +645,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "As you can see, these time-respecting paths are actually very similar to the paths data that we have previously represented using the `PathData` object. In fact, we could - in theory - first extract all time-respecting paths of all lengths, add them to a `PathData` object and then use the `MultiOderModel` class to generate higher-order De Bruijn graph models of all orders. In the example above, since we have paths of length one to four, we could create higher-order models with orders from one to four. \n", + "As you can see, these time-respecting paths are actually very similar to the paths data that we have previously represented using the `PathData` object. In fact, we could - in theory - first extract all time-respecting paths of all lengths, add them to a `PathData` object and then use the `MultiOrderModel` class to generate higher-order De Bruijn graph models of all orders. In the example above, since we have paths of length one to four, we could create higher-order models with orders from one to four. \n", "\n", "However, this approach would not be efficient for large temporal graphs, as it is computationally expensive to calculate all possible time-respecting paths as well as subpaths of length $k$, especially for larger values of $\\delta$. To avoid this bottleneck, `pathpyG` uses a smarter, GPU-based algorithm to calculate time-respecting paths of length $k$ that are needed for a given order $k$.\n", "\n", @@ -2863,7 +2863,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Intuitively, since in our example there are no time-respecting paths longer than four, if we were to generate a multi-order model with De Bruijn graphs with orders larger than four, those graphs cannot contain any edges. We see this in the following example. The first-order graph is simply the time-aggregated weighted graph, i.e. the number of nodes is equal to the number of nodes in the temporal graph and the number of edges is equal to the number of different time-stamped edges. In each graph of order $k>1$, the number of nodes corresponds to the number of edges in the graph with order $k-1$, since each of those nodes corresponds to a time-respecting path of length $k-1$, which are represented by edges in a $k-1$-th order gaph. This implies that the graph with order five has two nodes, which are the two time-respecting paths of length four. Those nodes are not connected since there is no time-respecting path with length five." + "Intuitively, since in our example there are no time-respecting paths longer than four, if we were to generate a multi-order model with De Bruijn graphs with orders larger than four, those graphs cannot contain any edges. We see this in the following example. The first-order graph is simply the time-aggregated weighted graph, i.e. the number of nodes is equal to the number of nodes in the temporal graph and the number of edges is equal to the number of different time-stamped edges. In each graph of order $k>1$, the number of nodes corresponds to the number of edges in the graph with order $k-1$, since each of those nodes corresponds to a time-respecting path of length $k-1$, which are represented by edges in a $k-1$-th order graph. This implies that the graph with order five has two nodes, which are the two time-respecting paths of length four. Those nodes are not connected since there is no time-respecting path with length five." ] }, { diff --git a/src/pathpyG/algorithms/lift_order.py b/src/pathpyG/algorithms/lift_order.py index 1d95e4b2..7b2d164a 100644 --- a/src/pathpyG/algorithms/lift_order.py +++ b/src/pathpyG/algorithms/lift_order.py @@ -4,8 +4,6 @@ from torch_geometric.data import Data from torch_geometric.utils import coalesce, cumsum, degree -from pathpyG.core.graph import Graph - def aggregate_node_attributes( edge_index: torch.Tensor, node_attribute: torch.Tensor, aggr: str = "src" @@ -134,7 +132,7 @@ def lift_order_step( 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. + (or [`HigherOrderGraph.aggregate`][pathpyG.HigherOrderGraph.aggregate]) to merge. Args: edge_index: A **sorted** edge index tensor of shape (2, num_edges). @@ -159,10 +157,10 @@ def lift_order_step( def aggregate_edge_index( edge_index: torch.Tensor, node_sequence: torch.Tensor, edge_weight: torch.Tensor | None = None, aggr: str = "sum" -) -> Graph: +) -> Data: """Aggregate the possibly duplicated edges in the (higher-order) edge index. - Aggregate the possibly duplicated edges in the (higher-order) edge index and return a graph object + Aggregate the possibly duplicated edges in the (higher-order) edge index and return a `Data` object containing the (higher-order) edge index without duplicates and the node sequences. This method can be seen as a higher-order generalization of the `torch_geometric.utils.coalesce` method. @@ -171,33 +169,46 @@ def aggregate_edge_index( Args: edge_index: The edge index of a (higher-order) graph where each source and destination node corresponds to a node which is an edge in the (k-1)-th order graph. - node_sequence: The node sequences of first order nodes that each node in the edge index corresponds to. + node_sequence: The node sequences of first order nodes that each node in the edge index corresponds to, + of shape `(num_nodes, k)` with `k >= 1`. edge_weight: The edge weights corresponding to the edge index. aggr: The aggregation method to use for the edge weights. One of "sum", "mean", "min", "max". Returns: - A graph object containing the aggregated edge index, the node sequences, the edge weights and the inverse index. + A `Data` object containing the aggregated edge index, the node sequences, the edge weights and the + inverse index. For order 1, node indices are first-order node indices, so the result has one node per + first-order node up to the largest one that occurs, including those not traversed by any edge. + + Raises: + ValueError: If `node_sequence` has width 0. Edges of an order-0 graph cannot be told apart by their + endpoints and hence cannot be aggregated this way. """ + if node_sequence.size(1) == 0: + raise ValueError("cannot aggregate an order-0 node sequence by its endpoints") + if edge_weight is None: edge_weight = torch.ones(edge_index.size(1), device=edge_index.device) - unique_nodes, inverse_idx = torch.unique(node_sequence, dim=0, return_inverse=True) - # If first order, then the indices in the node sequence are the inverse idx we would need already if node_sequence.size(1) == 1: - mapped_edge_index = node_sequence.squeeze()[edge_index] + # For order 1 the node sequence holds first-order indices, which are already the aggregated + # node indices. They may skip first-order nodes that are never visited, so the aggregated + # graph must span every index up to the largest one rather than only the distinct values. + inverse_idx = node_sequence.squeeze(1) + num_nodes = int(inverse_idx.max()) + 1 if inverse_idx.numel() > 0 else 0 + unique_nodes = torch.arange(num_nodes, device=node_sequence.device).unsqueeze(1) else: - mapped_edge_index = inverse_idx[edge_index] + unique_nodes, inverse_idx = torch.unique(node_sequence, dim=0, return_inverse=True) + num_nodes = unique_nodes.size(0) aggregated_edge_index, edge_weight = coalesce( - mapped_edge_index, + inverse_idx[edge_index], edge_attr=edge_weight, - num_nodes=unique_nodes.size(0), + num_nodes=num_nodes, reduce=aggr, ) - data = Data( + return Data( edge_index=aggregated_edge_index, - num_nodes=unique_nodes.size(0), + num_nodes=num_nodes, node_sequence=unique_nodes, edge_weight=edge_weight, inverse_idx=inverse_idx, ) - return Graph(data) diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py index 40815c15..8a06e552 100644 --- a/src/pathpyG/core/event_graph.py +++ b/src/pathpyG/core/event_graph.py @@ -26,7 +26,15 @@ def _copy_attr(value: Any) -> Any: class EventGraph(Graph): - """A directed acyclic graph whose nodes are time-stamped events.""" + """A directed acyclic graph whose nodes are time-stamped events. + + Each event is a temporal edge of an underlying temporal graph. The `node_sequence` + node attribute of shape `(num_events, 2)` holds the source and target first-order + node of each event, and `node_time` holds its timestamp. + """ + + # The event graph manages the first-order edge of each event itself. + _internal_node_attrs: frozenset[str] = frozenset({"node_sequence"}) # Attributes that are constructed explicitly when lifting a temporal graph and that # must therefore not be overwritten by propagated attributes. @@ -190,6 +198,7 @@ def __len__(self): def to(self, device: torch.device) -> "EventGraph": """Move the event graph and its underlying temporal graph to the given device.""" super().to(device) + self.data.node_sequence = self.data.node_sequence.to(device) if self._temporal_graph is not None: self._temporal_graph.to(device) return self diff --git a/src/pathpyG/core/graph.py b/src/pathpyG/core/graph.py index 42864d04..0f7f22d4 100644 --- a/src/pathpyG/core/graph.py +++ b/src/pathpyG/core/graph.py @@ -49,15 +49,13 @@ class Graph: and edge attributes. Data on nodes and edges are stored in an underlying instance of [`torch_geometric.data.Data`][torch_geometric.data.Data]. + A `Graph` is always a first-order graph. Graphs whose nodes are paths of first-order + nodes are represented by [`HigherOrderGraph`][pathpyG.HigherOrderGraph]. + Info: The `data` attribute is a PyG Data object that contains the following attributes: - - - `edge_index`: [Edge index][torch_geometric.EdgeIndex] of the graph. Entries correspond to indices in the node sequence. - - `node_sequence`: Node sequence [tensor][torch.Tensor] of shape `(num_nodes, order)` where each entry - corresponds to the index of first-order nodes in the underlying graph and mapping. For first-order graphs, - the indices in the node sequence is identical to the indices in the edge index. For higher-order graphs, - the node sequence contains tuples of node indices representing higher-order nodes that correspond to paths in - the underlying first-order graph. + + - `edge_index`: [Edge index][torch_geometric.EdgeIndex] of the graph. Attributes: data (Data): PyG Data object containing edges and attributes. @@ -69,6 +67,10 @@ class Graph: row (torch.Tensor): CSC row indices for efficient predecessor retrieval. """ + # Node-level attributes that subclasses manage themselves; they are not reported + # by `node_attrs()` and are not accepted by a plain `Graph`. + _internal_node_attrs: frozenset[str] = frozenset() + def __init__(self, data: Data, mapping: Optional[IndexMap] = None): """Generate graph instance from a pyG `Data` object. @@ -91,7 +93,17 @@ def __init__(self, data: Data, mapping: Optional[IndexMap] = None): g = pp.Graph(data, mapping=pp.IndexMap(["a", "b", "c"])) ``` + + Raises: + ValueError: If `data` carries a `node_sequence`, i.e. describes a higher-order graph. """ + if "node_sequence" in data and "node_sequence" not in self._internal_node_attrs: + logger.error("A Graph is a first-order graph and cannot carry a node_sequence") + raise ValueError( + "a Graph is a first-order graph and cannot carry a `node_sequence`; " + "use HigherOrderGraph for graphs whose nodes are paths" + ) + if mapping is None: self.mapping = IndexMap() else: @@ -132,10 +144,6 @@ def __init__(self, data: Data, mapping: Optional[IndexMap] = None): ((self.row_ptr, self.col), _) = self.data.edge_index.get_csr() ((self.col_ptr, self.row), _) = self.data.edge_index.get_csc() - # create node_sequence mapping for higher-order graphs - if "node_sequence" not in self.data: - self.data.node_sequence = torch.arange(data.num_nodes).reshape(-1, 1) - @staticmethod def from_edge_index( edge_index: torch.Tensor, mapping: Optional[IndexMap] = None, num_nodes: int | None = None @@ -298,7 +306,6 @@ def to(self, device: torch.device) -> Graph: Graph: self """ self.data.edge_index = self.data.edge_index.to(device) - self.data.node_sequence = self.data.node_sequence.to(device) for attr in self.node_attrs(): if isinstance(self.data[attr], torch.Tensor): self.data[attr] = self.data[attr].to(device) @@ -317,14 +324,15 @@ def node_attrs(self) -> List[str]: """Return a list of node attributes. This method returns a list containing the names of all node-level attributes, - ignoring the special `node_sequence` attribute. + ignoring attributes that a subclass manages internally (such as the `node_sequence` + of a [`HigherOrderGraph`][pathpyG.HigherOrderGraph]). Returns: list: list of node attributes """ attrs = [] for k in self.data.keys(): - if k != "node_sequence" and k.startswith("node_"): + if k not in self._internal_node_attrs and k.startswith("node_"): attrs.append(k) return attrs @@ -660,12 +668,12 @@ def m(self) -> int: @property def order(self) -> int: - """Return order of graph. + """Return the order of the graph, which is always 1 for a first-order graph. Returns: - int: order of the (De Bruijn) graph + int: order of the graph """ - return self.data.node_sequence.size(1) + return 1 def is_directed(self) -> bool: """Return whether graph is directed. @@ -715,9 +723,7 @@ def __add__(self, other: Graph, reduce: str = "sum") -> Graph: >>> g2 = pp.Graph.from_edge_index(torch.tensor([[0, 2, 3], [3, 2, 1]])) >>> print(g1 + g2) Directed graph with 4 nodes and 6 edges - { 'Edge Attributes': {}, - 'Graph Attributes': {'node_sequence': " -> torch.Size([8, 1])", 'num_nodes': ""}, - 'Node Attributes': {}} + {'Edge Attributes': {}, 'Graph Attributes': {'num_nodes': ""}, 'Node Attributes': {}} Adding two graphs with identical node IDs: @@ -725,9 +731,7 @@ def __add__(self, other: Graph, reduce: str = "sum") -> Graph: >>> g2 = pp.Graph.from_edge_list([("a", "c"), ("c", "b")]) >>> print(g1 + g2) Directed graph with 3 nodes and 4 edges - { 'Edge Attributes': {}, - 'Graph Attributes': {'node_sequence': " -> torch.Size([6, 1])", 'num_nodes': ""}, - 'Node Attributes': {}} + {'Edge Attributes': {}, 'Graph Attributes': {'num_nodes': ""}, 'Node Attributes': {}} Adding two graphs with non-overlapping node IDs: @@ -735,9 +739,7 @@ def __add__(self, other: Graph, reduce: str = "sum") -> Graph: >>> g2 = pp.Graph.from_edge_list([("c", "d"), ("d", "e")]) >>> print(g1 + g2) Directed graph with 5 nodes and 4 edges - { 'Edge Attributes': {}, - 'Graph Attributes': {'node_sequence': " -> torch.Size([6, 1])", 'num_nodes': ""}, - 'Node Attributes': {}} + {'Edge Attributes': {}, 'Graph Attributes': {'num_nodes': ""}, 'Node Attributes': {}} Adding two graphs with partly overlapping node IDs: @@ -745,9 +747,20 @@ def __add__(self, other: Graph, reduce: str = "sum") -> Graph: >>> g2 = pp.Graph.from_edge_list([("b", "d"), ("d", "e")]) >>> print(g1 + g2) Directed graph with 5 nodes and 4 edges - { 'Edge Attributes': {}, - 'Graph Attributes': {'node_sequence': " -> torch.Size([6, 1])", 'num_nodes': ""}, - 'Node Attributes': {}} + {'Edge Attributes': {}, 'Graph Attributes': {'num_nodes': ""}, 'Node Attributes': {}} + """ + data, mapping = self._add_data(other, reduce) + return Graph(data, mapping=mapping) + + def _add_data(self, other: Graph, reduce: str = "sum") -> tuple[Data, IndexMap]: + """Combine the data of this graph with that of `other`, remapping node indices to a joint mapping. + + Args: + other: Other graph to be combined with this graph + reduce: Reduction method for node attributes of nodes that are present in both graphs. + + Returns: + The combined `Data` object and the joint `IndexMap`. """ d1 = self.data.clone() m1 = self.mapping @@ -764,16 +777,9 @@ def __add__(self, other: Graph, reduce: str = "sum") -> Graph: d.num_nodes = mapping.num_ids() d.edge_index = EdgeIndex(d.edge_index, sparse_size=(d.num_nodes, d.num_nodes)) - # For higher-order graphs, we need to update the inverse_idx attribute - if "inverse_idx" in d: - d.inverse_idx = mapping.to_idxs( - np.concatenate([m1.to_ids(d1.inverse_idx), m2.to_ids(d2.inverse_idx)]), - device=d.inverse_idx.device, - ) - # If both graphs contain node attributes, reduce them using the specified method for k in d1.keys(): - if k != "node_sequence" and k.startswith("node_"): + if k not in self._internal_node_attrs and k.startswith("node_"): if isinstance(d[k], torch.Tensor): d[k] = torch_geometric.utils.scatter( d[k], @@ -786,7 +792,7 @@ def __add__(self, other: Graph, reduce: str = "sum") -> Graph: ) else: raise ValueError("Node attribute " + k + " is not a tensor and cannot be reduced.") - return Graph(d, mapping=mapping) + return d, mapping def _summary(self) -> str: """Return a one-line summary of the graph, to be overridden by subclasses.""" diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py index 79ce903c..439166d5 100644 --- a/src/pathpyG/core/higher_order_graph.py +++ b/src/pathpyG/core/higher_order_graph.py @@ -5,6 +5,7 @@ import logging from typing import Optional, Union +import numpy as np import torch from torch_geometric.data import Data from torch_geometric.utils import coalesce @@ -27,8 +28,16 @@ class HigherOrderGraph(Graph): 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 - plain node IDs rather than tuples. + The order `k` is the memory of the model: the next step of a path depends on the + last `k` first-order nodes visited. + + - Order 1 is simply the weighted first-order graph, with plain node IDs rather than tuples. + - Order 0 is the memoryless model, in which every step is an independent draw from a fixed + distribution over first-order nodes. It has a single node, the empty path `()`, and one + self-loop per first-order node, weighted by how often that node is visited. Since all + loops share the same endpoints, the first-order node each loop emits is stored in the + `edge_first_order_node` edge attribute. The transition probabilities of the loops are + the node visitation probabilities. Info: In addition to the attributes of [`Graph`][pathpyG.Graph], the `data` object holds: @@ -38,10 +47,12 @@ class HigherOrderGraph(Graph): - `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. + - `edge_first_order_node` (order 0 only): [Tensor][torch.Tensor] with the index of + the first-order node emitted by each self-loop. Attributes: data (Data): PyG Data object containing edges and attributes. - mapping (IndexMap): Mapping from higher-order node IDs (tuples, for order > 1) to indices. + mapping (IndexMap): Mapping from higher-order node IDs (tuples, for order other than 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. @@ -54,6 +65,8 @@ class HigherOrderGraph(Graph): 1 ['a', 'c', 'd'] """ + _internal_node_attrs: frozenset[str] = frozenset({"node_sequence"}) + def __init__( self, data: Data, @@ -74,8 +87,9 @@ def __init__( 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. + Required for order 0 if `first_order_mapping` has no IDs. + mapping: Mapping of higher-order node IDs to indices. For order 1 this must + use plain IDs; for any other order it must use tuple IDs. Raises: ValueError: If the order, the node sequence, and the mapping disagree, or if @@ -86,8 +100,10 @@ def __init__( 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 "node_sequence" not in self.data: + # An order-1 node is a first-order node, so the node sequence is the identity. + self.data.node_sequence = torch.arange(self.data.num_nodes, device=self.device).unsqueeze(1) + if order is not None and order != self.order: raise ValueError(f"order={order} does not match node sequence of width {self.order}") @@ -103,6 +119,9 @@ def __init__( 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.order == 0: + # The empty path does not refer to any first-order node to infer the count from. + raise ValueError("an order-0 graph requires `n_first_order` or a `first_order_mapping` with IDs") elif self.data.node_sequence.numel() > 0: self._n_first_order = int(self.data.node_sequence.max().item()) + 1 else: @@ -120,25 +139,57 @@ def _validate(self) -> None: f"but there are only {self._n_first_order} first-order nodes" ) + if self.order == 0: + if self.n > 1: + raise ValueError(f"an order-0 graph has at most one node (the empty path), got {self.n}") + if "edge_first_order_node" not in self.data: + raise ValueError("an order-0 graph requires an `edge_first_order_node` edge attribute") + emitted = self.data.edge_first_order_node + if emitted.numel() > 0 and int(emitted.max().item()) >= self._n_first_order: + raise ValueError( + f"edge_first_order_node refers to first-order node {int(emitted.max().item())}, " + 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): + # Higher-order nodes are paths and are identified by tuples (the empty tuple + # for order 0); 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" + 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 ) + @property + def order(self) -> int: + """Return the order of the graph, i.e. the number of first-order nodes in each node's path.""" + return self.data.node_sequence.size(1) + + def to(self, device: torch.device) -> HigherOrderGraph: + """Move all tensors to the given device. + + Args: + device: torch device to which all tensors shall be moved + + Returns: + HigherOrderGraph: self + """ + super().to(device) + self.data.node_sequence = self.data.node_sequence.to(device) + if "inverse_idx" in self.data: + self.data.inverse_idx = self.data.inverse_idx.to(device) + return self + @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}") + if order < 0: + logger.error("order must be at least 0, got %s", order) + raise ValueError(f"order must be at least 0, got {order}") @staticmethod def _build_mapping(node_sequence: torch.Tensor, first_order_mapping: IndexMap) -> IndexMap: @@ -149,6 +200,9 @@ def _build_mapping(node_sequence: torch.Tensor, first_order_mapping: IndexMap) - # 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 == 0: + # The only order-0 node is the empty path. + return IndexMap([()]) if order == 1: # Order-1 node indices are first-order node indices, so the mapping carries over. return first_order_mapping @@ -179,8 +233,15 @@ def aggregate( Returns: HigherOrderGraph: The aggregated higher-order graph. + + Raises: + ValueError: If `node_sequence` has width 0. Use + [`from_node_weights`][pathpyG.HigherOrderGraph.from_node_weights] to build + an order-0 graph. """ order = node_sequence.size(1) + if order == 0: + raise ValueError("order-0 graphs cannot be aggregated from an edge index; use from_node_weights") if first_order_mapping is None: first_order_mapping = IndexMap() if n_first_order is None: @@ -189,7 +250,7 @@ def aggregate( 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 + data = aggregate_edge_index(edge_index, node_sequence, edge_weight, aggr=aggr) if order == 1 and n_first_order > data.num_nodes: # Order-1 indices are first-order indices, so first-order nodes that are not @@ -206,29 +267,57 @@ def aggregate( ) @classmethod - def from_aggregated_graph( - cls, - g: Graph, - first_order_mapping: Optional[IndexMap] = None, - n_first_order: Optional[int] = None, + def from_node_weights( + cls, node_weight: torch.Tensor, first_order_mapping: Optional[IndexMap] = None ) -> HigherOrderGraph: - """Adopt an already-aggregated [`Graph`][pathpyG.Graph] as a higher-order graph. + """Create the order-0 graph for the given visitation weights of first-order nodes. + + The result has a single node, the empty path `()`, with one self-loop per first-order + node. Each loop carries the node's weight as `edge_weight` and the node's index as + `edge_first_order_node`. Nodes with weight 0 keep their loop, so that every first-order + node is represented. Args: - g: Aggregated graph carrying a `node_sequence` of shape `(num_nodes, order)`. + node_weight: Tensor of shape `(n_first_order,)` with the weight of each first-order node. 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. + HigherOrderGraph: A higher-order graph of order 0. + + Examples: + >>> import torch + >>> import pathpyG as pp + >>> h = pp.HigherOrderGraph.from_node_weights( + ... torch.tensor([3.0, 1.0, 4.0, 4.0, 0.0]), first_order_mapping=pp.IndexMap(list("abcde")) + ... ) + >>> print(h.order, h.nodes, h.m) + 0 [()] 5 + >>> print(h.transition_probabilities(edge_attr="edge_weight")) + tensor([0.2500, 0.0833, 0.3333, 0.3333, 0.0000]) """ - if isinstance(g, HigherOrderGraph): - return g + n_first_order = node_weight.size(0) + if first_order_mapping is None: + first_order_mapping = IndexMap() + elif first_order_mapping.has_ids and first_order_mapping.num_ids() != n_first_order: + raise ValueError( + f"first_order_mapping has {first_order_mapping.num_ids()} IDs, " + f"but {n_first_order} node weights were given" + ) + + device = node_weight.device + data = Data( + edge_index=torch.zeros((2, n_first_order), dtype=torch.long, device=device), + num_nodes=1, + node_sequence=torch.empty((1, 0), dtype=torch.long, device=device), + edge_weight=node_weight, + edge_first_order_node=torch.arange(n_first_order, device=device), + ) return cls( - g.data, + data, + order=0, first_order_mapping=first_order_mapping, n_first_order=n_first_order, - mapping=g.mapping, + mapping=IndexMap([()]), ) @classmethod @@ -309,7 +398,8 @@ def from_path_data(cls, path_data: PathData, order: int = 1, mode: str = "propag Args: path_data: The observed paths. - order: The order `k` of the graph to compute. + order: The order `k` of the graph to compute. Order 0 yields the memoryless + model of node visitation frequencies. mode: The process that we assume. Either "diffusion" or "propagation". Returns: @@ -379,7 +469,12 @@ def to_first_order(self, mode: str = "last") -> Graph: Returns: Graph: A weighted first-order graph. + + Raises: + ValueError: If the graph has order 0, whose only node refers to no first-order node. """ + if self.order == 0: + raise ValueError("an order-0 graph cannot be projected onto first-order nodes") if mode == "last": projection = self.data.node_sequence[:, -1] elif mode == "first": @@ -417,7 +512,12 @@ def bipartite_edge_index( Returns: torch.Tensor: Edge index of shape `(2, ยท)`, higher-order nodes in the first row. + + Raises: + ValueError: If the graph has order 0, whose only node refers to no first-order node. """ + if self.order == 0: + raise ValueError("an order-0 graph has no first-order nodes to connect to") if device is None: device = first_order_graph.device if first_order_graph is not None else self.device @@ -444,12 +544,94 @@ def n_first_order(self) -> int: 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 == 0: + return () 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 __add__(self, other: Graph, reduce: str = "sum") -> HigherOrderGraph: + """Combine this higher-order graph with another one of the same order. + + Nodes are matched by the paths they represent, as described for + [`Graph.__add__`][pathpyG.Graph.__add__]. The first-order node sets are joined as well. + + Args: + other: Higher-order graph of the same order to be combined with this graph + reduce: Reduction method for node attributes of nodes that are present in both graphs. + + Returns: + HigherOrderGraph: The combined higher-order graph. + + Raises: + TypeError: If `other` is not a `HigherOrderGraph`. + ValueError: If the graphs have different orders, or only one of them has first-order node IDs. + """ + if not isinstance(other, HigherOrderGraph): + raise TypeError("a HigherOrderGraph can only be combined with another HigherOrderGraph") + if other.order != self.order: + raise ValueError(f"cannot combine graphs of order {self.order} and {other.order}") + + data, mapping = self._add_data(other, reduce) + device = data.edge_index.device + + fo_self, fo_other = self.first_order_mapping, other.first_order_mapping + if self.order == 1: + # Order-1 nodes are first-order nodes, so the joint mapping is the first-order mapping. + first_order_mapping = mapping + n_first_order = data.num_nodes + elif fo_self.has_ids and fo_other.has_ids: + if np.array_equal(fo_self.node_ids, fo_other.node_ids): # type: ignore[arg-type] + first_order_mapping = fo_self + else: + first_order_mapping = IndexMap( + np.unique(np.concatenate([fo_self.node_ids, fo_other.node_ids])).tolist() + ) + n_first_order = first_order_mapping.num_ids() + elif not fo_self.has_ids and not fo_other.has_ids: + first_order_mapping = IndexMap() + n_first_order = max(self.n_first_order, other.n_first_order) + else: + raise ValueError("cannot combine graphs where only one has first-order node IDs") + + # Rebuild the node sequence from the joint mapping, whose IDs are the paths. + if self.order == 0: + data.node_sequence = torch.empty((data.num_nodes, 0), dtype=torch.long, device=device) + data.edge_first_order_node = first_order_mapping.to_idxs( + np.concatenate( + [ + fo_self.to_ids(self.data.edge_first_order_node.cpu()), + fo_other.to_ids(other.data.edge_first_order_node.cpu()), + ] + ), + device=device, + ) + elif self.order == 1: + data.node_sequence = torch.arange(data.num_nodes, device=device).unsqueeze(1) + else: + data.node_sequence = first_order_mapping.to_idxs( + mapping.to_ids(np.arange(data.num_nodes)), device=device + ).reshape(data.num_nodes, self.order) + + # Each pre-aggregation row keeps pointing at the (renumbered) node it was merged into. + if "inverse_idx" in data: + data.inverse_idx = mapping.to_idxs( + np.concatenate( + [self.mapping.to_ids(self.data.inverse_idx.cpu()), other.mapping.to_ids(other.data.inverse_idx.cpu())] + ), + device=device, + ) + + return HigherOrderGraph( + data, + order=self.order, + first_order_mapping=first_order_mapping, + n_first_order=n_first_order, + mapping=mapping, + ) + def __str__(self) -> str: """Return a human-readable summary of the higher-order graph.""" s = ( diff --git a/src/pathpyG/core/index_map.py b/src/pathpyG/core/index_map.py index 4650d985..d118ef34 100644 --- a/src/pathpyG/core/index_map.py +++ b/src/pathpyG/core/index_map.py @@ -387,9 +387,13 @@ def to_idxs(self, nodes: list | tuple | np.ndarray, device: Optional[torch.devic if self.id_shape == (-1,): return torch.tensor([self.id_to_idx[node] for node in nodes.flatten()], device=device).reshape(shape) else: + # Leading dimensions index the IDs, trailing ones span a single tuple ID. The number + # of IDs is computed explicitly since `reshape(-1, 0)` is ambiguous for empty tuples. + lead_shape = shape[: len(shape) - len(self.id_shape) + 1] + ids = nodes.reshape(int(np.prod(lead_shape)), *self.id_shape[1:]) return torch.tensor( - [self.id_to_idx[tuple(node.tolist())] for node in nodes.reshape(self.id_shape)], device=device - ).reshape(shape[: -len(self.id_shape) + 1]) + [self.id_to_idx[tuple(node.tolist())] for node in ids], dtype=torch.long, device=device + ).reshape(lead_shape) else: return torch.tensor(nodes, device=device) diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py index 30d02a1d..84c2b037 100644 --- a/src/pathpyG/core/multi_order_model.py +++ b/src/pathpyG/core/multi_order_model.py @@ -11,7 +11,6 @@ from torch_geometric.utils import cumsum, degree from pathpyG.algorithms.lift_order import ( - aggregate_edge_index, aggregate_node_attributes, lift_node_sequence, lift_order_edge_index, @@ -19,7 +18,6 @@ ) 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.path_data import PathData from pathpyG.core.temporal_graph import TemporalGraph @@ -34,7 +32,8 @@ 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. + built from. Models built from path data also hold the memoryless order-0 layer, which + is used by the likelihood computations. 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. @@ -45,18 +44,18 @@ class MultiOrderModel: Examples: Example where the optimal order is 1: >>> import pathpyG as pp - >>> paths = PathData(IndexMap(list("abcde"))) + >>> paths = pp.PathData(pp.IndexMap(list("abcde"))) >>> paths.append_walk(("a", "c", "d"), weight=3) >>> paths.append_walk(("b", "c", "e"), weight=3) - >>> m = MultiOrderModel.from_path_data(paths, max_order=2) + >>> m = pp.MultiOrderModel.from_path_data(paths, max_order=2) >>> print(m.estimate_order(paths, max_order=2)) 1 Example where the optimal order is 2: - >>> paths = PathData(IndexMap(list("abcde"))) + >>> paths = pp.PathData(pp.IndexMap(list("abcde"))) >>> paths.append_walk(("a", "c", "d"), weight=4) >>> paths.append_walk(("b", "c", "e"), weight=4) - >>> m = MultiOrderModel.from_path_data(paths, max_order=2) + >>> m = pp.MultiOrderModel.from_path_data(paths, max_order=2) >>> print(m.estimate_order(paths, max_order=2)) 2 @@ -87,51 +86,6 @@ def to(self, device: torch.device) -> "MultiOrderModel": g.to(device) return self - @staticmethod - def iterate_lift_order( - edge_index: torch.Tensor, - node_sequence: torch.Tensor, - mapping: IndexMap, - edge_weight: torch.Tensor | None = None, - aggr: str = "src", - save: bool = True, - 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. - Only use for edge_indices after the special cases have been handled e.g. - in the from_temporal_graph (filtering non-time-respecting paths of order 2). - - Args: - edge_index: The edge index of the (k-1)-th order graph. - node_sequence: The node sequences of the (k-1)-th order graph. - mapping: The [IndexMap][pathpyG.IndexMap] mapping higher-order nodes to first-order nodes. - edge_weight: The edge weights of the (k-1)-th order graph. - 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 - ho_index, node_sequence, edge_weight = lift_order_step( - edge_index, node_sequence, edge_weight=edge_weight, aggr=aggr - ) - - # Aggregate - if save: - gk = HigherOrderGraph.aggregate( - 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 - @staticmethod def from_temporal_graph( g: TemporalGraph, @@ -154,7 +108,16 @@ def from_temporal_graph( Returns: MultiOrderModel: A multi-order model where each layer is a De Bruijn graph with order k. + + Raises: + ValueError: If `max_order` is smaller than 1. The order-0 layer is only defined for path data. """ + if max_order < 1: + logger.error("max_order must be at least 1 for a temporal graph, got %s", max_order) + raise ValueError( + f"max_order must be at least 1 for a temporal graph, got {max_order}; " + "the order-0 layer is only defined for path data" + ) m = MultiOrderModel() if not g.data.is_sorted_by_time(): data = g.data.sort_by_time() @@ -166,45 +129,29 @@ def from_temporal_graph( edge_weight = data[weight] else: edge_weight = torch.ones(edge_index.size(1), device=edge_index.device) - if cached or max_order == 1: - m.layers[1] = HigherOrderGraph.aggregate( - edge_index=edge_index, - node_sequence=node_sequence, - edge_weight=edge_weight, - first_order_mapping=g.mapping, - n_first_order=g.n, - ) - if max_order > 1: - node_sequence = lift_node_sequence(edge_index, node_sequence) - if event_graph is None: - edge_index = EventGraph.build_edge_index(g, delta) - else: - edge_index = event_graph - edge_weight = aggregate_node_attributes(edge_index, edge_weight, "src") - - # Aggregate - if cached or max_order == 2: - m.layers[2] = HigherOrderGraph.aggregate( - edge_index=edge_index, - node_sequence=node_sequence, - edge_weight=edge_weight, - first_order_mapping=g.mapping, - n_first_order=g.n, + # Each iteration lifts the *unaggregated* order-(k-1) data to order k and aggregates it + # only if the layer is kept. The aggregated layers are never lifted themselves. + for k in range(1, max_order + 1): + if k == 2: + # The first lift is temporal: an edge may only be continued within `delta`. + node_sequence = lift_node_sequence(edge_index, node_sequence) + edge_index = EventGraph.build_edge_index(g, delta) if event_graph is None else event_graph + edge_weight = aggregate_node_attributes(edge_index, edge_weight, "src") + elif k > 2: + edge_index, node_sequence, edge_weight = lift_order_step( + edge_index, node_sequence, edge_weight=edge_weight, aggr="src" ) - for k in range(3, max_order + 1): - edge_index, node_sequence, edge_weight, gk = MultiOrderModel.iterate_lift_order( + if cached or k == max_order: + m.layers[k] = HigherOrderGraph.aggregate( edge_index=edge_index, node_sequence=node_sequence, - mapping=g.mapping, edge_weight=edge_weight, - aggr="src", - save=cached or k == max_order, + first_order_mapping=g.mapping, n_first_order=g.n, ) - if cached or k == max_order: - m.layers[k] = gk # type: ignore[assignment] + return m @classmethod @@ -247,11 +194,15 @@ def from_path_data( max_order: The maximum order of the [MultiOrderModel][pathpyG.MultiOrderModel] that should be computed mode: The process that we assume. Can be "diffusion" or "propagation". cached: Whether to save the aggregated higher-order graphs smaller than max order - in the [MultiOrderModel][pathpyG.MultiOrderModel]. + in the [MultiOrderModel][pathpyG.MultiOrderModel]. The layers of order 0 and 1 + are always kept, since the likelihood computations rely on them. Returns: MultiOrderModel: The MultiOrderModel. """ + if max_order < 0: + logger.error("max_order must be at least 0, got %s", max_order) + raise ValueError(f"max_order must be at least 0, got {max_order}") m = MultiOrderModel() # We assume that paths are sorted @@ -267,27 +218,37 @@ def from_path_data( elif mode == "propagation": aggr = "src" - 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 - ) + # First-order nodes that are not visited by any path are still part of the model. + if path_data.mapping.has_ids: + n_first_order = path_data.mapping.num_ids() + else: + n_first_order = int(node_sequence.max().item()) + 1 if node_sequence.numel() > 0 else 0 + + # Order 0: every visit of a node, weighted by the frequency of its path. + visit_weight = path_graph.dag_weight.repeat_interleave(path_graph.dag_num_nodes) + node_weight = torch.zeros(n_first_order, dtype=visit_weight.dtype, device=visit_weight.device) + node_weight.scatter_add_(0, node_sequence.squeeze(1), visit_weight) + m.layers[0] = HigherOrderGraph.from_node_weights(node_weight, first_order_mapping=path_data.mapping) + if max_order == 0: + return m + + # The paths themselves are the unaggregated order-1 data (one node per visit). Each + # iteration lifts the unaggregated order-(k-1) data to order k and aggregates it only + # if the layer is kept. The aggregated layers are never lifted themselves. + for k in range(1, max_order + 1): + if k > 1: + edge_index, node_sequence, edge_weight = lift_order_step( + edge_index, node_sequence, edge_weight=edge_weight, aggr=aggr + ) - 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=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] + if k == 1 or cached or k == max_order: + m.layers[k] = HigherOrderGraph.aggregate( + edge_index=edge_index, + node_sequence=node_sequence, + first_order_mapping=path_data.mapping, + edge_weight=edge_weight, + n_first_order=n_first_order, + ) return m @@ -325,7 +286,11 @@ def get_mon_dof(self, max_order: Optional[int] = None, assumption: str = "paths" logger.error("max_order cannot be larger than maximum order of multi-order network") raise ValueError("max_order cannot be larger than maximum order of multi-order network") - dof = self.layers[1].data.num_nodes - 1 # Degrees of freedom for zeroth order + # Degrees of freedom for zeroth order: one probability per first-order node, minus normalisation + if 0 in self.layers: + dof = self.layers[0].m - 1 + else: + dof = self.layers[1].n_first_order - 1 if assumption == "paths": # COMPUTING CONTRIBUTION FROM NUM PATHS AND NONZERO OUTDEGREES SEPARATELY @@ -379,15 +344,32 @@ def get_zeroth_order_log_likelihood(self, dag_graph: Data) -> float: # Q: Is dag_graph.path_index[:-1] enough to get the start_ixs? mask = torch.ones(dag_graph.num_nodes, dtype=bool) # type: ignore[call-overload] mask[dag_graph.edge_index[1]] = False - start_ixs = dag_graph.node_sequence.squeeze()[mask] + start_ixs = dag_graph.node_sequence.squeeze(1)[mask] + + node_visit_probabilities = self._node_visit_probabilities() + return torch.mul(frequencies, torch.log(node_visit_probabilities[start_ixs])).sum().item() - # Compute node emission probabilities - # TODO: modify once we have zeroth order in mon - _, counts = torch.unique(dag_graph.node_sequence, return_counts=True) - # WARNING: Only works if all nodes in the first-order graph are also in `node_sequence` - # Otherwise the missing nodes will not be included in `counts` which can lead to elements at the wrong index. - node_emission_probabilities = counts / counts.sum() - return torch.mul(frequencies, torch.log(node_emission_probabilities[start_ixs])).sum().item() + def _zeroth_order_layer(self) -> HigherOrderGraph: + """Return the order-0 layer. + + Raises: + ValueError: If the model has no order-0 layer, i.e. was not built from path data. + """ + if 0 not in self.layers: + logger.error("MultiOrderModel has no order-0 layer") + raise ValueError("the likelihood requires the order-0 layer, which is only built from path data") + return self.layers[0] + + def _node_visit_probabilities(self) -> torch.Tensor: + """Return the visitation probability of each first-order node under the order-0 layer. + + Returns: + torch.Tensor: Tensor of shape `(n_first_order,)` indexed by first-order node index. + """ + g0 = self._zeroth_order_layer() + probabilities = torch.zeros(g0.n_first_order, device=g0.device) + probabilities[g0.data.edge_first_order_node] = g0.transition_probabilities(edge_attr="edge_weight").float() + return probabilities def get_intermediate_order_log_likelihood(self, dag_graph: Data, order: int) -> float: """Compute the intermediate order log likelihood. @@ -431,6 +413,13 @@ def get_mon_log_likelihood(self, dag_graph: Data, max_order: int = 1) -> float: Returns: float: The log likelihood of the walks given the multi-order model. """ + if max_order == 0: + # Under the memoryless model every visit, including the first of each path, is an + # independent draw from the order-0 layer, whose loop weights count the visits. + g0 = self._zeroth_order_layer() + visit_probabilities = g0.transition_probabilities(edge_attr="edge_weight") + return torch.xlogy(g0.data.edge_weight, visit_probabilities).sum().item() + llh = 0.0 # Adding likelihood of zeroth order @@ -441,21 +430,10 @@ def get_mon_log_likelihood(self, dag_graph: Data, max_order: int = 1) -> float: llh += self.get_intermediate_order_log_likelihood(dag_graph, order) # Adding the likelihood of highest/stationary order - if max_order > 0: - transition_probabilities = self.layers[max_order].transition_probabilities(edge_attr="edge_weight") - log_transition_probabilities = torch.log(transition_probabilities) - llh_by_subpath = log_transition_probabilities * self.layers[max_order].data.edge_weight - llh += llh_by_subpath.sum().item() - else: - # Compute likelihood for zeroth order (to be modified) - # TODO: modify once we have zeroth order in mon - # (then won t need to compute emission probs from dag_graph -- which also hinders us from computing the lh that a new set of paths was generated by the model) - frequencies = dag_graph.dag_weight - counts = torch.bincount( - dag_graph.node_sequence.squeeze(), frequencies.repeat_interleave(dag_graph.dag_num_nodes) - ) - node_emission_probabilities = counts / counts.sum() - llh = torch.mul(torch.log(node_emission_probabilities), counts).sum().item() + transition_probabilities = self.layers[max_order].transition_probabilities(edge_attr="edge_weight") + log_transition_probabilities = torch.log(transition_probabilities) + llh_by_subpath = log_transition_probabilities * self.layers[max_order].data.edge_weight + llh += llh_by_subpath.sum().item() return llh @@ -569,6 +547,9 @@ def to_dbgnn_data(self, max_order: int = 2, mapping: str = "last") -> Data: Returns: Data: The De Bruijn graph data. """ + if max_order < 1: + logger.error("max_order must be at least 1 for the DBGNN, got %s", max_order) + raise ValueError(f"max_order must be at least 1 for the DBGNN, got {max_order}") if max_order not in self.layers: logger.error("Higher-order graph of specified order not found.") raise ValueError(f"Higher-order graph of order {max_order} not found.") diff --git a/src/pathpyG/core/temporal_graph.py b/src/pathpyG/core/temporal_graph.py index 2c09ad45..5a6b93c1 100644 --- a/src/pathpyG/core/temporal_graph.py +++ b/src/pathpyG/core/temporal_graph.py @@ -197,11 +197,6 @@ def to(self, device: torch.device) -> "TemporalGraph": self.data[attr] = self.data[attr].to(device) return self - @property - def order(self) -> int: - """Return order 1, since all temporal graphs must be order one.""" - return 1 - @property def start_time(self) -> Union[int, float]: """Return the timestamp of the first event in the temporal graph.""" diff --git a/tests/algorithms/test_lift_order.py b/tests/algorithms/test_lift_order.py index bf8ef02c..5e01c51d 100644 --- a/tests/algorithms/test_lift_order.py +++ b/tests/algorithms/test_lift_order.py @@ -73,7 +73,7 @@ def test_aggregate_edge_index(): [4, 5], # Node 3 ] ) - g = aggregate_edge_index(edge_index=edge_index, edge_weight=edge_weight, node_sequence=node_sequence) - assert g.data.edge_index.as_tensor().tolist() == [[0, 0, 1], [1, 2, 0]] - assert g.data.edge_weight.tolist() == [3, 3, 4] - assert g.data.node_sequence.tolist() == [[1, 2], [2, 3], [4, 5]] + d = aggregate_edge_index(edge_index=edge_index, edge_weight=edge_weight, node_sequence=node_sequence) + assert d.edge_index.tolist() == [[0, 0, 1], [1, 2, 0]] + assert d.edge_weight.tolist() == [3, 3, 4] + assert d.node_sequence.tolist() == [[1, 2], [2, 3], [4, 5]] diff --git a/tests/core/test_graph.py b/tests/core/test_graph.py index 897b4e0b..a7c2848f 100644 --- a/tests/core/test_graph.py +++ b/tests/core/test_graph.py @@ -21,7 +21,7 @@ def test_init(): assert isinstance(g.data, Data) assert isinstance(g.mapping, IndexMap) assert isinstance(g.edge_to_index, dict) - assert g.data.node_sequence.size() == (g.n, 1) + assert "node_sequence" not in g.data assert g.order == 1 diff --git a/tests/core/test_multi_order_model.py b/tests/core/test_multi_order_model.py index b5fe60c8..7e7d57a3 100644 --- a/tests/core/test_multi_order_model.py +++ b/tests/core/test_multi_order_model.py @@ -26,22 +26,6 @@ def test_multi_order_model_str(): assert str(model) == "MultiOrderModel with max. order 5" -def test_iterate_lift_order(simple_graph_multi_edges): - ho_index, node_sequence, edge_weight, gk = MultiOrderModel.iterate_lift_order( - edge_index=simple_graph_multi_edges.data.edge_index, - node_sequence=torch.arange(simple_graph_multi_edges.n).unsqueeze(1), - mapping=simple_graph_multi_edges.mapping, - save=True, - ) - assert ho_index.tolist() == [[0, 2], [3, 3]] - assert node_sequence.tolist() == [[0, 1], [0, 2], [0, 1], [1, 2]] - assert edge_weight is None - assert gk.data.edge_index.as_tensor().tolist() == [[0], [2]] - assert gk.data.node_sequence.tolist() == [[0, 1], [0, 2], [1, 2]] - assert gk.data.edge_weight.tolist() == [2.0] - assert gk.order == 2 - - def test_dof(): line_data = PathData(IndexMap(list("abcd"))) line_data.append_walk(("a", "b", "c", "d")) @@ -120,7 +104,7 @@ def test_log_likelihood(): m = MultiOrderModel.from_path_data(toy_paths, max_order=max_order, mode="propagation") dag_graph = toy_paths.data assert np.isclose( - m.get_mon_log_likelihood(dag_graph, max_order=0), # fails already at computing log_lh here + m.get_mon_log_likelihood(dag_graph, max_order=0), np.log(2 / 12) * 8 + np.log(4 / 12) * 4, ) assert np.isclose(m.get_mon_log_likelihood(dag_graph, max_order=1), np.log(2 / 12) * 4 + 0 + 4 * np.log(1 / 2)) @@ -136,7 +120,7 @@ def test_log_likelihood(): m = MultiOrderModel.from_path_data(toy_paths, max_order=max_order, mode="propagation") dag_graph = toy_paths.data assert np.isclose( - m.get_mon_log_likelihood(dag_graph, max_order=0), # fails already at computing log_lh here + m.get_mon_log_likelihood(dag_graph, max_order=0), np.log(3 / 6) * 3 + np.log(2 / 6) * 2 + np.log(1 / 6) * 1, ) assert np.isclose(m.get_mon_log_likelihood(dag_graph, max_order=1), np.log(3 / 6) * 3 + 0 + 0) From 01e752bbb15b2a2bf1bd2ec3a9977bfa07a9689e Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Mon, 21 Sep 2026 15:12:12 -0400 Subject: [PATCH 14/17] wording fixes in advanced notebooks --- .../advanced/01-07-message-passing-pyG.ipynb | 2 +- docs/tutorial/advanced/01-08-GCNs.ipynb | 4 +- .../advanced/01-09-GCN-node-features.ipynb | 2 +- .../02-02-trp-centralities-communities.ipynb | 2 +- .../advanced/02-03-trp-event-graphs.ipynb | 76 +++++++++---------- .../advanced/02-05-tgn-baseline.ipynb | 4 +- docs/tutorial/advanced/02-06-dbgnn.ipynb | 9 ++- 7 files changed, 49 insertions(+), 50 deletions(-) diff --git a/docs/tutorial/advanced/01-07-message-passing-pyG.ipynb b/docs/tutorial/advanced/01-07-message-passing-pyG.ipynb index 486854b5..928e9afc 100644 --- a/docs/tutorial/advanced/01-07-message-passing-pyG.ipynb +++ b/docs/tutorial/advanced/01-07-message-passing-pyG.ipynb @@ -445,7 +445,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Data(edge_index=[2, 12], num_nodes=5, node_sequence=[5, 1], x=[5, 1], y=[5, 2])\n", + "Data(edge_index=[2, 12], num_nodes=5, x=[5, 1], y=[5, 2])\n", "tensor([[1.],\n", " [2.],\n", " [3.],\n", diff --git a/docs/tutorial/advanced/01-08-GCNs.ipynb b/docs/tutorial/advanced/01-08-GCNs.ipynb index a7df5bff..647fbcdb 100644 --- a/docs/tutorial/advanced/01-08-GCNs.ipynb +++ b/docs/tutorial/advanced/01-08-GCNs.ipynb @@ -555,7 +555,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Data(edge_index=[2, 800], num_nodes=100, node_sequence=[100, 1], y=[100, 1], x=[100, 100], train_mask=[100], val_mask=[100], test_mask=[100])\n" + "Data(edge_index=[2, 800], num_nodes=100, y=[100, 1], x=[100, 100], train_mask=[100], val_mask=[100], test_mask=[100])\n" ] } ], @@ -1392,7 +1392,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Data(edge_index=[2, 2390], num_nodes=200, node_sequence=[200, 1], y=[200, 1], x=[200, 200])\n" + "Data(edge_index=[2, 2390], num_nodes=200, y=[200, 1], x=[200, 200])\n" ] } ], diff --git a/docs/tutorial/advanced/01-09-GCN-node-features.ipynb b/docs/tutorial/advanced/01-09-GCN-node-features.ipynb index f27ad51a..6ad7c087 100644 --- a/docs/tutorial/advanced/01-09-GCN-node-features.ipynb +++ b/docs/tutorial/advanced/01-09-GCN-node-features.ipynb @@ -191,7 +191,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Data(edge_index=[2, 800], num_nodes=100, node_sequence=[100, 1], x=[100, 101], cluster=[100], y=[100, 2], train_mask=[100], val_mask=[100], test_mask=[100])\n" + "Data(edge_index=[2, 800], num_nodes=100, x=[100, 101], cluster=[100], y=[100, 2], train_mask=[100], val_mask=[100], test_mask=[100])\n" ] } ], diff --git a/docs/tutorial/advanced/02-02-trp-centralities-communities.ipynb b/docs/tutorial/advanced/02-02-trp-centralities-communities.ipynb index 3d0c66b3..b21f6428 100644 --- a/docs/tutorial/advanced/02-02-trp-centralities-communities.ipynb +++ b/docs/tutorial/advanced/02-02-trp-centralities-communities.ipynb @@ -3840,7 +3840,7 @@ "source": [ "## Visualizing Temporal Communities\n", "\n", - "Time-respecting paths also let us uncover **temporal communities**: groups of nodes that are connected by many time-respecting paths, even in cases where this pattern is not visible in the topology of the time-aggregated graph. This is exactly the same phenomenon that motivates the higher-order De Bruijn graph models used in the previous notebooks, but here we take a complementary, simulation-based perspective.\n", + "Time-respecting paths also let us uncover **temporal communities**: groups of nodes that are connected by many time-respecting paths, even in cases where this pattern is not visible in the topology of the time-aggregated graph. This is exactly the same phenomenon that motivates the higher-order De Bruijn graph models that we introduce in the following notebooks, but here we take a complementary, simulation-based perspective.\n", "\n", "We load a synthetic temporal graph with 30 nodes and 60,000 time-stamped interactions, which was generated with a planted community structure (nodes 0-9, 10-19, and 20-29 each form a temporal community). Note that the CSV file uses the column names `source`, `target`, `time` instead of the `v`, `w`, `t` expected by `pp.io.read_csv_temporal_graph`, so we read it with `pandas` and rename the columns ourselves before converting it to a `TemporalGraph`:" ] diff --git a/docs/tutorial/advanced/02-03-trp-event-graphs.ipynb b/docs/tutorial/advanced/02-03-trp-event-graphs.ipynb index d1559880..434a5800 100644 --- a/docs/tutorial/advanced/02-03-trp-event-graphs.ipynb +++ b/docs/tutorial/advanced/02-03-trp-event-graphs.ipynb @@ -17,9 +17,9 @@ "source": [ "## Motivation\n", "\n", - "In the previous tutorial, we have seen how we can use higher-order models to model paths in complex networks. In this example, paths were directly given in terms of sequences of nodes traversed by some process (like a random walk). We have further seen that higher-order De Bruijn graph models can be used to capture patterns that influence the **causal topology** of a complex network, i.e. which nodes can possibly influence each other via paths. The same is true for time-respecting paths in a temporal graph. Due to the fact that time-stamped edges need to occur in the correct temporal ordering (and within a given time interval based on the maximum time difference $\\delta$), the causal topology given by time-respecting paths can be very different from what we would expect from the (static) topology of links.\n", + "In the tutorial on paths and higher-order models, we have seen how we can use higher-order models to model paths in complex networks. In this example, paths were directly given in terms of sequences of nodes traversed by some process (like a random walk). We have further seen that higher-order De Bruijn graph models can be used to capture patterns that influence the **causal topology** of a complex network, i.e. which nodes can possibly influence each other via paths. The same is true for time-respecting paths in a temporal graph. Due to the fact that time-stamped edges need to occur in the correct temporal ordering (and within a given time interval based on the maximum time difference $\\delta$), the causal topology given by time-respecting paths can be very different from what we would expect from the (static) topology of links.\n", "\n", - "In the following, we will show how we can easiy and efficiently construct higher-order models for time-respecting paths in a temporal graph. To illustrate this, we use the same toy example as before:" + "In the following, we will show how we can easily and efficiently construct higher-order models for time-respecting paths in a temporal graph. To illustrate this, we use the same toy example as before:" ] }, { @@ -400,7 +400,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "As you can see, these time-respecting paths are actually very similar to the paths data that we have previously represented using the `PathData` object. In fact, we could - in theory - first extract all time-respecting paths of all lengths, add them to a `PathData` object and then use the `MultiOderModel` class to generate higher-order De Bruijn graph models of all orders. In the example above, since we have paths of length one to four, we could create higher-order models with orders from one to four. \n", + "As you can see, these time-respecting paths are actually very similar to the paths data that we have previously represented using the `PathData` object. In fact, we could - in theory - first extract all time-respecting paths of all lengths, add them to a `PathData` object and then use the `MultiOrderModel` class to generate higher-order De Bruijn graph models of all orders. In the example above, since we have paths of length one to four, we could create higher-order models with orders from one to four. \n", "\n", "However, this approach would not be efficient for large temporal graphs, as it is computationally expensive to calculate all possible time-respecting paths as well as subpaths of length $k$, especially for larger values of $\\delta$. To avoid this bottleneck, `pathpyG` uses a smarter, GPU-based algorithm to calculate time-respecting paths of length $k$ that are needed for a given order $k$.\n", "\n", @@ -423,11 +423,13 @@ "name": "stdout", "output_type": "stream", "text": [ - "Directed graph with 6 nodes and 4 edges\n", + "Higher-order graph of order 3 with 6 nodes and 4 edges\n", + "(over 4 first-order nodes)\n", "{ 'Edge Attributes': {'edge_weight': \" -> torch.Size([4])\"},\n", " 'Graph Attributes': {'inverse_idx': \" -> torch.Size([7])\", 'num_nodes': \"\"},\n", " 'Node Attributes': {}}\n", - "Directed graph with 4 nodes and 2 edges\n", + "Higher-order graph of order 4 with 4 nodes and 2 edges\n", + "(over 4 first-order nodes)\n", "{'Edge Attributes': {'edge_weight': \" -> torch.Size([2])\"}, 'Graph Attributes': {'num_nodes': \"\"}, 'Node Attributes': {}}\n" ] }, @@ -464,7 +466,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Directed graph with 4 nodes and 6 edges\n", + "Higher-order graph of order 1 with 4 nodes and 6 edges\n", + "(over 4 first-order nodes)\n", "{'Edge Attributes': {'edge_weight': \" -> torch.Size([6])\"}, 'Graph Attributes': {'num_nodes': \"\"}, 'Node Attributes': {}}\n" ] }, @@ -769,7 +772,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Directed graph with 6 nodes and 6 edges\n", + "Higher-order graph of order 2 with 6 nodes and 6 edges\n", + "(over 4 first-order nodes)\n", "{ 'Edge Attributes': {'edge_weight': \" -> torch.Size([6])\"},\n", " 'Graph Attributes': {'inverse_idx': \" -> torch.Size([10])\", 'num_nodes': \"\"},\n", " 'Node Attributes': {}}\n", @@ -1078,7 +1082,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Directed graph with 6 nodes and 4 edges\n", + "Higher-order graph of order 3 with 6 nodes and 4 edges\n", + "(over 4 first-order nodes)\n", "{ 'Edge Attributes': {'edge_weight': \" -> torch.Size([4])\"},\n", " 'Graph Attributes': {'inverse_idx': \" -> torch.Size([7])\", 'num_nodes': \"\"},\n", " 'Node Attributes': {}}\n" @@ -1385,7 +1390,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Directed graph with 4 nodes and 2 edges\n", + "Higher-order graph of order 4 with 4 nodes and 2 edges\n", + "(over 4 first-order nodes)\n", "{'Edge Attributes': {'edge_weight': \" -> torch.Size([2])\"}, 'Graph Attributes': {'num_nodes': \"\"}, 'Node Attributes': {}}\n" ] }, @@ -1678,7 +1684,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Intuitively, since in our example there are no time-respecting paths longer than four, if we were to generate a multi-order model with De Bruijn graphs with orders larger than four, those graphs cannot contain any edges. We see this in the following example. The first-order graph is simply the time-aggregated weighted graph, i.e. the number of nodes is equal to the number of nodes in the temporal graph and the number of edges is equal to the number of different time-stamped edges. In each graph of order $k>1$, the number of nodes corresponds to the number of edges in the graph with order $k-1$, since each of those nodes corresponds to a time-respecting path of length $k-1$, which are represented by edges in a $k-1$-th order gaph. This implies that the graph with order five has two nodes, which are the two time-respecting paths of length four. Those nodes are not connected since there is no time-respecting path with length five." + "Intuitively, since in our example there are no time-respecting paths longer than four, if we were to generate a multi-order model with De Bruijn graphs with orders larger than four, those graphs cannot contain any edges. We see this in the following example. The first-order graph is simply the time-aggregated weighted graph, i.e. the number of nodes is equal to the number of nodes in the temporal graph and the number of edges is equal to the number of different time-stamped edges. In each graph of order $k>1$, the number of nodes corresponds to the number of edges in the graph with order $k-1$, since each of those nodes corresponds to a time-respecting path of length $k-1$, which are represented by edges in a $k-1$-th order graph. This implies that the graph with order five has two nodes, which are the two time-respecting paths of length four. Those nodes are not connected since there is no time-respecting path with length five." ] }, { @@ -1697,9 +1703,11 @@ "name": "stdout", "output_type": "stream", "text": [ - "Directed graph with 4 nodes and 2 edges\n", + "Higher-order graph of order 4 with 4 nodes and 2 edges\n", + "(over 4 first-order nodes)\n", "{'Edge Attributes': {'edge_weight': \" -> torch.Size([2])\"}, 'Graph Attributes': {'num_nodes': \"\"}, 'Node Attributes': {}}\n", - "Directed graph with 2 nodes and 0 edges\n", + "Higher-order graph of order 5 with 2 nodes and 0 edges\n", + "(over 4 first-order nodes)\n", "{'Edge Attributes': {'edge_weight': \" -> torch.Size([0])\"}, 'Graph Attributes': {'num_nodes': \"\"}, 'Node Attributes': {}}\n" ] }, @@ -2004,7 +2012,7 @@ "source": [ "## Constructing Higher-Order De Bruijn Graph Models for Empirical Temporal Networks\n", "\n", - "Let us now use `pathpyG` to construcct higher-order De Bruijn graph models for time-respecting paths in empirical temporal network. For this, we first read a number of temporal graphs using `TemporalGraph.from_csv`. In the following, we use the following three publicly available data sets:\n", + "Let us now use `pathpyG` to construct higher-order De Bruijn graph models for time-respecting paths in empirical temporal networks. For this, we first read a number of temporal graphs using `TemporalGraph.from_csv`. In the following, we use the following three publicly available data sets:\n", "\n", "- Antenna interactions between ants in a colony [(Blonder and Dornhaus, 2011)](https://pubmed.ncbi.nlm.nih.gov/21625450/) \n", "- E-Mail exchanges in a manufacturing company [(Nurek and Michalski, 2020)](https://www.ii.pwr.edu.pl/~michalski/index.php?content=datasets) \n", @@ -2073,17 +2081,21 @@ "name": "stdout", "output_type": "stream", "text": [ - "Directed graph with 89 nodes and 947 edges\n", + "Higher-order graph of order 1 with 89 nodes and 947 edges\n", + "(over 89 first-order nodes)\n", "{'Edge Attributes': {'edge_weight': \" -> torch.Size([947])\"}, 'Graph Attributes': {'num_nodes': \"\"}, 'Node Attributes': {}}\n", - "Directed graph with 947 nodes and 1780 edges\n", + "Higher-order graph of order 2 with 947 nodes and 1780 edges\n", + "(over 89 first-order nodes)\n", "{ 'Edge Attributes': {'edge_weight': \" -> torch.Size([1780])\"},\n", " 'Graph Attributes': {'inverse_idx': \" -> torch.Size([1911])\", 'num_nodes': \"\"},\n", " 'Node Attributes': {}}\n", - "Directed graph with 1780 nodes and 2410 edges\n", + "Higher-order graph of order 3 with 1780 nodes and 2410 edges\n", + "(over 89 first-order nodes)\n", "{ 'Edge Attributes': {'edge_weight': \" -> torch.Size([2410])\"},\n", " 'Graph Attributes': {'inverse_idx': \" -> torch.Size([2518])\", 'num_nodes': \"\"},\n", " 'Node Attributes': {}}\n", - "Directed graph with 2410 nodes and 3292 edges\n", + "Higher-order graph of order 4 with 2410 nodes and 3292 edges\n", + "(over 89 first-order nodes)\n", "{ 'Edge Attributes': {'edge_weight': \" -> torch.Size([3292])\"},\n", " 'Graph Attributes': {'inverse_idx': \" -> torch.Size([3572])\", 'num_nodes': \"\"},\n", " 'Node Attributes': {}}\n" @@ -2126,28 +2138,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "Directed graph with 327 nodes and 5818 edges\n", - "\n", - "Node attributes\n", - "\tnode_sequence\t\t -> torch.Size([327, 1])\n", - "\n", - "Edge attributes\n", - "\tedge_weight\t\t -> torch.Size([5818])\n", - "\n", - "Graph attributes\n", - "\tnum_nodes\t\t\n", - "\n", - "Directed graph with 16307 nodes and 8712 edges\n", - "\n", - "Node attributes\n", - "\tnode_sequence\t\t -> torch.Size([16307, 5])\n", - "\n", - "Edge attributes\n", - "\tedge_weight\t\t -> torch.Size([8712])\n", - "\n", - "Graph attributes\n", - "\tnum_nodes\t\t\n", - "\n" + "Higher-order graph of order 1 with 327 nodes and 5818 edges\n", + "(over 327 first-order nodes)\n", + "{'Edge Attributes': {'edge_weight': \" -> torch.Size([5818])\"}, 'Graph Attributes': {'num_nodes': \"\"}, 'Node Attributes': {}}\n", + "Higher-order graph of order 5 with 24550 nodes and 17433 edges\n", + "(over 327 first-order nodes)\n", + "{ 'Edge Attributes': {'edge_weight': \" -> torch.Size([17433])\"},\n", + " 'Graph Attributes': {'inverse_idx': \" -> torch.Size([12314285])\", 'num_nodes': \"\"},\n", + " 'Node Attributes': {}}\n" ] } ], diff --git a/docs/tutorial/advanced/02-05-tgn-baseline.ipynb b/docs/tutorial/advanced/02-05-tgn-baseline.ipynb index a77355dd..ce64e0a7 100644 --- a/docs/tutorial/advanced/02-05-tgn-baseline.ipynb +++ b/docs/tutorial/advanced/02-05-tgn-baseline.ipynb @@ -373,9 +373,9 @@ "source": [ "## Conclusion\n", "\n", - "Both checks tell the same story: the mean within-community and cross-community distances are essentially identical, the Adjusted Rand Index is close to zero (no better than a random clustering), and the visualization shows the three communities scattered on top of each other rather than forming separated clusters. Unlike the causality-aware DBGNN model from the previous notebook, TGN **fails to recover the planted temporal communities** in this data set, despite being trained on the exact same interactions.\n", + "Both checks tell the same story: the mean within-community and cross-community distances are essentially identical, the Adjusted Rand Index is close to zero (no better than a random clustering), and the visualization shows the three communities scattered on top of each other rather than forming separated clusters. TGN **fails to recover the planted temporal communities** in this data set, despite being trained on the exact same interactions.\n", "\n", - "The reason is architectural: TGN's memory update and its graph attention embedding only ever look at a node's own history and its most recent, *direct* neighbors. It has no mechanism to explicitly aggregate information along longer chains of time-respecting interactions that connect nodes several hops apart in time. Since the community signal in this data set is encoded purely in such longer time-respecting paths (recall that the static, time-aggregated topology is statistically indistinguishable from a random graph), a model that only reasons locally and pairwise, however well it models the timing of interactions, simply has no access to the relevant signal. This supports the conclusion that DBGNN's success in the previous notebook is not just a matter of being *temporally aware* in some general sense, but specifically relies on its explicit, higher-order modelling of time-respecting paths via De Bruijn graphs." + "The reason is architectural: TGN's memory update and its graph attention embedding only ever look at a node's own history and its most recent, *direct* neighbors. It has no mechanism to explicitly aggregate information along longer chains of time-respecting interactions that connect nodes several hops apart in time. Since the community signal in this data set is encoded purely in such longer time-respecting paths (recall that the static, time-aggregated topology is statistically indistinguishable from a random graph), a model that only reasons locally and pairwise, however well it models the timing of interactions, simply has no access to the relevant signal. In the next notebook, we will see that the causality-aware DBGNN model does recover the communities from the same interactions. This supports the conclusion that DBGNN's success is not just a matter of being *temporally aware* in some general sense, but specifically relies on its explicit, higher-order modelling of time-respecting paths via De Bruijn graphs." ] } ], diff --git a/docs/tutorial/advanced/02-06-dbgnn.ipynb b/docs/tutorial/advanced/02-06-dbgnn.ipynb index c75ee40c..5ebd1bbb 100644 --- a/docs/tutorial/advanced/02-06-dbgnn.ipynb +++ b/docs/tutorial/advanced/02-06-dbgnn.ipynb @@ -1210,11 +1210,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "In fact, the topology of this graph corresponds to that of a random graph, i.e. there are not patterns whatsoever in the topology of links. Nevertheless, the temporal graph contains a cluster pattern in the topology of causal or time-respecting paths. In particular, the temporal ordering of time-stamped edges is such that nodes with the same cluster label are more frequently connected by time-respecting paths than nodes with different cluster labels. Hence, nodes within the same clusters can more strongly influence each other in a causal way, i.e. via multiple interactions that follow the arrow of time. \n", + "In fact, the topology of this graph corresponds to that of a random graph, i.e. there are no patterns whatsoever in the topology of links. Nevertheless, the temporal graph contains a cluster pattern in the topology of causal or time-respecting paths. In particular, the temporal ordering of time-stamped edges is such that nodes with the same cluster label are more frequently connected by time-respecting paths than nodes with different cluster labels. Hence, nodes within the same clusters can more strongly influence each other in a causal way, i.e. via multiple interactions that follow the arrow of time. \n", "\n", "Traditional (temporal) graph neural networks will not be able to learn from this pattern, as it is due to the specific microscopic temporal ordering of edges. Using higher-order De Bruijn graph models implemented in pathpyG, we can learn from temporal graph data that contains such patterns. Let us explain this step by step.\n", "\n", - "Referring to the previous tutorial on causal paths in temporal graphs, we first create a node-time directed acyclic graph that captures the causal structure of the temporal graph. In this small example, we will only consider two time-stamped edges $(u,v;t)$ and $(v,w;t')$ to contribute to a causal path iff $0 < t'-t \\leq 1$, i.e. we use a delta for the maximum time difference of one time step." + "Building on the previous notebooks on time-respecting paths in temporal graphs, we directly construct a multi-order model whose second-order layer captures the causal structure of the temporal graph. We only consider two time-stamped edges $(u,v;t)$ and $(v,w;t')$ to contribute to a causal path iff $0 < t'-t \\leq 1$, i.e. we use the default maximum time difference $\\delta=1$." ] }, { @@ -1253,7 +1253,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We can get the first and second order networks from the Multi Order Network object. The first order network is the network of nodes and edges, while the second-order network uses first-order edges as nodes and edges represent time-respecting paths of length two. The second order network is a De Bruijn graph that captures temporal-topological patterns in the network." + "We can get the first and second order networks from the `MultiOrderModel` object. The first order network is the network of nodes and edges, while the second-order network uses first-order edges as nodes and edges represent time-respecting paths of length two. The second order network is a De Bruijn graph that captures temporal-topological patterns in the network." ] }, { @@ -1825,7 +1825,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Directed graph with 557 nodes and 1965 edges\n", + "Higher-order graph of order 2 with 557 nodes and 1965 edges\n", + "(over 30 first-order nodes)\n", "{ 'Edge Attributes': {'edge_weight': \" -> torch.Size([1965])\"},\n", " 'Graph Attributes': {'inverse_idx': \" -> torch.Size([60000])\", 'num_nodes': \"\"},\n", " 'Node Attributes': {}}\n" From 79aa65afc6547ef741f5b73ec41867be3cecc26e Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Mon, 21 Sep 2026 15:46:29 -0400 Subject: [PATCH 15/17] tests for HigherOrderGraph added; some tweaks to existing tests --- src/pathpyG/core/higher_order_graph.py | 12 +- tests/algorithms/test_lift_order.py | 21 +++ tests/core/test_event_graph.py | 2 +- tests/core/test_graph.py | 7 + tests/core/test_higher_order_graph.py | 218 +++++++++++++++++++++++++ tests/core/test_multi_order_model.py | 75 +++++++++ tests/core/test_temporal_graph.py | 6 + 7 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 tests/core/test_higher_order_graph.py diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py index 439166d5..f062d6d4 100644 --- a/src/pathpyG/core/higher_order_graph.py +++ b/src/pathpyG/core/higher_order_graph.py @@ -574,15 +574,19 @@ def __add__(self, other: Graph, reduce: str = "sum") -> HigherOrderGraph: if other.order != self.order: raise ValueError(f"cannot combine graphs of order {self.order} and {other.order}") + fo_self, fo_other = self.first_order_mapping, other.first_order_mapping + if self.order != 1 and fo_self.has_ids != fo_other.has_ids: + # The paths of one graph would be named by IDs and those of the other by indices. + raise ValueError("cannot combine graphs where only one has first-order node IDs") + data, mapping = self._add_data(other, reduce) device = data.edge_index.device - fo_self, fo_other = self.first_order_mapping, other.first_order_mapping if self.order == 1: # Order-1 nodes are first-order nodes, so the joint mapping is the first-order mapping. first_order_mapping = mapping n_first_order = data.num_nodes - elif fo_self.has_ids and fo_other.has_ids: + elif fo_self.has_ids: if np.array_equal(fo_self.node_ids, fo_other.node_ids): # type: ignore[arg-type] first_order_mapping = fo_self else: @@ -590,11 +594,9 @@ def __add__(self, other: Graph, reduce: str = "sum") -> HigherOrderGraph: np.unique(np.concatenate([fo_self.node_ids, fo_other.node_ids])).tolist() ) n_first_order = first_order_mapping.num_ids() - elif not fo_self.has_ids and not fo_other.has_ids: + else: first_order_mapping = IndexMap() n_first_order = max(self.n_first_order, other.n_first_order) - else: - raise ValueError("cannot combine graphs where only one has first-order node IDs") # Rebuild the node sequence from the joint mapping, whose IDs are the paths. if self.order == 0: diff --git a/tests/algorithms/test_lift_order.py b/tests/algorithms/test_lift_order.py index 5e01c51d..735b3230 100644 --- a/tests/algorithms/test_lift_order.py +++ b/tests/algorithms/test_lift_order.py @@ -1,5 +1,6 @@ import pytest import torch +from torch_geometric.data import Data from pathpyG.algorithms.lift_order import ( aggregate_edge_index, @@ -77,3 +78,23 @@ def test_aggregate_edge_index(): assert d.edge_index.tolist() == [[0, 0, 1], [1, 2, 0]] assert d.edge_weight.tolist() == [3, 3, 4] assert d.node_sequence.tolist() == [[1, 2], [2, 3], [4, 5]] + + +def test_aggregate_edge_index_returns_data(): + d = aggregate_edge_index(edge_index=torch.tensor([[0], [1]]), node_sequence=torch.tensor([[0, 1], [1, 2]])) + assert isinstance(d, Data) + assert d.inverse_idx.tolist() == [0, 1] + assert d.edge_weight.tolist() == [1.0] + + +def test_aggregate_edge_index_first_order_with_unvisited_node(): + """First-order indices are kept as node indices, even if a lower index is never visited.""" + # first-order nodes 0..4; node 1 is never visited; the steps are 0->4 and 2->0 + d = aggregate_edge_index( + edge_index=torch.tensor([[0, 2], [1, 3]]), node_sequence=torch.tensor([[0], [4], [2], [0]]) + ) + assert d.num_nodes == 5 + assert d.edge_index.tolist() == [[0, 2], [4, 0]] + assert d.edge_weight.tolist() == [1.0, 1.0] + assert d.node_sequence.tolist() == [[0], [1], [2], [3], [4]] + assert d.inverse_idx.tolist() == [0, 4, 2, 0] diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py index 039bc734..c66294fe 100644 --- a/tests/core/test_event_graph.py +++ b/tests/core/test_event_graph.py @@ -384,4 +384,4 @@ def test_construct_from_data_to_temporal_graph(event_data): 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 + assert tg.data.time.tolist() == [1, 2, 3, 5] diff --git a/tests/core/test_graph.py b/tests/core/test_graph.py index a7c2848f..f3801a52 100644 --- a/tests/core/test_graph.py +++ b/tests/core/test_graph.py @@ -25,6 +25,13 @@ def test_init(): assert g.order == 1 +def test_init_rejects_node_sequence(): + """A Graph is always first-order; graphs whose nodes are paths are HigherOrderGraphs.""" + data = Data(edge_index=torch.tensor([[0, 1], [1, 2]]), num_nodes=3, node_sequence=torch.tensor([[0], [1], [2]])) + with pytest.raises(ValueError): + Graph(data) + + def test_init_with_edge_index(): edge_index = EdgeIndex(get_random_edge_index(100, 100, 1000)) data = Data(edge_index=edge_index, num_nodes=100) diff --git a/tests/core/test_higher_order_graph.py b/tests/core/test_higher_order_graph.py new file mode 100644 index 00000000..7917b2df --- /dev/null +++ b/tests/core/test_higher_order_graph.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import pytest +import torch +from torch_geometric.data import Data + +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 +from pathpyG.core.temporal_graph import TemporalGraph + +# Paths used in the tests: +# +# a -> c -> d (weight 3) +# b -> c -> d (weight 1) +# +# Node e is part of the mapping but never visited. Weighted visit counts are +# a: 3, b: 1, c: 4, d: 4, e: 0 (12 visits in total). + + +@pytest.fixture +def paths() -> PathData: + paths = PathData(IndexMap(list("abcde"))) + paths.append_walk(("a", "c", "d"), weight=3) + paths.append_walk(("b", "c", "d"), weight=1) + return paths + + +@pytest.fixture +def order_zero() -> HigherOrderGraph: + return HigherOrderGraph.from_node_weights( + torch.tensor([3.0, 1.0, 4.0, 4.0, 0.0]), first_order_mapping=IndexMap(list("abcde")) + ) + + +def test_is_graph(paths): + h = HigherOrderGraph.from_path_data(paths, order=2) + assert isinstance(h, Graph) + + +def test_node_sequence_is_internal(paths): + h = HigherOrderGraph.from_path_data(paths, order=2) + assert "node_sequence" in h.data + assert "node_sequence" not in h.node_attrs() + + +def test_init_creates_identity_node_sequence_for_order_one(): + data = Data(edge_index=torch.tensor([[0, 1], [1, 2]]), num_nodes=3) + h = HigherOrderGraph(data) + assert h.order == 1 + assert h.data.node_sequence.tolist() == [[0], [1], [2]] + assert h.n_first_order == 3 + + +def test_to_device(paths): + h = MultiOrderModel.from_path_data(paths, max_order=2).layers[2] + moved = h.to(torch.device("cpu")) + assert moved is h + assert h.data.node_sequence.device.type == "cpu" + assert h.data.inverse_idx.device.type == "cpu" + + +# --------------------------------------------------------------------------- +# Order 0 +# --------------------------------------------------------------------------- + + +def test_from_node_weights(order_zero): + assert order_zero.order == 0 + assert order_zero.n == 1 + assert order_zero.m == 5 + assert order_zero.nodes == [()] + assert order_zero.n_first_order == 5 + assert order_zero.mapping.has_tuple_ids + assert order_zero.data.node_sequence.shape == (1, 0) + # one self-loop per first-order node, including the unvisited node e + assert order_zero.data.edge_index.as_tensor().tolist() == [[0] * 5, [0] * 5] + assert sorted(zip(order_zero.data.edge_first_order_node.tolist(), order_zero.data.edge_weight.tolist())) == [ + (0, 3.0), + (1, 1.0), + (2, 4.0), + (3, 4.0), + (4, 0.0), + ] + + +def test_order_zero_transition_probabilities_are_visit_probabilities(order_zero): + probabilities = order_zero.transition_probabilities(edge_attr="edge_weight") + by_node = dict(zip(order_zero.data.edge_first_order_node.tolist(), probabilities.tolist())) + expected = {0: 3 / 12, 1: 1 / 12, 2: 4 / 12, 3: 4 / 12, 4: 0.0} + assert by_node == pytest.approx(expected) + + +def order_zero_data(**kwargs) -> Data: + data = { + "edge_index": torch.zeros((2, 2), dtype=torch.long), + "num_nodes": 1, + "node_sequence": torch.empty((1, 0), dtype=torch.long), + "edge_first_order_node": torch.tensor([0, 1]), + "edge_weight": torch.tensor([1.0, 1.0]), + } + data.update(kwargs) + return Data(**{k: v for k, v in data.items() if v is not None}) + + +def test_order_zero_requires_n_first_order(): + with pytest.raises(ValueError): + HigherOrderGraph(order_zero_data()) + assert HigherOrderGraph(order_zero_data(), n_first_order=2).order == 0 + + +def test_order_zero_has_at_most_one_node(): + data = order_zero_data(num_nodes=2, node_sequence=torch.empty((2, 0), dtype=torch.long)) + with pytest.raises(ValueError): + HigherOrderGraph(data, n_first_order=2) + + +def test_order_zero_cannot_be_projected(order_zero): + with pytest.raises(ValueError): + order_zero.to_first_order() + with pytest.raises(ValueError): + order_zero.bipartite_edge_index() + + +def test_from_path_data_order_zero(paths): + h = HigherOrderGraph.from_path_data(paths, order=0) + assert h.order == 0 + assert sorted(zip(h.data.edge_first_order_node.tolist(), h.data.edge_weight.tolist())) == [ + (0, 3.0), + (1, 1.0), + (2, 4.0), + (3, 4.0), + (4, 0.0), + ] + + +def test_from_temporal_graph_rejects_order_zero(): + t = TemporalGraph.from_edge_list([("a", "b", 1), ("b", "c", 2)]) + with pytest.raises(ValueError): + HigherOrderGraph.from_temporal_graph(t, order=0) + + +def test_str_order_zero(order_zero): + assert str(order_zero).startswith("Higher-order graph of order 0 with 1 nodes and 5 edges") + + +# --------------------------------------------------------------------------- +# Order 1 +# --------------------------------------------------------------------------- + + +def test_aggregate_keeps_unvisited_first_order_nodes(): + # First-order nodes 0..4, node 1 is never visited; the steps are 0->4 and 2->0. + h = HigherOrderGraph.aggregate( + torch.tensor([[0, 2], [1, 3]]), + torch.tensor([[0], [4], [2], [0]]), + first_order_mapping=IndexMap(list("abcde")), + ) + assert h.order == 1 + assert h.n == 5 + assert h.edges == [("a", "e"), ("c", "a")] + assert h.data.edge_weight.tolist() == [1.0, 1.0] + + +# --------------------------------------------------------------------------- +# Combining higher-order graphs +# --------------------------------------------------------------------------- + + +def test_add_order_zero(): + g1 = HigherOrderGraph.from_node_weights(torch.tensor([1.0, 2.0, 3.0]), first_order_mapping=IndexMap(list("abc"))) + g2 = HigherOrderGraph.from_node_weights(torch.tensor([4.0, 5.0]), first_order_mapping=IndexMap(list("cd"))) + g = g1 + g2 + + assert isinstance(g, HigherOrderGraph) + assert g.order == 0 + assert g.nodes == [()] + assert g.n_first_order == 4 + assert g.first_order_mapping.node_ids.tolist() == list("abcd") + # emitted nodes are re-indexed to the joint first-order mapping; loops are kept as multi-edges + assert sorted(zip(g.data.edge_first_order_node.tolist(), g.data.edge_weight.tolist())) == [ + (0, 1.0), + (1, 2.0), + (2, 3.0), + (2, 4.0), + (3, 5.0), + ] + + +def test_add_order_one(paths): + h = HigherOrderGraph.from_path_data(paths, order=1) + g = h + h + assert isinstance(g, HigherOrderGraph) + assert g.order == 1 + assert g.data.node_sequence.tolist() == [[i] for i in range(g.n)] + + +def test_add_rebuilds_node_sequence(paths): + h = HigherOrderGraph.from_path_data(paths, order=2) + other = PathData(IndexMap(list("abcde"))) + other.append_walk(("c", "e", "a")) + g = h + HigherOrderGraph.from_path_data(other, order=2) + + assert g.order == 2 + for node, seq in zip(g.nodes, g.data.node_sequence.tolist()): + assert tuple(g.first_order_mapping.to_ids(seq).tolist()) == node + + +def test_add_rejects_different_orders(paths): + with pytest.raises(ValueError): + HigherOrderGraph.from_path_data(paths, order=1) + HigherOrderGraph.from_path_data(paths, order=2) + + +def test_add_rejects_plain_graph(paths): + with pytest.raises(TypeError): + HigherOrderGraph.from_path_data(paths, order=1) + Graph.from_edge_list([("a", "b")]) diff --git a/tests/core/test_multi_order_model.py b/tests/core/test_multi_order_model.py index 7e7d57a3..95c56072 100644 --- a/tests/core/test_multi_order_model.py +++ b/tests/core/test_multi_order_model.py @@ -1,6 +1,7 @@ # pylint: disable=missing-function-docstring,missing-module-docstring import numpy as np +import pytest import torch from scipy.stats import chi2 from torch_geometric import EdgeIndex @@ -206,3 +207,77 @@ def test_paths_indexing(): max_order=max_order ) assert detected_order == 2 + + +def weighted_paths() -> PathData: + """Return the walks a -> c -> d (weight 3) and b -> c -> d (weight 1); e is never visited.""" + paths = PathData(IndexMap(list("abcde"))) + paths.append_walk(("a", "c", "d"), weight=3) + paths.append_walk(("b", "c", "d"), weight=1) + return paths + + +def test_from_path_data_builds_order_zero_layer(): + m = MultiOrderModel.from_path_data(weighted_paths(), max_order=2) + assert sorted(m.layers) == [0, 1, 2] + + g0 = m.layers[0] + assert g0.order == 0 + assert g0.n_first_order == 5 + # visits weighted by path frequency: a 3, b 1, c 4, d 4, e 0 + weights = dict(zip(g0.data.edge_first_order_node.tolist(), g0.data.edge_weight.tolist())) + assert weights == {0: 3.0, 1: 1.0, 2: 4.0, 3: 4.0, 4: 0.0} + + +def test_from_path_data_uncached_keeps_orders_zero_and_one(): + paths = PathData(IndexMap(list("abcd"))) + paths.append_walk(("a", "b", "c", "d")) + assert sorted(MultiOrderModel.from_path_data(paths, max_order=3, cached=False).layers) == [0, 1, 3] + assert sorted(MultiOrderModel.from_path_data(paths, max_order=3, cached=True).layers) == [0, 1, 2, 3] + + +def test_from_path_data_with_unvisited_first_order_node(): + """A first-order node with a lower index than the visited ones is never visited (b).""" + paths = PathData(IndexMap(list("abcde"))) + paths.append_walk(("a", "e")) + paths.append_walk(("c", "a")) + g1 = MultiOrderModel.from_path_data(paths, max_order=1).layers[1] + assert g1.n == 5 + assert g1.edges == [("a", "e"), ("c", "a")] + assert g1.data.edge_weight.tolist() == [1.0, 1.0] + + +def test_zeroth_order_log_likelihood_is_weighted(): + paths = weighted_paths() + m = MultiOrderModel.from_path_data(paths, max_order=1) + # path starts: a (weight 3) and b (weight 1); visit probabilities a 3/12, b 1/12 + expected = 3 * np.log(3 / 12) + 1 * np.log(1 / 12) + assert np.isclose(m.get_zeroth_order_log_likelihood(paths.data), expected) + + +def test_log_likelihood_order_zero_with_unvisited_node(): + paths = weighted_paths() + m = MultiOrderModel.from_path_data(paths, max_order=1) + # every visit is a draw from the order-0 layer; the unvisited node e contributes nothing + expected = 3 * np.log(3 / 12) + 1 * np.log(1 / 12) + 8 * np.log(4 / 12) + assert np.isclose(m.get_mon_log_likelihood(paths.data, max_order=0), expected) + + +def test_dof_order_zero_counts_unvisited_nodes(): + m = MultiOrderModel.from_path_data(weighted_paths(), max_order=1) + assert m.get_mon_dof(max_order=0) == 4 + + +def test_from_temporal_graph_rejects_order_zero(simple_temporal_graph): + with pytest.raises(ValueError): + MultiOrderModel.from_temporal_graph(simple_temporal_graph, max_order=0) + + +def test_temporal_model_has_no_order_zero_layer(simple_temporal_graph): + m = MultiOrderModel.from_temporal_graph(simple_temporal_graph, max_order=2, delta=4) + assert 0 not in m.layers + # the zeroth-order degrees of freedom do not need the layer + assert m.get_mon_dof(max_order=0) == simple_temporal_graph.n - 1 + # the likelihoods do + with pytest.raises(ValueError): + m.get_mon_log_likelihood(weighted_paths().data, max_order=0) diff --git a/tests/core/test_temporal_graph.py b/tests/core/test_temporal_graph.py index 006f5448..bc095121 100644 --- a/tests/core/test_temporal_graph.py +++ b/tests/core/test_temporal_graph.py @@ -153,3 +153,9 @@ def test_get_window(long_temporal_graph): def test_str(simple_temporal_graph): assert str(simple_temporal_graph) + + +def test_order(): + t = TemporalGraph.from_edge_list([("a", "b", 1), ("b", "c", 2)]) + assert t.order == 1 + assert "node_sequence" not in t.data From 7dc5fc83986a9763ddb6cadc122bd061e100d859 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Mon, 21 Sep 2026 15:46:29 -0400 Subject: [PATCH 16/17] tests for HigherOrderGraph added; some tweaks to existing tests --- src/pathpyG/core/higher_order_graph.py | 12 +- tests/algorithms/test_lift_order.py | 21 +++ tests/core/test_event_graph.py | 2 +- tests/core/test_graph.py | 7 + tests/core/test_higher_order_graph.py | 218 +++++++++++++++++++++++++ tests/core/test_multi_order_model.py | 75 +++++++++ tests/core/test_temporal_graph.py | 6 + 7 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 tests/core/test_higher_order_graph.py diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py index 439166d5..f062d6d4 100644 --- a/src/pathpyG/core/higher_order_graph.py +++ b/src/pathpyG/core/higher_order_graph.py @@ -574,15 +574,19 @@ def __add__(self, other: Graph, reduce: str = "sum") -> HigherOrderGraph: if other.order != self.order: raise ValueError(f"cannot combine graphs of order {self.order} and {other.order}") + fo_self, fo_other = self.first_order_mapping, other.first_order_mapping + if self.order != 1 and fo_self.has_ids != fo_other.has_ids: + # The paths of one graph would be named by IDs and those of the other by indices. + raise ValueError("cannot combine graphs where only one has first-order node IDs") + data, mapping = self._add_data(other, reduce) device = data.edge_index.device - fo_self, fo_other = self.first_order_mapping, other.first_order_mapping if self.order == 1: # Order-1 nodes are first-order nodes, so the joint mapping is the first-order mapping. first_order_mapping = mapping n_first_order = data.num_nodes - elif fo_self.has_ids and fo_other.has_ids: + elif fo_self.has_ids: if np.array_equal(fo_self.node_ids, fo_other.node_ids): # type: ignore[arg-type] first_order_mapping = fo_self else: @@ -590,11 +594,9 @@ def __add__(self, other: Graph, reduce: str = "sum") -> HigherOrderGraph: np.unique(np.concatenate([fo_self.node_ids, fo_other.node_ids])).tolist() ) n_first_order = first_order_mapping.num_ids() - elif not fo_self.has_ids and not fo_other.has_ids: + else: first_order_mapping = IndexMap() n_first_order = max(self.n_first_order, other.n_first_order) - else: - raise ValueError("cannot combine graphs where only one has first-order node IDs") # Rebuild the node sequence from the joint mapping, whose IDs are the paths. if self.order == 0: diff --git a/tests/algorithms/test_lift_order.py b/tests/algorithms/test_lift_order.py index 5e01c51d..735b3230 100644 --- a/tests/algorithms/test_lift_order.py +++ b/tests/algorithms/test_lift_order.py @@ -1,5 +1,6 @@ import pytest import torch +from torch_geometric.data import Data from pathpyG.algorithms.lift_order import ( aggregate_edge_index, @@ -77,3 +78,23 @@ def test_aggregate_edge_index(): assert d.edge_index.tolist() == [[0, 0, 1], [1, 2, 0]] assert d.edge_weight.tolist() == [3, 3, 4] assert d.node_sequence.tolist() == [[1, 2], [2, 3], [4, 5]] + + +def test_aggregate_edge_index_returns_data(): + d = aggregate_edge_index(edge_index=torch.tensor([[0], [1]]), node_sequence=torch.tensor([[0, 1], [1, 2]])) + assert isinstance(d, Data) + assert d.inverse_idx.tolist() == [0, 1] + assert d.edge_weight.tolist() == [1.0] + + +def test_aggregate_edge_index_first_order_with_unvisited_node(): + """First-order indices are kept as node indices, even if a lower index is never visited.""" + # first-order nodes 0..4; node 1 is never visited; the steps are 0->4 and 2->0 + d = aggregate_edge_index( + edge_index=torch.tensor([[0, 2], [1, 3]]), node_sequence=torch.tensor([[0], [4], [2], [0]]) + ) + assert d.num_nodes == 5 + assert d.edge_index.tolist() == [[0, 2], [4, 0]] + assert d.edge_weight.tolist() == [1.0, 1.0] + assert d.node_sequence.tolist() == [[0], [1], [2], [3], [4]] + assert d.inverse_idx.tolist() == [0, 4, 2, 0] diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py index 039bc734..c66294fe 100644 --- a/tests/core/test_event_graph.py +++ b/tests/core/test_event_graph.py @@ -384,4 +384,4 @@ def test_construct_from_data_to_temporal_graph(event_data): 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 + assert tg.data.time.tolist() == [1, 2, 3, 5] diff --git a/tests/core/test_graph.py b/tests/core/test_graph.py index a7c2848f..f3801a52 100644 --- a/tests/core/test_graph.py +++ b/tests/core/test_graph.py @@ -25,6 +25,13 @@ def test_init(): assert g.order == 1 +def test_init_rejects_node_sequence(): + """A Graph is always first-order; graphs whose nodes are paths are HigherOrderGraphs.""" + data = Data(edge_index=torch.tensor([[0, 1], [1, 2]]), num_nodes=3, node_sequence=torch.tensor([[0], [1], [2]])) + with pytest.raises(ValueError): + Graph(data) + + def test_init_with_edge_index(): edge_index = EdgeIndex(get_random_edge_index(100, 100, 1000)) data = Data(edge_index=edge_index, num_nodes=100) diff --git a/tests/core/test_higher_order_graph.py b/tests/core/test_higher_order_graph.py new file mode 100644 index 00000000..7917b2df --- /dev/null +++ b/tests/core/test_higher_order_graph.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import pytest +import torch +from torch_geometric.data import Data + +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 +from pathpyG.core.temporal_graph import TemporalGraph + +# Paths used in the tests: +# +# a -> c -> d (weight 3) +# b -> c -> d (weight 1) +# +# Node e is part of the mapping but never visited. Weighted visit counts are +# a: 3, b: 1, c: 4, d: 4, e: 0 (12 visits in total). + + +@pytest.fixture +def paths() -> PathData: + paths = PathData(IndexMap(list("abcde"))) + paths.append_walk(("a", "c", "d"), weight=3) + paths.append_walk(("b", "c", "d"), weight=1) + return paths + + +@pytest.fixture +def order_zero() -> HigherOrderGraph: + return HigherOrderGraph.from_node_weights( + torch.tensor([3.0, 1.0, 4.0, 4.0, 0.0]), first_order_mapping=IndexMap(list("abcde")) + ) + + +def test_is_graph(paths): + h = HigherOrderGraph.from_path_data(paths, order=2) + assert isinstance(h, Graph) + + +def test_node_sequence_is_internal(paths): + h = HigherOrderGraph.from_path_data(paths, order=2) + assert "node_sequence" in h.data + assert "node_sequence" not in h.node_attrs() + + +def test_init_creates_identity_node_sequence_for_order_one(): + data = Data(edge_index=torch.tensor([[0, 1], [1, 2]]), num_nodes=3) + h = HigherOrderGraph(data) + assert h.order == 1 + assert h.data.node_sequence.tolist() == [[0], [1], [2]] + assert h.n_first_order == 3 + + +def test_to_device(paths): + h = MultiOrderModel.from_path_data(paths, max_order=2).layers[2] + moved = h.to(torch.device("cpu")) + assert moved is h + assert h.data.node_sequence.device.type == "cpu" + assert h.data.inverse_idx.device.type == "cpu" + + +# --------------------------------------------------------------------------- +# Order 0 +# --------------------------------------------------------------------------- + + +def test_from_node_weights(order_zero): + assert order_zero.order == 0 + assert order_zero.n == 1 + assert order_zero.m == 5 + assert order_zero.nodes == [()] + assert order_zero.n_first_order == 5 + assert order_zero.mapping.has_tuple_ids + assert order_zero.data.node_sequence.shape == (1, 0) + # one self-loop per first-order node, including the unvisited node e + assert order_zero.data.edge_index.as_tensor().tolist() == [[0] * 5, [0] * 5] + assert sorted(zip(order_zero.data.edge_first_order_node.tolist(), order_zero.data.edge_weight.tolist())) == [ + (0, 3.0), + (1, 1.0), + (2, 4.0), + (3, 4.0), + (4, 0.0), + ] + + +def test_order_zero_transition_probabilities_are_visit_probabilities(order_zero): + probabilities = order_zero.transition_probabilities(edge_attr="edge_weight") + by_node = dict(zip(order_zero.data.edge_first_order_node.tolist(), probabilities.tolist())) + expected = {0: 3 / 12, 1: 1 / 12, 2: 4 / 12, 3: 4 / 12, 4: 0.0} + assert by_node == pytest.approx(expected) + + +def order_zero_data(**kwargs) -> Data: + data = { + "edge_index": torch.zeros((2, 2), dtype=torch.long), + "num_nodes": 1, + "node_sequence": torch.empty((1, 0), dtype=torch.long), + "edge_first_order_node": torch.tensor([0, 1]), + "edge_weight": torch.tensor([1.0, 1.0]), + } + data.update(kwargs) + return Data(**{k: v for k, v in data.items() if v is not None}) + + +def test_order_zero_requires_n_first_order(): + with pytest.raises(ValueError): + HigherOrderGraph(order_zero_data()) + assert HigherOrderGraph(order_zero_data(), n_first_order=2).order == 0 + + +def test_order_zero_has_at_most_one_node(): + data = order_zero_data(num_nodes=2, node_sequence=torch.empty((2, 0), dtype=torch.long)) + with pytest.raises(ValueError): + HigherOrderGraph(data, n_first_order=2) + + +def test_order_zero_cannot_be_projected(order_zero): + with pytest.raises(ValueError): + order_zero.to_first_order() + with pytest.raises(ValueError): + order_zero.bipartite_edge_index() + + +def test_from_path_data_order_zero(paths): + h = HigherOrderGraph.from_path_data(paths, order=0) + assert h.order == 0 + assert sorted(zip(h.data.edge_first_order_node.tolist(), h.data.edge_weight.tolist())) == [ + (0, 3.0), + (1, 1.0), + (2, 4.0), + (3, 4.0), + (4, 0.0), + ] + + +def test_from_temporal_graph_rejects_order_zero(): + t = TemporalGraph.from_edge_list([("a", "b", 1), ("b", "c", 2)]) + with pytest.raises(ValueError): + HigherOrderGraph.from_temporal_graph(t, order=0) + + +def test_str_order_zero(order_zero): + assert str(order_zero).startswith("Higher-order graph of order 0 with 1 nodes and 5 edges") + + +# --------------------------------------------------------------------------- +# Order 1 +# --------------------------------------------------------------------------- + + +def test_aggregate_keeps_unvisited_first_order_nodes(): + # First-order nodes 0..4, node 1 is never visited; the steps are 0->4 and 2->0. + h = HigherOrderGraph.aggregate( + torch.tensor([[0, 2], [1, 3]]), + torch.tensor([[0], [4], [2], [0]]), + first_order_mapping=IndexMap(list("abcde")), + ) + assert h.order == 1 + assert h.n == 5 + assert h.edges == [("a", "e"), ("c", "a")] + assert h.data.edge_weight.tolist() == [1.0, 1.0] + + +# --------------------------------------------------------------------------- +# Combining higher-order graphs +# --------------------------------------------------------------------------- + + +def test_add_order_zero(): + g1 = HigherOrderGraph.from_node_weights(torch.tensor([1.0, 2.0, 3.0]), first_order_mapping=IndexMap(list("abc"))) + g2 = HigherOrderGraph.from_node_weights(torch.tensor([4.0, 5.0]), first_order_mapping=IndexMap(list("cd"))) + g = g1 + g2 + + assert isinstance(g, HigherOrderGraph) + assert g.order == 0 + assert g.nodes == [()] + assert g.n_first_order == 4 + assert g.first_order_mapping.node_ids.tolist() == list("abcd") + # emitted nodes are re-indexed to the joint first-order mapping; loops are kept as multi-edges + assert sorted(zip(g.data.edge_first_order_node.tolist(), g.data.edge_weight.tolist())) == [ + (0, 1.0), + (1, 2.0), + (2, 3.0), + (2, 4.0), + (3, 5.0), + ] + + +def test_add_order_one(paths): + h = HigherOrderGraph.from_path_data(paths, order=1) + g = h + h + assert isinstance(g, HigherOrderGraph) + assert g.order == 1 + assert g.data.node_sequence.tolist() == [[i] for i in range(g.n)] + + +def test_add_rebuilds_node_sequence(paths): + h = HigherOrderGraph.from_path_data(paths, order=2) + other = PathData(IndexMap(list("abcde"))) + other.append_walk(("c", "e", "a")) + g = h + HigherOrderGraph.from_path_data(other, order=2) + + assert g.order == 2 + for node, seq in zip(g.nodes, g.data.node_sequence.tolist()): + assert tuple(g.first_order_mapping.to_ids(seq).tolist()) == node + + +def test_add_rejects_different_orders(paths): + with pytest.raises(ValueError): + HigherOrderGraph.from_path_data(paths, order=1) + HigherOrderGraph.from_path_data(paths, order=2) + + +def test_add_rejects_plain_graph(paths): + with pytest.raises(TypeError): + HigherOrderGraph.from_path_data(paths, order=1) + Graph.from_edge_list([("a", "b")]) diff --git a/tests/core/test_multi_order_model.py b/tests/core/test_multi_order_model.py index 7e7d57a3..95c56072 100644 --- a/tests/core/test_multi_order_model.py +++ b/tests/core/test_multi_order_model.py @@ -1,6 +1,7 @@ # pylint: disable=missing-function-docstring,missing-module-docstring import numpy as np +import pytest import torch from scipy.stats import chi2 from torch_geometric import EdgeIndex @@ -206,3 +207,77 @@ def test_paths_indexing(): max_order=max_order ) assert detected_order == 2 + + +def weighted_paths() -> PathData: + """Return the walks a -> c -> d (weight 3) and b -> c -> d (weight 1); e is never visited.""" + paths = PathData(IndexMap(list("abcde"))) + paths.append_walk(("a", "c", "d"), weight=3) + paths.append_walk(("b", "c", "d"), weight=1) + return paths + + +def test_from_path_data_builds_order_zero_layer(): + m = MultiOrderModel.from_path_data(weighted_paths(), max_order=2) + assert sorted(m.layers) == [0, 1, 2] + + g0 = m.layers[0] + assert g0.order == 0 + assert g0.n_first_order == 5 + # visits weighted by path frequency: a 3, b 1, c 4, d 4, e 0 + weights = dict(zip(g0.data.edge_first_order_node.tolist(), g0.data.edge_weight.tolist())) + assert weights == {0: 3.0, 1: 1.0, 2: 4.0, 3: 4.0, 4: 0.0} + + +def test_from_path_data_uncached_keeps_orders_zero_and_one(): + paths = PathData(IndexMap(list("abcd"))) + paths.append_walk(("a", "b", "c", "d")) + assert sorted(MultiOrderModel.from_path_data(paths, max_order=3, cached=False).layers) == [0, 1, 3] + assert sorted(MultiOrderModel.from_path_data(paths, max_order=3, cached=True).layers) == [0, 1, 2, 3] + + +def test_from_path_data_with_unvisited_first_order_node(): + """A first-order node with a lower index than the visited ones is never visited (b).""" + paths = PathData(IndexMap(list("abcde"))) + paths.append_walk(("a", "e")) + paths.append_walk(("c", "a")) + g1 = MultiOrderModel.from_path_data(paths, max_order=1).layers[1] + assert g1.n == 5 + assert g1.edges == [("a", "e"), ("c", "a")] + assert g1.data.edge_weight.tolist() == [1.0, 1.0] + + +def test_zeroth_order_log_likelihood_is_weighted(): + paths = weighted_paths() + m = MultiOrderModel.from_path_data(paths, max_order=1) + # path starts: a (weight 3) and b (weight 1); visit probabilities a 3/12, b 1/12 + expected = 3 * np.log(3 / 12) + 1 * np.log(1 / 12) + assert np.isclose(m.get_zeroth_order_log_likelihood(paths.data), expected) + + +def test_log_likelihood_order_zero_with_unvisited_node(): + paths = weighted_paths() + m = MultiOrderModel.from_path_data(paths, max_order=1) + # every visit is a draw from the order-0 layer; the unvisited node e contributes nothing + expected = 3 * np.log(3 / 12) + 1 * np.log(1 / 12) + 8 * np.log(4 / 12) + assert np.isclose(m.get_mon_log_likelihood(paths.data, max_order=0), expected) + + +def test_dof_order_zero_counts_unvisited_nodes(): + m = MultiOrderModel.from_path_data(weighted_paths(), max_order=1) + assert m.get_mon_dof(max_order=0) == 4 + + +def test_from_temporal_graph_rejects_order_zero(simple_temporal_graph): + with pytest.raises(ValueError): + MultiOrderModel.from_temporal_graph(simple_temporal_graph, max_order=0) + + +def test_temporal_model_has_no_order_zero_layer(simple_temporal_graph): + m = MultiOrderModel.from_temporal_graph(simple_temporal_graph, max_order=2, delta=4) + assert 0 not in m.layers + # the zeroth-order degrees of freedom do not need the layer + assert m.get_mon_dof(max_order=0) == simple_temporal_graph.n - 1 + # the likelihoods do + with pytest.raises(ValueError): + m.get_mon_log_likelihood(weighted_paths().data, max_order=0) diff --git a/tests/core/test_temporal_graph.py b/tests/core/test_temporal_graph.py index 006f5448..bc095121 100644 --- a/tests/core/test_temporal_graph.py +++ b/tests/core/test_temporal_graph.py @@ -153,3 +153,9 @@ def test_get_window(long_temporal_graph): def test_str(simple_temporal_graph): assert str(simple_temporal_graph) + + +def test_order(): + t = TemporalGraph.from_edge_list([("a", "b", 1), ("b", "c", 2)]) + assert t.order == 1 + assert "node_sequence" not in t.data From 228ed404d2f6903fcb0a799d7a4da1749a44f649 Mon Sep 17 00:00:00 2001 From: Vineet Bansal Date: Tue, 22 Sep 2026 12:13:18 -0400 Subject: [PATCH 17/17] estimate_order can now return order 0 as the estimated order --- src/pathpyG/core/multi_order_model.py | 21 ++++++++++------ tests/core/test_multi_order_model.py | 36 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py index 84c2b037..1c218029 100644 --- a/src/pathpyG/core/multi_order_model.py +++ b/src/pathpyG/core/multi_order_model.py @@ -508,28 +508,33 @@ def estimate_order( Raises: ValueError: If the provided max_order is larger than the maximum order of the multi-order model + or if a layer of order 0 to max_order is missing from the multi-order model or if the input does not have the same set of nodes as the multi-order network """ if max_order is None: max_order = max(self.layers) - if max_order > max(self.layers): - logger.error("max_order cannot be larger than maximum order of multi-order network") - raise ValueError("max_order cannot be larger than maximum order of multi-order network") - if max_order <= 1: - logger.error("max_order must be larger than one") - raise ValueError("max_order must be larger than one") + if max_order < 1: + logger.error("max_order must be at least 1, got %s", max_order) + raise ValueError(f"max_order must be at least 1, got {max_order}") + missing = [k for k in range(max_order + 1) if k not in self.layers] + if missing: + logger.error("MultiOrderModel is missing layers %s", missing) + raise ValueError( + f"estimating the order up to {max_order} requires layers 0 to {max_order}, but layers {missing} " + "are missing; build the model from path data with cached=True and a sufficient max_order" + ) if set(dag_data.mapping.node_ids).intersection(set(self.layers[1].mapping.node_ids)) != set( # type: ignore[arg-type] dag_data.mapping.node_ids # type: ignore[arg-type] ): logger.error("Input paths do not have same set of nodes as multi-order network") raise ValueError("Input paths do not have same set of nodes as multi-order network") - max_accepted_order = 1 + max_accepted_order = 0 dag_graph = dag_data.data # Test for highest order that passes # likelihood ratio test against null model - for k in range(2, max_order + 1): + for k in range(1, max_order + 1): if self.likelihood_ratio_test( dag_graph, max_order_null=k - 1, max_order=k, significance_threshold=significance_threshold )[0]: diff --git a/tests/core/test_multi_order_model.py b/tests/core/test_multi_order_model.py index 95c56072..d3b53333 100644 --- a/tests/core/test_multi_order_model.py +++ b/tests/core/test_multi_order_model.py @@ -281,3 +281,39 @@ def test_temporal_model_has_no_order_zero_layer(simple_temporal_graph): # the likelihoods do with pytest.raises(ValueError): m.get_mon_log_likelihood(weighted_paths().data, max_order=0) + + +def memoryless_paths() -> PathData: + """Return all four transitions between a and b with equal weight, so that no order beats order 0.""" + paths = PathData(IndexMap(list("ab"))) + for walk in [("a", "a"), ("a", "b"), ("b", "a"), ("b", "b")]: + paths.append_walk(walk, weight=10) + return paths + + +def test_estimate_order_returns_zero_for_memoryless_paths(): + paths = memoryless_paths() + m = MultiOrderModel.from_path_data(paths, max_order=2) + assert m.estimate_order(paths, max_order=1) == 0 + assert m.estimate_order(paths) == 0 + + +@pytest.mark.parametrize("max_order", [0, -1, 3]) +def test_estimate_order_rejects_invalid_max_order(max_order): + paths = memoryless_paths() + m = MultiOrderModel.from_path_data(paths, max_order=2) + with pytest.raises(ValueError): + m.estimate_order(paths, max_order=max_order) + + +def test_estimate_order_rejects_missing_layers(simple_temporal_graph): + paths = PathData(IndexMap(list("abcd"))) + paths.append_walk(("a", "b", "c", "d")) + # uncached models lack the intermediate layers + m = MultiOrderModel.from_path_data(paths, max_order=3, cached=False) + with pytest.raises(ValueError): + m.estimate_order(paths) + # temporal models lack the order-0 layer + m = MultiOrderModel.from_temporal_graph(simple_temporal_graph, max_order=2, delta=4) + with pytest.raises(ValueError): + m.estimate_order(paths, max_order=1)