diff --git a/docs/tutorial/archive/_scalability_analysis.ipynb b/docs/tutorial/archive/_scalability_analysis.ipynb index 799c0550..6c83ac7f 100644 --- a/docs/tutorial/archive/_scalability_analysis.ipynb +++ b/docs/tutorial/archive/_scalability_analysis.ipynb @@ -36,7 +36,7 @@ " res['temp_net_events'] = g.data.edge_index.size(1)\n", "\n", " start_time = time.time()\n", - " eg = pp.algorithms.lift_order_temporal(g, delta=exp['delta'])\n", + " eg = pp.core.event_graph.EventGraph.build_edge_index(g, delta=exp['delta'])\n", " eg.to(exp['device'])\n", " res['lift_event_graph_time'] = time.time() - start_time\n", " res['event_graph_edges'] = eg.size(1)\n", @@ -1392,13 +1392,13 @@ "evalue": "", "output_type": "error", "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mKeyboardInterrupt\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[4], line 13\u001b[0m\n\u001b[1;32m 11\u001b[0m exp[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmax_order\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m k\n\u001b[1;32m 12\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[0;32m---> 13\u001b[0m res \u001b[38;5;241m=\u001b[39m \u001b[43mtest_mo_scalability\u001b[49m\u001b[43m(\u001b[49m\u001b[43mg\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mexp\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 14\u001b[0m printer\u001b[38;5;241m.\u001b[39mpprint(res)\n\u001b[1;32m 15\u001b[0m results_rm[delta][k] \u001b[38;5;241m=\u001b[39m res\n", - "Cell \u001b[0;32mIn[2], line 16\u001b[0m, in \u001b[0;36mtest_mo_scalability\u001b[0;34m(g, exp)\u001b[0m\n\u001b[1;32m 13\u001b[0m res[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mevent_graph_edges\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m eg\u001b[38;5;241m.\u001b[39msize(\u001b[38;5;241m1\u001b[39m)\n\u001b[1;32m 15\u001b[0m start_time \u001b[38;5;241m=\u001b[39m time\u001b[38;5;241m.\u001b[39mtime()\n\u001b[0;32m---> 16\u001b[0m m \u001b[38;5;241m=\u001b[39m \u001b[43mpp\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mMultiOrderModel\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mfrom_temporal_graph\u001b[49m\u001b[43m(\u001b[49m\u001b[43mg\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mdelta\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mexp\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mdelta\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mmax_order\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mexp\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mmax_order\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 17\u001b[0m res[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmo_time\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m time\u001b[38;5;241m.\u001b[39mtime() \u001b[38;5;241m-\u001b[39m start_time\n\u001b[1;32m 18\u001b[0m res[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmax_order_nodes\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m m\u001b[38;5;241m.\u001b[39mlayers[exp[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmax_order\u001b[39m\u001b[38;5;124m'\u001b[39m]]\u001b[38;5;241m.\u001b[39mN\n", - "File \u001b[0;32m/workspaces/pathpyG/src/pathpyG/core/multi_order_model.py:108\u001b[0m, in \u001b[0;36mMultiOrderModel.from_temporal_graph\u001b[0;34m(g, delta, max_order, weight, cached)\u001b[0m\n\u001b[1;32m 106\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m max_order \u001b[38;5;241m>\u001b[39m \u001b[38;5;241m1\u001b[39m:\n\u001b[1;32m 107\u001b[0m node_sequence \u001b[38;5;241m=\u001b[39m torch\u001b[38;5;241m.\u001b[39mcat([node_sequence[edge_index[\u001b[38;5;241m0\u001b[39m]], node_sequence[edge_index[\u001b[38;5;241m1\u001b[39m]][:, \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m1\u001b[39m:]], dim\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m1\u001b[39m)\n\u001b[0;32m--> 108\u001b[0m edge_index \u001b[38;5;241m=\u001b[39m \u001b[43mlift_order_temporal\u001b[49m\u001b[43m(\u001b[49m\u001b[43mg\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mdelta\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 109\u001b[0m edge_weight \u001b[38;5;241m=\u001b[39m aggregate_node_attributes(edge_index, edge_weight, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124msrc\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 111\u001b[0m \u001b[38;5;66;03m# Aggregate\u001b[39;00m\n", - "File \u001b[0;32m/workspaces/pathpyG/src/pathpyG/algorithms/temporal.py:39\u001b[0m, in \u001b[0;36mlift_order_temporal\u001b[0;34m(g, delta)\u001b[0m\n\u001b[1;32m 36\u001b[0m dst_node_mask \u001b[38;5;241m=\u001b[39m torch\u001b[38;5;241m.\u001b[39misin(edge_index[\u001b[38;5;241m0\u001b[39m], edge_index[\u001b[38;5;241m1\u001b[39m, src_edge_idx])\n\u001b[1;32m 37\u001b[0m dst_edge_idx \u001b[38;5;241m=\u001b[39m indices[dst_time_mask \u001b[38;5;241m&\u001b[39m dst_node_mask]\n\u001b[0;32m---> 39\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[43mdst_edge_idx\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43msize\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m0\u001b[39;49m\u001b[43m)\u001b[49m \u001b[38;5;241m>\u001b[39m \u001b[38;5;241m0\u001b[39m \u001b[38;5;129;01mand\u001b[39;00m src_edge_idx\u001b[38;5;241m.\u001b[39msize(\u001b[38;5;241m0\u001b[39m) \u001b[38;5;241m>\u001b[39m \u001b[38;5;241m0\u001b[39m:\n\u001b[1;32m 40\u001b[0m \n\u001b[1;32m 41\u001b[0m \u001b[38;5;66;03m# compute second-order edges between src and dst idx for all edges where dst in src_edges matches src in dst_edges\u001b[39;00m\n\u001b[1;32m 42\u001b[0m x \u001b[38;5;241m=\u001b[39m torch\u001b[38;5;241m.\u001b[39mcartesian_prod(src_edge_idx, dst_edge_idx)\u001b[38;5;241m.\u001b[39mt()\n\u001b[1;32m 43\u001b[0m \u001b[38;5;66;03m# print(x.size(1))\u001b[39;00m\n", - "\u001b[0;31mKeyboardInterrupt\u001b[0m: " + "\u001B[0;31m---------------------------------------------------------------------------\u001B[0m", + "\u001B[0;31mKeyboardInterrupt\u001B[0m Traceback (most recent call last)", + "Cell \u001B[0;32mIn[4], line 13\u001B[0m\n\u001B[1;32m 11\u001B[0m exp[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmax_order\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m k\n\u001B[1;32m 12\u001B[0m \u001B[38;5;28;01mtry\u001B[39;00m:\n\u001B[0;32m---> 13\u001B[0m res \u001B[38;5;241m=\u001B[39m \u001B[43mtest_mo_scalability\u001B[49m\u001B[43m(\u001B[49m\u001B[43mg\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mexp\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 14\u001B[0m printer\u001B[38;5;241m.\u001B[39mpprint(res)\n\u001B[1;32m 15\u001B[0m results_rm[delta][k] \u001B[38;5;241m=\u001B[39m res\n", + "Cell \u001B[0;32mIn[2], line 16\u001B[0m, in \u001B[0;36mtest_mo_scalability\u001B[0;34m(g, exp)\u001B[0m\n\u001B[1;32m 13\u001B[0m res[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mevent_graph_edges\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m eg\u001B[38;5;241m.\u001B[39msize(\u001B[38;5;241m1\u001B[39m)\n\u001B[1;32m 15\u001B[0m start_time \u001B[38;5;241m=\u001B[39m time\u001B[38;5;241m.\u001B[39mtime()\n\u001B[0;32m---> 16\u001B[0m m \u001B[38;5;241m=\u001B[39m \u001B[43mpp\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mMultiOrderModel\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mfrom_temporal_graph\u001B[49m\u001B[43m(\u001B[49m\u001B[43mg\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mdelta\u001B[49m\u001B[38;5;241;43m=\u001B[39;49m\u001B[43mexp\u001B[49m\u001B[43m[\u001B[49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[38;5;124;43mdelta\u001B[39;49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[43m]\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mmax_order\u001B[49m\u001B[38;5;241;43m=\u001B[39;49m\u001B[43mexp\u001B[49m\u001B[43m[\u001B[49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[38;5;124;43mmax_order\u001B[39;49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[43m]\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 17\u001B[0m res[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmo_time\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m time\u001B[38;5;241m.\u001B[39mtime() \u001B[38;5;241m-\u001B[39m start_time\n\u001B[1;32m 18\u001B[0m res[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmax_order_nodes\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m m\u001B[38;5;241m.\u001B[39mlayers[exp[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmax_order\u001B[39m\u001B[38;5;124m'\u001B[39m]]\u001B[38;5;241m.\u001B[39mN\n", + "File \u001B[0;32m/workspaces/pathpyG/src/pathpyG/core/multi_order_model.py:108\u001B[0m, in \u001B[0;36mMultiOrderModel.from_temporal_graph\u001B[0;34m(g, delta, max_order, weight, cached)\u001B[0m\n\u001B[1;32m 106\u001B[0m \u001B[38;5;28;01mif\u001B[39;00m max_order \u001B[38;5;241m>\u001B[39m \u001B[38;5;241m1\u001B[39m:\n\u001B[1;32m 107\u001B[0m node_sequence \u001B[38;5;241m=\u001B[39m torch\u001B[38;5;241m.\u001B[39mcat([node_sequence[edge_index[\u001B[38;5;241m0\u001B[39m]], node_sequence[edge_index[\u001B[38;5;241m1\u001B[39m]][:, \u001B[38;5;241m-\u001B[39m\u001B[38;5;241m1\u001B[39m:]], dim\u001B[38;5;241m=\u001B[39m\u001B[38;5;241m1\u001B[39m)\n\u001B[0;32m--> 108\u001B[0m edge_index \u001B[38;5;241m=\u001B[39m \u001B[43mlift_order_temporal\u001B[49m\u001B[43m(\u001B[49m\u001B[43mg\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mdelta\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 109\u001B[0m edge_weight \u001B[38;5;241m=\u001B[39m aggregate_node_attributes(edge_index, edge_weight, \u001B[38;5;124m\"\u001B[39m\u001B[38;5;124msrc\u001B[39m\u001B[38;5;124m\"\u001B[39m)\n\u001B[1;32m 111\u001B[0m \u001B[38;5;66;03m# Aggregate\u001B[39;00m\n", + "File \u001B[0;32m/workspaces/pathpyG/src/pathpyG/algorithms/temporal.py:39\u001B[0m, in \u001B[0;36mlift_order_temporal\u001B[0;34m(g, delta)\u001B[0m\n\u001B[1;32m 36\u001B[0m dst_node_mask \u001B[38;5;241m=\u001B[39m torch\u001B[38;5;241m.\u001B[39misin(edge_index[\u001B[38;5;241m0\u001B[39m], edge_index[\u001B[38;5;241m1\u001B[39m, src_edge_idx])\n\u001B[1;32m 37\u001B[0m dst_edge_idx \u001B[38;5;241m=\u001B[39m indices[dst_time_mask \u001B[38;5;241m&\u001B[39m dst_node_mask]\n\u001B[0;32m---> 39\u001B[0m \u001B[38;5;28;01mif\u001B[39;00m \u001B[43mdst_edge_idx\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43msize\u001B[49m\u001B[43m(\u001B[49m\u001B[38;5;241;43m0\u001B[39;49m\u001B[43m)\u001B[49m \u001B[38;5;241m>\u001B[39m \u001B[38;5;241m0\u001B[39m \u001B[38;5;129;01mand\u001B[39;00m src_edge_idx\u001B[38;5;241m.\u001B[39msize(\u001B[38;5;241m0\u001B[39m) \u001B[38;5;241m>\u001B[39m \u001B[38;5;241m0\u001B[39m:\n\u001B[1;32m 40\u001B[0m \n\u001B[1;32m 41\u001B[0m \u001B[38;5;66;03m# compute second-order edges between src and dst idx for all edges where dst in src_edges matches src in dst_edges\u001B[39;00m\n\u001B[1;32m 42\u001B[0m x \u001B[38;5;241m=\u001B[39m torch\u001B[38;5;241m.\u001B[39mcartesian_prod(src_edge_idx, dst_edge_idx)\u001B[38;5;241m.\u001B[39mt()\n\u001B[1;32m 43\u001B[0m \u001B[38;5;66;03m# print(x.size(1))\u001B[39;00m\n", + "\u001B[0;31mKeyboardInterrupt\u001B[0m: " ] } ], diff --git a/docs/tutorial/implementation_concepts.ipynb b/docs/tutorial/implementation_concepts.ipynb index 8bcdad2f..ee224589 100644 --- a/docs/tutorial/implementation_concepts.ipynb +++ b/docs/tutorial/implementation_concepts.ipynb @@ -2023,7 +2023,7 @@ "source": [ "### Temporal Order Lifting\n", "\n", - "One of the core functionalities of PathpyG is the ability to create temporal higher-order models. For this, an extension of the `lift_order_edge_index` function to temporal graphs is needed. We implement this in the `lift_order_temporal` function. This function works similarly to the `lift_order_edge_index` function, but with some additional steps to account for the temporal aspect of the graph. The main difference is that we need to ensure that the higher-order edges respect the temporal ordering of the original edges. Let us take a look at an example:" + "One of the core functionalities of PathpyG is the ability to create temporal higher-order models. For this, an extension of the `lift_order_edge_index` function to temporal graphs is needed. We implement this in the `EventGraph.build_edge_index` function. This function works similarly to the `lift_order_edge_index` function, but with some additional steps to account for the temporal aspect of the graph. The main difference is that we need to ensure that the higher-order edges respect the temporal ordering of the original edges. Let us take a look at an example:" ] }, { @@ -2690,9 +2690,7 @@ "cell_type": "markdown", "id": "951a8ffa", "metadata": {}, - "source": [ - "We can create a second-order graph from this temporal graph using the `lift_order_temporal` function. This second-order graph is typically referred to as an event graph. Each node in the graph is an event (edge) in the original temporal graph and two events are connected if they can follow each other in time respecting a maximum time difference `delta`. Here, we set `delta=2` which means that two events can be connected if the time difference between them is at most 2 time units." - ] + "source": "We can create a second-order graph from this temporal graph using the `EventGraph.build_edge_index` function. This second-order graph is typically referred to as an event graph. Each node in the graph is an event (edge) in the original temporal graph and two events are connected if they can follow each other in time respecting a maximum time difference `delta`. Here, we set `delta=2` which means that two events can be connected if the time difference between them is at most 2 time units." }, { "cell_type": "code", @@ -3223,7 +3221,7 @@ } ], "source": [ - "event_edge_index = pp.algorithms.temporal.lift_order_temporal(t, delta=2)\n", + "event_edge_index = pp.core.event_graph.EventGraph.build_edge_index(t, delta=2)\n", "event_mapping = pp.IndexMap(t.temporal_edges)\n", "event_data = Data(edge_index=event_edge_index, node_sequence=graph.data.edge_index.t())\n", "event_graph = pp.Graph(data=event_data, mapping=event_mapping)\n", @@ -3237,9 +3235,9 @@ "source": [ "Starting with the event graph, we have a static higher-order representation of the temporal graph that we can use to create higher-order models. For each following lift-order transformations, we can use the same principles as described in the previous section on order-lifting and line graph transformations. \n", "\n", - "#### Internals of the `lift_order_temporal` Function\n", + "#### Internals of the `EventGraph.build_edge_index` Function\n", "\n", - "The simplest way to implement the `lift_order_temporal` function would be to first create the full higher-order edge index using the `lift_order_edge_index` function and then filter out the edges that do not respect the temporal ordering. The filter function could look as follows:" + "The simplest way to implement the `EventGraph.build_edge_index` function would be to first create the full higher-order edge index using the `lift_order_edge_index` function and then filter out the edges that do not respect the temporal ordering. The filter function could look as follows:" ] }, { @@ -3825,7 +3823,7 @@ "
\n", "\n", "\n", - "However, the above implementation has a large memory consumption for graphs with many edges because the full higher-order edge index is created before filtering. Therefore, we implement a more memory-efficient version in PathpyG that constructs the higher-order edges from the temporal graph sequentially for each timestamp. This implementation looks as follows:" + "However, the above implementation has a large memory consumption for graphs with many edges because the full higher-order edge index is created before filtering. Therefore, we implement a more memory-efficient version in PathpyG that constructs the higher-order edges from the temporal graph sequentially for each timestamp. This implementation (available as `EventGraph.build_edge_index`) looks as follows:" ] }, { @@ -3835,30 +3833,31 @@ "metadata": {}, "outputs": [], "source": [ - "def lift_order_temporal(g: pp.TemporalGraph, delta: int = 1): # noqa: D103\n", - " indices = torch.arange(0, g.data.edge_index.size(1))\n", + "def build_edge_index(g: pp.TemporalGraph, delta: int = 1): # noqa: D103\n", + " # first-order edge index\n", + " edge_index, timestamps = g.data.edge_index, g.data.time\n", "\n", - " unique_t = torch.unique(g.data.time)\n", + " delta = torch.tensor(delta, device=edge_index.device) # type: ignore[assignment]\n", + " indices = torch.arange(0, edge_index.size(1), device=edge_index.device)\n", + "\n", + " unique_t = torch.unique(timestamps, sorted=True)\n", " second_order = []\n", "\n", " # lift order: find possible continuations for edges in each time stamp\n", " for t in unique_t:\n", - "\n", " # find indices of all source edges that occur at unique timestamp t\n", - " src_time_mask = g.data.time == t\n", + " src_time_mask = timestamps == t\n", " src_edge_idx = indices[src_time_mask]\n", "\n", " # find indices of all edges that can possibly continue edges occurring at time t for the given delta\n", - " dst_time_mask = (g.data.time > t) & (g.data.time <= t + delta)\n", + " dst_time_mask = (timestamps > t) & (timestamps <= t + delta)\n", " dst_edge_idx = indices[dst_time_mask]\n", "\n", " if dst_edge_idx.size(0) > 0 and src_edge_idx.size(0) > 0:\n", " # compute second-order edges between src and dst idx\n", - " # create all possible combinations of src and dst edges\n", + " # for all edges where dst in src_edges (edge_index[1, x[:, 0]]) matches src in dst_edges (edge_index[0, x[:, 1]])\n", " x = torch.cartesian_prod(src_edge_idx, dst_edge_idx)\n", - " # filter combinations for real higher-order edges\n", - " # for all edges where dst in src_edges (g.data.edge_index[1, x[:, 0]]) matches src in dst_edges (g.data.edge_index[0, x[:, 1]])\n", - " ho_edge_index = x[g.data.edge_index[1, x[:, 0]] == g.data.edge_index[0, x[:, 1]]]\n", + " ho_edge_index = x[edge_index[1, x[:, 0]] == edge_index[0, x[:, 1]]]\n", " second_order.append(ho_edge_index)\n", "\n", " ho_index = torch.cat(second_order, dim=0).t().contiguous()\n", @@ -5691,7 +5690,7 @@ "id": "94f6db38", "metadata": {}, "source": [ - "We can see that the second-order graph created by the `MultiOrderModel` is different from the one created by the `lift_order_temporal` function directly. This is because the `MultiOrderModel` higher-order DeBruijn graph representation. This representation merges higher-order nodes that correspond to the same path in the original graph. This means that temporal edges that appear in the event graph as different nodes will be merged into one node in the DeBruijn graph if they correspond to the same path in the original graph. This results in a more compact representation of the higher-order graph.\n", + "We can see that the second-order graph created by the `MultiOrderModel` is different from the one created by the `EventGraph.build_edge_index` function directly. This is because the `MultiOrderModel` higher-order DeBruijn graph representation. This representation merges higher-order nodes that correspond to the same path in the original graph. This means that temporal edges that appear in the event graph as different nodes will be merged into one node in the DeBruijn graph if they correspond to the same path in the original graph. This results in a more compact representation of the higher-order graph.\n", "\n", "\n", "The same is true for paths. We can create a multi-order model from a collection of paths as follows:" @@ -6241,7 +6240,7 @@ "Let us now take a closer look at how the `MultiOrderModel` class works under the hood. We already saw that the `MultiOrderModel` merges higher-order nodes from the line/event graph transformations. \n", "\n", "This is done in 3 distinct steps which we will go through using the paths example above:\n", - "1. **Order Lifting**: First, we create the higher-order edge index using the appropriate lift-order function (`lift_order_edge_index` or `lift_order_temporal`) depending on whether we are working with paths or temporal graphs in the first order and `lift_order_edge_index` for the second order and beyond regardless of the input type.\n", + "1. **Order Lifting**: First, we create the higher-order edge index using the appropriate lift-order function (`lift_order_edge_index` or `EventGraph.build_edge_index`) depending on whether we are working with paths or temporal graphs in the first order and `lift_order_edge_index` for the second order and beyond regardless of the input type.\n", "\n", "Note
\n", diff --git a/docs/tutorial/temporal_graphs.ipynb b/docs/tutorial/temporal_graphs.ipynb index 31b1de8b..6ba57852 100644 --- a/docs/tutorial/temporal_graphs.ipynb +++ b/docs/tutorial/temporal_graphs.ipynb @@ -1117,7 +1117,7 @@ "\n", "To calculate time-respecting paths in a temporal graph, we can construct a directed acyclic graph (DAG), where each time-stamped edge $(u,v;t)$ in the temporal graph is represented by a node and two nodes representing time-stamped edges $(u,v;t_1)$ and $(v,w;t_2)$ are connected by an edge iff $0 < t_2-t_1 \\leq \\delta$. This implies that (i) each edge in the resulting DAG represents a time-respecting path of length two, and (ii) time-respecting paths of any lenghts are represented by paths in this DAG.\n", "\n", - "We can construct such a DAG using the function `pp.algorithms.lift_order_temporal`, which returns an edge_index. We can pass this to the constructor of a `Graph` object, which we can use to visualize the resulting DAG." + "We can construct such a DAG using the function `pp.core.event_graph.EventGraph.build_edge_index`, which returns an edge_index. We can pass this to the constructor of a `Graph` object, which we can use to visualize the resulting DAG." ] }, { @@ -1127,7 +1127,7 @@ "outputs": [], "source": [ "%%capture\n", - "e_i = pp.algorithms.lift_order_temporal(t, delta=1)" + "e_i = pp.core.event_graph.EventGraph.build_edge_index(t, delta=1)" ] }, { diff --git a/docs/tutorial/trp_higher_order.ipynb b/docs/tutorial/trp_higher_order.ipynb index 0a9a5605..e2023eef 100644 --- a/docs/tutorial/trp_higher_order.ipynb +++ b/docs/tutorial/trp_higher_order.ipynb @@ -605,7 +605,7 @@ } ], "source": [ - "e_i = pp.algorithms.lift_order_temporal(t, delta=1)\n", + "e_i = pp.core.event_graph.EventGraph.build_edge_index(t, delta=1)\n", "mapping = pp.IndexMap([f'{v}-{w}-{time}' for v, w, time in t.temporal_edges])\n", "dag = pp.Graph.from_edge_index(e_i, mapping=mapping)\n", "pp.plot(dag);" @@ -3484,9 +3484,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "tags": [ - "skip-execution" - ] + "tags": [ + "skip-execution" + ] }, "outputs": [], "source": [ @@ -3498,9 +3498,9 @@ "cell_type": "code", "execution_count": 13, "metadata": { - "tags": [ - "skip-execution" - ] + "tags": [ + "skip-execution" + ] }, "outputs": [ { diff --git a/src/pathpyG/__init__.py b/src/pathpyG/__init__.py index 4f591847..c4c23c9a 100644 --- a/src/pathpyG/__init__.py +++ b/src/pathpyG/__init__.py @@ -8,10 +8,12 @@ __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 from pathpyG.core.temporal_graph import TemporalGraph +from pathpyG.core.event_graph import EventGraph from pathpyG import algorithms, io, nn, statistics from pathpyG.utils.config import config from pathpyG.utils.logger import logger @@ -20,7 +22,9 @@ __all__ = [ "Graph", + "HigherOrderGraph", "TemporalGraph", + "EventGraph", "PathData", "MultiOrderModel", "IndexMap", diff --git a/src/pathpyG/algorithms/__init__.py b/src/pathpyG/algorithms/__init__.py index d2deda91..0cf041a0 100644 --- a/src/pathpyG/algorithms/__init__.py +++ b/src/pathpyG/algorithms/__init__.py @@ -21,7 +21,7 @@ ]) # Extract DAG capturing causal interaction sequences in temporal graph. - e_i = pp.algorithms.lift_order_temporal(g, delta=1) + e_i = EventGraph.build_edge_index(g, delta=1) dag = pp.Graph.from_edge_index(e_i) print(dag) @@ -33,11 +33,10 @@ from pathpyG.algorithms import centrality, generative_models, shortest_paths from pathpyG.algorithms.components import connected_components, largest_connected_component from pathpyG.algorithms.rolling_time_window import RollingTimeWindow -from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths +from pathpyG.algorithms.temporal import temporal_shortest_paths from pathpyG.algorithms.weisfeiler_leman import WeisfeilerLeman_test __all__ = [ - "lift_order_temporal", "temporal_shortest_paths", "centrality", "generative_models", diff --git a/src/pathpyG/algorithms/centrality.py b/src/pathpyG/algorithms/centrality.py index c65a4a7e..394afdcd 100644 --- a/src/pathpyG/algorithms/centrality.py +++ b/src/pathpyG/algorithms/centrality.py @@ -42,7 +42,8 @@ from torch_geometric.utils import to_networkx from tqdm import tqdm -from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths +from pathpyG.algorithms.temporal import temporal_shortest_paths +from pathpyG.core.event_graph import EventGraph from pathpyG.core.graph import Graph from pathpyG.core.path_data import PathData from pathpyG.core.temporal_graph import TemporalGraph @@ -193,7 +194,7 @@ def temporal_betweenness_centrality(graph: TemporalGraph, delta: int = 1) -> dic ``` """ # generate temporal event DAG - edge_index = lift_order_temporal(graph, delta) + edge_index = EventGraph.build_edge_index(graph, delta) # Add indices of first-order nodes as src of paths in augmented # temporal event DAG diff --git a/src/pathpyG/algorithms/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/algorithms/temporal.py b/src/pathpyG/algorithms/temporal.py index 108268be..d0ab2c1c 100644 --- a/src/pathpyG/algorithms/temporal.py +++ b/src/pathpyG/algorithms/temporal.py @@ -7,75 +7,42 @@ import numpy as np import torch from scipy.sparse.csgraph import dijkstra -from tqdm import tqdm from pathpyG import Graph +from pathpyG.core.event_graph import EventGraph from pathpyG.core.temporal_graph import TemporalGraph from pathpyG.utils import to_numpy -def lift_order_temporal(g: TemporalGraph, delta: float | int = 1): - """Lift a temporal graph to a second-order temporal event graph. - - Args: - g: Temporal graph to lift. - delta: Maximum time difference between events to consider them connected. - - Returns: - ho_index: Edge index of the second-order temporal event graph. - """ - # first-order edge index - edge_index, timestamps = g.data.edge_index, g.data.time - - delta = torch.tensor(delta, device=edge_index.device) # type: ignore[assignment] - indices = torch.arange(0, edge_index.size(1), device=edge_index.device) - - unique_t = torch.unique(timestamps, sorted=True) - second_order = [] - - # lift order: find possible continuations for edges in each time stamp - for t in tqdm(unique_t): - # find indices of all source edges that occur at unique timestamp t - src_time_mask = timestamps == t - src_edge_idx = indices[src_time_mask] - - # find indices of all edges that can possibly continue edges occurring at time t for the given delta - dst_time_mask = (timestamps > t) & (timestamps <= t + delta) - dst_edge_idx = indices[dst_time_mask] - - if dst_edge_idx.size(0) > 0 and src_edge_idx.size(0) > 0: - # compute second-order edges between src and dst idx - # for all edges where dst in src_edges (edge_index[1, x[:, 0]]) matches src in dst_edges (edge_index[0, x[:, 1]]) - x = torch.cartesian_prod(src_edge_idx, dst_edge_idx) - ho_edge_index = x[edge_index[1, x[:, 0]] == edge_index[0, x[:, 1]]] - second_order.append(ho_edge_index) - - ho_index = torch.cat(second_order, dim=0).t().contiguous() - return ho_index - - -def temporal_shortest_paths(g: TemporalGraph, delta: int) -> Tuple[np.ndarray, np.ndarray]: +def temporal_shortest_paths(temporal_graph: TemporalGraph | None, delta: int, event_graph: EventGraph | None = None) -> Tuple[np.ndarray, np.ndarray]: """Compute shortest time-respecting paths in a temporal graph. Args: - g: Temporal graph to compute shortest paths on. + temporal_graph: Temporal graph to compute shortest paths on. If None, `eg` must be provided. delta: Maximum time difference between events in a path. + event_graph: Event graph to compute shortest paths on. If None, `g` must be provided. Returns: Tuple of two numpy arrays: - dist: Shortest time-respecting path distances between all first-order nodes. - pred: Predecessor matrix for shortest time-respecting paths between all first-order nodes. """ - # generate temporal event DAG - edge_index = lift_order_temporal(g, delta) + assert temporal_graph is None or event_graph is None, "Only one of g or eg can be provided" + if temporal_graph is None: + assert event_graph is not None, "If g is None, eg must be provided" + edge_index = event_graph.data.edge_index + temporal_graph = event_graph.to_temporal_graph() + else: + # generate temporal event DAG + edge_index = EventGraph.build_edge_index(temporal_graph, delta) # Add indices of first-order nodes as src and dst of paths in augmented # temporal event DAG - src_edges_src = g.data.edge_index[0] + g.m - src_edges_dst = torch.arange(0, g.data.edge_index.size(1), device=g.data.edge_index.device) + src_edges_src = temporal_graph.data.edge_index[0] + temporal_graph.m + src_edges_dst = torch.arange(0, temporal_graph.data.edge_index.size(1), device=temporal_graph.data.edge_index.device) - dst_edges_src = torch.arange(0, g.data.edge_index.size(1), device=g.data.edge_index.device) - dst_edges_dst = g.data.edge_index[1] + g.m + g.n + dst_edges_src = torch.arange(0, temporal_graph.data.edge_index.size(1), device=temporal_graph.data.edge_index.device) + dst_edges_dst = temporal_graph.data.edge_index[1] + temporal_graph.m + temporal_graph.n # add edges from source to edges and from edges to destinations src_edges = torch.stack([src_edges_src, src_edges_dst]) @@ -83,25 +50,25 @@ def temporal_shortest_paths(g: TemporalGraph, delta: int) -> Tuple[np.ndarray, n edge_index = torch.cat([edge_index, src_edges, dst_edges], dim=1) # create sparse scipy matrix - event_graph = Graph.from_edge_index(edge_index, num_nodes=g.m + 2 * g.n) - m = event_graph.sparse_adj_matrix() + augmented_dag = Graph.from_edge_index(edge_index, num_nodes=temporal_graph.m + 2 * temporal_graph.n) + m = augmented_dag.sparse_adj_matrix() - # print(f"Created temporal event DAG with {event_graph.n} nodes and {event_graph.m} edges") + # print(f"Created temporal event DAG with {augmented_dag.n} nodes and {augmented_dag.m} edges") # run disjktra for all source nodes dist, pred = dijkstra( - m, directed=True, indices=np.arange(g.m, g.m + g.n), return_predecessors=True, unweighted=True + m, directed=True, indices=np.arange(temporal_graph.m, temporal_graph.m + temporal_graph.n), return_predecessors=True, unweighted=True ) # limit to first-order destinations and correct distances - dist_fo = dist[:, g.m + g.n :] - 1 + dist_fo = dist[:, temporal_graph.m + temporal_graph.n:] - 1 np.fill_diagonal(dist_fo, 0) # limit to first-order destinations and correct predecessors - pred_fo = pred[:, g.n + g.m :] + pred_fo = pred[:, temporal_graph.n + temporal_graph.m:] pred_fo[pred_fo == -9999] = -1 - idx_map = np.concatenate([to_numpy(g.data.edge_index[0].cpu()), [-1]]) + idx_map = np.concatenate([to_numpy(temporal_graph.data.edge_index[0].cpu()), [-1]]) pred_fo = idx_map[pred_fo] - np.fill_diagonal(pred_fo, np.arange(g.n)) + np.fill_diagonal(pred_fo, np.arange(temporal_graph.n)) return dist_fo, pred_fo diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py new file mode 100644 index 00000000..40815c15 --- /dev/null +++ b/src/pathpyG/core/event_graph.py @@ -0,0 +1,288 @@ +"""Event graph representation of a temporal graph and related operations.""" +from __future__ import annotations + +import logging +from typing import Any, Tuple, Union + +import numpy as np +import torch +from torch_geometric.data import Data +from tqdm import tqdm + +from pathpyG.core.graph import Graph +from pathpyG.core.index_map import IndexMap +from pathpyG.core.temporal_graph import TemporalGraph + +logger = logging.getLogger("root") + + +def _copy_attr(value: Any) -> Any: + """Return an independent copy of an attribute value, leaving immutable values as they are.""" + if isinstance(value, torch.Tensor): + return value.clone() + if isinstance(value, np.ndarray): + return value.copy() + return value + + +class EventGraph(Graph): + """A directed acyclic graph whose nodes are time-stamped events.""" + + # Attributes that are constructed explicitly when lifting a temporal graph and that + # must therefore not be overwritten by propagated attributes. + _RESERVED_ATTRS = frozenset({"edge_index", "num_nodes", "node_sequence", "node_time"}) + + def __init__( + self, + data: Data, + delta: int, + first_order_mapping: IndexMap | None = None, + n_first_order: int | None = None, + mapping: IndexMap | None = None, + ) -> None: + """Create an EventGraph from a `Data` object carrying per-event `node_time`.""" + if "node_time" not in data: + raise ValueError("EventGraph requires a per-event `node_time` node attribute.") + + super().__init__(data, mapping=mapping) + + self.delta = delta + self.first_order_mapping = first_order_mapping if first_order_mapping is not None else IndexMap() + if n_first_order is not None: + self._n_first_order = int(n_first_order) + else: + self._n_first_order = int(self.data.node_sequence.max().item()) + 1 + + ei = self.data.edge_index + self.data.edge_delta = self.data.node_time[ei[1]] - self.data.node_time[ei[0]] + + self._temporal_graph: TemporalGraph | None = None + + @staticmethod + def build_edge_index(temporal_graph: TemporalGraph, delta: float | int = 1): + """Build the event-graph edge index by lifting a temporal graph to second order. + + Each temporal edge of `temporal_graph` becomes an event (node); two events are + connected when the second can continue the first within the time window `delta`. + + Args: + temporal_graph: Temporal graph to lift. + delta: Maximum time difference between events to consider them connected. + + Returns: + ho_index: Edge index of the second-order temporal event graph. + """ + # first-order edge index + edge_index, timestamps = temporal_graph.data.edge_index, temporal_graph.data.time + + delta = torch.tensor(delta, device=edge_index.device) # type: ignore[assignment] + indices = torch.arange(0, edge_index.size(1), device=edge_index.device) + + unique_t = torch.unique(timestamps, sorted=True) + second_order = [] + + # lift order: find possible continuations for edges in each time stamp + for t in tqdm(unique_t): + # find indices of all source edges that occur at unique timestamp t + src_time_mask = timestamps == t + src_edge_idx = indices[src_time_mask] + + # find indices of all edges that can possibly continue edges occurring at time t for the given delta + dst_time_mask = (timestamps > t) & (timestamps <= t + delta) + dst_edge_idx = indices[dst_time_mask] + + if dst_edge_idx.size(0) > 0 and src_edge_idx.size(0) > 0: + # compute second-order edges between src and dst idx + # for all edges where dst in src_edges (edge_index[1, x[:, 0]]) matches src in dst_edges (edge_index[0, x[:, 1]]) + x = torch.cartesian_prod(src_edge_idx, dst_edge_idx) + ho_edge_index = x[edge_index[1, x[:, 0]] == edge_index[0, x[:, 1]]] + second_order.append(ho_edge_index) + + ho_index = torch.cat(second_order, dim=0).t().contiguous() + return ho_index + + @staticmethod + def lift_attrs(temporal_graph: TemporalGraph) -> dict[str, Any]: + """Map the attributes of a temporal graph to attributes of the lifted event graph. + + Since every temporal edge becomes an event, edge attributes of the temporal graph + become node attributes of the event graph and are renamed from `edge_x` to `node_x` + accordingly. Node attributes refer to first-order nodes, which have no counterpart + among the events, so they are dropped. Graph attributes are kept unchanged. + + Attributes that are constructed explicitly while lifting (`edge_index`, `num_nodes`, + `node_sequence` and the timestamps in `time`) are excluded. + + Args: + temporal_graph: Temporal graph whose attributes shall be lifted. + + Returns: + dict: mapping from attribute names in the event graph to their values. + """ + attrs: dict[str, Any] = {} + for key in temporal_graph.data.keys(): + if key in EventGraph._RESERVED_ATTRS or key == "time": + continue + if key.startswith("node_"): + # attributes of first-order nodes have no counterpart in the event graph + continue + if key.startswith("edge_"): + event_key = "node_" + key[len("edge_") :] + if event_key in EventGraph._RESERVED_ATTRS: + logger.error("Edge attribute %s cannot be lifted to reserved attribute %s", key, event_key) + raise ValueError(f"edge attribute '{key}' would be lifted to reserved attribute '{event_key}'") + attrs[event_key] = _copy_attr(temporal_graph.data[key]) + else: + # graph-level attributes are kept as they are + attrs[key] = _copy_attr(temporal_graph.data[key]) + return attrs + + @classmethod + def from_temporal_graph(cls, temporal_graph: TemporalGraph, delta: int = 1) -> "EventGraph": + """Build an EventGraph from a temporal graph by lifting its edges into events. + + Attributes of the temporal graph are propagated to the event graph as described in + [`lift_attrs`][pathpyG.core.event_graph.EventGraph.lift_attrs]. + """ + ho_index = cls.build_edge_index(temporal_graph, delta) + m = temporal_graph.data.time.size(0) # number of events (== number of first-order edges) + node_sequence = temporal_graph.data.edge_index.as_tensor().t().contiguous() # [m, 2] + node_time = temporal_graph.data.time.clone() # [m] + + # Build an event mapping with IDs of the form "a->b@t" for each edge node + event_ids = [ + f"{temporal_graph.mapping.to_id(u)}->{temporal_graph.mapping.to_id(v)}@{t}" + for (u, v), t in zip(node_sequence.tolist(), node_time.tolist()) + ] + mapping = IndexMap(event_ids) + + data = Data( + edge_index=ho_index, + num_nodes=m, + node_sequence=node_sequence, + node_time=node_time, + ) + for key, value in cls.lift_attrs(temporal_graph).items(): + data[key] = value + + event_graph = cls(data, delta=delta, first_order_mapping=temporal_graph.mapping, n_first_order=temporal_graph.n, mapping=mapping) + + # Attach a clone of the temporal graph since we already have it + event_graph._temporal_graph = TemporalGraph(temporal_graph.data.clone(), mapping=temporal_graph.mapping) + + return event_graph + + def _summary(self) -> str: + """Return a one-line summary of the event graph.""" + return "Event Graph (delta={0}) with {1} first-order nodes, {2} events and {3} edges in [{4}, {5}]".format( + self.delta, + self.n_first_order, + self.num_events, + self.m, + self.start_time, + self.end_time, + ) + + def __len__(self): + """Return the number of events in the graph.""" + return self.n + + def to(self, device: torch.device) -> "EventGraph": + """Move the event graph and its underlying temporal graph to the given device.""" + super().to(device) + if self._temporal_graph is not None: + self._temporal_graph.to(device) + return self + + def to_temporal_graph(self) -> TemporalGraph: + """Return the underlying temporal graph, reconstructing it if necessary.""" + if self._temporal_graph is None: + edge_index = self.data.node_sequence.t().contiguous() # [2, num_events] + self._temporal_graph = TemporalGraph( + Data( + edge_index=edge_index, + time=self.data.node_time.clone(), + num_nodes=self.n_first_order, + ), + mapping=self.first_order_mapping, + ) + return self._temporal_graph + + @property + def n_first_order(self) -> int: + """Number of distinct first-order nodes underlying the events.""" + return self._n_first_order + + @property + def num_events(self) -> int: + """Number of events (nodes) in the event graph.""" + return self.n + + @property + def start_time(self) -> Union[int, float]: + """Return the timestamp of the first event in the event graph.""" + return self.data.node_time.min().item() + + @property + def end_time(self) -> Union[int, float]: + """Return the timestamp of the last event in the event graph.""" + return self.data.node_time.max().item() + + def event_time(self, i: int) -> int: + """Return the timestamp of the i-th event.""" + return self.data.node_time[i].item() + + def edge_delta_map(self) -> dict[tuple[int, int], int]: + """Return a mapping from each transition edge (src, dst) to its time delta.""" + return { + tuple(c): d + for c, d in zip( + self.data.edge_index.as_tensor().t().tolist(), + self.data.edge_delta.tolist(), + ) + } + + def shortest_paths(self) -> Tuple[np.ndarray, np.ndarray]: + """Return first-order shortest-path distances and predecessors respecting delta.""" + from pathpyG.algorithms.temporal import temporal_shortest_paths + + return temporal_shortest_paths(temporal_graph=None, delta=self.delta, event_graph=self) + + def reduce_delta(self, decrement: int = 1) -> "EventGraph": + """Return a new EventGraph with a reduced time window `delta - decrement`. + + The events are unchanged, so node and graph attributes are carried over as they are, + while edge attributes are restricted to the edges that remain for the smaller `delta`. + """ + new_delta = self.delta - decrement + if new_delta < 0: + raise ValueError( + f"decrement={decrement} exceeds current delta={self.delta}" + ) + + ei = self.data.edge_index + edge_delta = self.data.node_time[ei[1]] - self.data.node_time[ei[0]] + mask = edge_delta <= new_delta + new_edge_index = ei[:, mask].contiguous() + + data = Data( + edge_index=new_edge_index, + num_nodes=self.n, + node_sequence=self.data.node_sequence.clone(), + node_time=self.data.node_time.clone(), + ) + for key in self.data.keys(): + if key in self._RESERVED_ATTRS: + continue + if key.startswith("edge_"): + data[key] = self.data[key][mask] + else: + data[key] = _copy_attr(self.data[key]) + + return EventGraph( + data, + delta=new_delta, + first_order_mapping=self.first_order_mapping, + n_first_order=self.n_first_order, + mapping=self.mapping, + ) \ No newline at end of file diff --git a/src/pathpyG/core/graph.py b/src/pathpyG/core/graph.py index 52d15470..42864d04 100644 --- a/src/pathpyG/core/graph.py +++ b/src/pathpyG/core/graph.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from pprint import pformat from typing import ( Any, Dict, @@ -116,8 +117,10 @@ def __init__(self, data: Data, mapping: Optional[IndexMap] = None): # sort EdgeIndex and validate data.edge_index, sorted_idx = data.edge_index.sort_by("row") - for edge_attr in self.edge_attrs(): - data[edge_attr] = self.data[edge_attr][sorted_idx] + # an edge index that is already sorted is returned without a permutation + if sorted_idx is not None: + for edge_attr in self.edge_attrs(): + data[edge_attr] = self.data[edge_attr][sorted_idx] data.edge_index.validate() @@ -785,8 +788,14 @@ def __add__(self, other: Graph, reduce: str = "sum") -> Graph: raise ValueError("Node attribute " + k + " is not a tensor and cannot be reduced.") return Graph(d, mapping=mapping) - def __str__(self) -> str: - """Return a string representation of the graph.""" + def _summary(self) -> str: + """Return a one-line summary of the graph, to be overridden by subclasses.""" + if self.is_undirected(): + return "Undirected graph with {0} nodes and {1} edges".format(self.n, self.m) + return "Directed graph with {0} nodes and {1} edges".format(self.n, self.m) + + def _attribute_summary(self) -> str: + """Return a pretty-printed summary of node-, edge- and graph-level attributes.""" attr = self.data.to_dict() attr_types = {} for k in attr: @@ -796,13 +805,6 @@ def __str__(self) -> str: else: attr_types[k] = str(t) - from pprint import pformat - - if self.is_undirected(): - s = "Undirected graph with {0} nodes and {1} edges\n".format(self.n, self.m) - else: - s = "Directed graph with {0} nodes and {1} edges\n".format(self.n, self.m) - attribute_info: dict[str, dict[str, str]] = { "Node Attributes": {}, "Edge Attributes": {}, @@ -815,5 +817,8 @@ def __str__(self) -> str: for a in self.data.keys(): if not self.data.is_node_attr(a) and not self.data.is_edge_attr(a): attribute_info["Graph Attributes"][a] = attr_types[a] - s += pformat(attribute_info, indent=4, width=160) - return s + return pformat(attribute_info, indent=4, width=160) + + def __str__(self) -> str: + """Return a string representation of the graph.""" + return self._summary() + "\n" + self._attribute_summary() diff --git a/src/pathpyG/core/higher_order_graph.py b/src/pathpyG/core/higher_order_graph.py new file mode 100644 index 00000000..b856a0c2 --- /dev/null +++ b/src/pathpyG/core/higher_order_graph.py @@ -0,0 +1,481 @@ +"""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.data import Data +from torch_geometric.utils import coalesce + +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 +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. + + 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`. 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. + + 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.") + + 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. + + 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. + """ + 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. + + 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. + + 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. + + 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)]) + >>> print(pp.HigherOrderGraph.from_temporal_graph(t, order=2, delta=1).nodes) + [('a', 'c'), ('c', 'd')] + """ + cls._validate_order(order) + 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. + + 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"))) + >>> 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. + + Equivalent to [`from_temporal_graph`][pathpyG.HigherOrderGraph.from_temporal_graph] + on the underlying temporal graph with the event graph's `delta`. + + 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. + + 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 + + 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.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. + + 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:]) diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py index 38647c2a..6f75f843 100644 --- a/src/pathpyG/core/multi_order_model.py +++ b/src/pathpyG/core/multi_order_model.py @@ -13,15 +13,15 @@ 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.algorithms.temporal import lift_order_temporal -from pathpyG.core.graph import Graph +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 -from pathpyG.utils.dbgnn import generate_bipartite_edge_index logger = logging.getLogger("root") @@ -31,13 +31,16 @@ 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. 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,20 +111,23 @@ 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: - 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: - 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,26 +167,30 @@ 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) + node_sequence = lift_node_sequence(edge_index, node_sequence) if event_graph is None: - edge_index = lift_order_temporal(g, delta) + edge_index = EventGraph.build_edge_index(g, delta) else: edge_index = event_graph edge_weight = aggregate_node_attributes(edge_index, edge_weight, "src") # 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,11 +201,40 @@ 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] return m + @classmethod + def from_event_graph( + cls, + eg: EventGraph, + max_order: int = 2, + cached: bool = True, + ) -> "MultiOrderModel": + """Create a multi-order model from a pre-built event graph. + + Args: + eg: The second-order temporal `EventGraph` to build the model from. + max_order: The maximum order of the model to compute. + cached: Whether to also keep the aggregated layers below `max_order`. + + Returns: + MultiOrderModel: A multi-order model equivalent to + `MultiOrderModel.from_temporal_graph(eg.to_temporal_graph(), delta=eg.delta, ...)`. + """ + m = cls() + m.layers = MultiOrderModel.from_temporal_graph( + eg.to_temporal_graph(), + delta=eg.delta, + max_order=max_order, + cached=cached, + event_graph=eg.data.edge_index.as_tensor(), + ).layers + return m + @staticmethod def from_path_data( path_data: PathData, max_order: int = 1, mode: str = "propagation", cached: bool = True @@ -223,17 +267,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] @@ -535,7 +586,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 diff --git a/src/pathpyG/core/temporal_graph.py b/src/pathpyG/core/temporal_graph.py index 588d82ac..2c09ad45 100644 --- a/src/pathpyG/core/temporal_graph.py +++ b/src/pathpyG/core/temporal_graph.py @@ -365,38 +365,12 @@ def __getitem__(self, key: Union[tuple, str]) -> Any: else: raise KeyError(key[0] + " is not a node or edge attribute") - def __str__(self) -> str: - """Return a string representation of the graph.""" - s = "Temporal Graph with {0} nodes, {1} unique edges and {2} events in [{3}, {4}]\n".format( + def _summary(self) -> str: + """Return a one-line summary of the temporal graph.""" + return "Temporal Graph with {0} nodes, {1} unique edges and {2} events in [{3}, {4}]".format( self.data.num_nodes, self.data.edge_index.unique(dim=1).size(dim=1), self.data.edge_index.size(1), self.start_time, self.end_time, ) - - attr = self.data.to_dict() - attr_types = {} - for k in attr: - t = type(attr[k]) - if t == torch.Tensor: - attr_types[k] = str(t) + " -> " + str(attr[k].size()) - else: - attr_types[k] = str(t) - - from pprint import pformat - - attribute_info: dict[str, dict[str, Any]] = { - "Node Attributes": {}, - "Edge Attributes": {}, - "Graph Attributes": {}, - } - for a in self.node_attrs(): - attribute_info["Node Attributes"][a] = attr_types[a] - for a in self.edge_attrs(): - attribute_info["Edge Attributes"][a] = attr_types[a] - for a in self.data.keys(): - if not self.data.is_node_attr(a) and not self.data.is_edge_attr(a): - attribute_info["Graph Attributes"][a] = attr_types[a] - s += pformat(attribute_info, indent=4, width=160) - return s diff --git a/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/algorithms/test_temporal.py b/tests/algorithms/test_temporal.py index c09c00dd..8bc26af3 100644 --- a/tests/algorithms/test_temporal.py +++ b/tests/algorithms/test_temporal.py @@ -1,20 +1,8 @@ from __future__ import annotations import numpy as np -import torch -from torch_geometric import EdgeIndex -from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths -from pathpyG.core.graph import Graph - - -def test_lift_order_temporal(simple_temporal_graph): - edge_index = lift_order_temporal(simple_temporal_graph, delta=5) - event_graph = Graph.from_edge_index(edge_index) - assert event_graph.n == simple_temporal_graph.m - # for delta=5 we have three time-respecting paths (a,b,1) -> (b,c,5), (b,c,5) -> (c,d,9) and (b,c,5) -> (c,e,9) - assert event_graph.m == 3 - assert torch.equal(event_graph.data.edge_index, EdgeIndex([[0, 1, 1], [1, 2, 3]])) +from pathpyG.algorithms.temporal import temporal_shortest_paths def test_temporal_shortest_paths(long_temporal_graph): diff --git a/tests/core/test_event_graph.py b/tests/core/test_event_graph.py new file mode 100644 index 00000000..039bc734 --- /dev/null +++ b/tests/core/test_event_graph.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import numpy as np +import pytest +import torch +from scipy.sparse.csgraph import dijkstra +from torch_geometric.data import Data + +from 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 + +DELTA = 2 + + +# Example temporal graph used in the tests: +# +# a +# | t=1 +# v +# b -------- t=5 --------> d +# | t=2 +# v +# c +# | t=3 +# v +# e +# +# Event graph corresponding to the above (with DELTA = 2): +# +# 0 --gap 1--> 1 --gap 1--> 2 +# 3 (isolated event) +# +# where +# +# event 0: (a->b)@1 +# event 1: (b->c)@2 +# event 2: (c->e)@3 +# event 3: (b->d)@5 + +@pytest.fixture +def temporal_graph() -> TemporalGraph: + return TemporalGraph.from_edge_list( + [ + ("a", "b", 1), + ("b", "c", 2), + ("b", "d", 5), + ("c", "e", 3), + ] + ) + + +@pytest.fixture +def event_graph(temporal_graph) -> EventGraph: + return EventGraph.from_temporal_graph(temporal_graph, delta=DELTA) + + +def test_basic(event_graph): + """Basic counts (delta, events, nodes) match the source temporal graph.""" + assert event_graph.delta == DELTA + assert len(event_graph) == 4 + assert event_graph.num_events == 4 + assert event_graph.n == 4 + assert event_graph.n_first_order == 5 + + +def test_str(event_graph): + """The string representation summarizes counts, time range, delta and attributes.""" + s = str(event_graph) + assert s.startswith( + "Event Graph (delta=2) with 5 first-order nodes, 4 events and 2 edges in [1, 5]\n" + ) + assert "'node_time'" in s + assert "'edge_delta'" in s + + +def test_node_time(event_graph): + """Each event node carries the timestamp of its underlying edge.""" + assert event_graph.data.node_time.tolist() == [1, 2, 3, 5] + + +def test_node_sequence(event_graph): + """Each event node stores the (source, target) first-order node pair.""" + assert event_graph.data.node_sequence.tolist() == [[0, 1], [1, 2], [2, 4], [1, 3]] + + +def test_first_order_mapping(event_graph, temporal_graph): + """The first-order node mapping round-trips and matches the temporal graph.""" + fo = event_graph.first_order_mapping + assert fo.num_ids() == 5 + for node in "abcde": + assert fo.to_id(fo.to_idx(node)) == node + assert fo.to_idx(node) == temporal_graph.mapping.to_idx(node) + + +def test_continuation_edge_index(event_graph): + """The continuation edge index matches the expected result.""" + got = event_graph.data.edge_index.as_tensor() + got_set = {tuple(c) for c in got.t().tolist()} + assert got_set == {(0, 1), (1, 2)} + + +def test_event_time(event_graph, ): + """event_time(i) returns the timestamp of the i-th event.""" + assert [event_graph.event_time(i) for i in range(event_graph.num_events)] == [1, 2, 3, 5] + + +def test_event_labels(event_graph): + """Events are labeled with as "u->v@t".""" + assert event_graph.nodes == ["a->b@1", "b->c@2", "c->e@3", "b->d@5"] + assert event_graph.edges == [("a->b@1", "b->c@2"), ("b->c@2", "c->e@3")] + assert event_graph.successors("a->b@1") == ["b->c@2"] + assert event_graph.predecessors("c->e@3") == ["b->c@2"] + + +def test_isolated_events(event_graph): + """Events with no predecessors or successors are correctly identified.""" + isolated = [ + i + for i in range(event_graph.num_events) + if event_graph.get_successors(i).numel() == 0 and event_graph.get_predecessors(i).numel() == 0 + ] + assert isolated == [3] + + +def test_edge_deltas(event_graph): + """Every edge_delta lies within (0, delta].""" + for i in range(event_graph.num_events): + for nxt in event_graph.get_successors(i): + nxt = int(nxt.item()) + delta = event_graph.data.edge_delta[event_graph.edge_to_index[(i, nxt)]].item() + assert 0 < delta <= event_graph.delta + + +def test_edge_delta_map(event_graph): + """Per-edge time delta map matches the expected value.""" + assert event_graph.edge_delta_map() == {(0, 1): 1, (1, 2): 1} + + +def test_shortest_paths_distances(event_graph): + """shortest_paths() distances match the expected result.""" + dist, _pred = event_graph.shortest_paths() + expected = np.array( + [ + [0, 1, 2, np.inf, 3], + [np.inf, 0, 1, 1, 2], + [np.inf, np.inf, 0, np.inf, 1], + [np.inf, np.inf, np.inf, 0, np.inf], + [np.inf, np.inf, np.inf, np.inf, 0], + ] + ) + np.testing.assert_array_equal(dist, expected) + + +def test_shortest_paths_predecessors(event_graph): + """shortest_paths() predecessors match the expected result.""" + _dist, pred = event_graph.shortest_paths() + expected = np.array( + [ + [0, 0, 1, -1, 2], + [-1, 1, 1, 1, 2], + [-1, -1, 2, -1, 2], + [-1, -1, -1, 3, -1], + [-1, -1, -1, -1, 4], + ] + ) + np.testing.assert_array_equal(pred, expected) + + +def test_shortest_paths_a_to_d_is_unreachable(event_graph): + """Transition a->d needs a->b@1 then b->d@5, gap of 4 > delta.""" + dist, _pred = event_graph.shortest_paths() + assert dist[0, 3] == np.inf + + +def test_fastest_path_distances(event_graph): + """Fastest-path distances over edge deltas match the expected result.""" + fastest = dijkstra(event_graph.sparse_adj_matrix(edge_attr="edge_delta"), directed=True) + expected = np.array( + [ + [0, 1, 2, np.inf], + [np.inf, 0, 1, np.inf], + [np.inf, np.inf, 0, np.inf], + [np.inf, np.inf, np.inf, 0], + ] + ) + np.testing.assert_array_equal(fastest, expected) + + +def test_to_temporal_graph_round_trip(event_graph, temporal_graph): + """An EventGraph converts back to an equivalent TemporalGraph.""" + rebuilt = event_graph.to_temporal_graph() + assert isinstance(rebuilt, TemporalGraph) + assert torch.equal( + rebuilt.data.edge_index.as_tensor(), + temporal_graph.data.edge_index.as_tensor(), + ) + assert torch.equal(rebuilt.data.time, temporal_graph.data.time) + assert rebuilt.n == temporal_graph.n + for node in ("a", "b", "c", "d", "e"): + assert rebuilt.mapping.to_idx(node) == temporal_graph.mapping.to_idx(node) + + +def test_multi_order_model_construction(event_graph, temporal_graph): + """A MultiOrderModel built from an EventGraph matches one from a TemporalGraph.""" + mom_eg = MultiOrderModel.from_event_graph(event_graph, max_order=2) + mom_tg = MultiOrderModel.from_temporal_graph(temporal_graph, delta=DELTA, max_order=2) + + for k in (1, 2): + assert torch.equal( + mom_eg.layers[k].data.edge_index.as_tensor(), + mom_tg.layers[k].data.edge_index.as_tensor(), + ) + assert torch.equal( + mom_eg.layers[k].data.edge_weight, + mom_tg.layers[k].data.edge_weight, + ) + + +def test_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")) + assert isinstance(moved, EventGraph) + assert moved is event_graph + assert moved.to_temporal_graph().data.edge_index.device.type == "cpu" + + +def test_reduce_delta(temporal_graph, event_graph): + """Reducing delta reproduces the graph built directly with the smaller delta.""" + eg_delta4 = EventGraph.from_temporal_graph(temporal_graph, delta=4) + eg_delta2 = eg_delta4.reduce_delta(decrement=2) + + assert eg_delta2.delta == 2 + assert eg_delta2.num_events == event_graph.num_events + assert eg_delta2.n_first_order == event_graph.n_first_order + assert torch.equal(eg_delta2.data.node_time, event_graph.data.node_time) + assert torch.equal(eg_delta2.data.node_sequence, event_graph.data.node_sequence) + assert eg_delta2.edge_delta_map() == event_graph.edge_delta_map() + + +@pytest.fixture +def attributed_temporal_graph(temporal_graph) -> TemporalGraph: + """Temporal graph carrying one edge-, node- and graph-level attribute.""" + # edges are sorted by time, i.e. (a,b)@1, (b,c)@2, (c,e)@3, (b,d)@5 + temporal_graph.data.edge_weight = torch.tensor([10.0, 20.0, 30.0, 40.0]) + temporal_graph.data.edge_temperature = np.array([4, 3, 2, 1]) # arbitrary edge-level attribute + temporal_graph.data.node_color = torch.arange(5) + temporal_graph.data.dataset_name = "toy" + return temporal_graph + + +def test_lift_attrs(attributed_temporal_graph): + """Edge attributes become node attributes, node attributes are dropped, graph attributes stay.""" + attrs = EventGraph.lift_attrs(attributed_temporal_graph) + + assert set(attrs) == {"node_temperature", "node_weight", "dataset_name"} + assert torch.equal(attrs["node_weight"], torch.tensor([10.0, 20.0, 30.0, 40.0])) + np.testing.assert_array_equal(attrs["node_temperature"], np.array([4, 3, 2, 1])) + assert attrs["dataset_name"] == "toy" + + +def test_lift_attrs_copies(attributed_temporal_graph): + """Lifted ndarray/tensor attributes are copies, so the event graph does not alias the temporal graph.""" + attrs = EventGraph.lift_attrs(attributed_temporal_graph) + attrs["node_weight"][0] = -1.0 + attrs["node_temperature"][0] = -1.0 + + assert attributed_temporal_graph.data.edge_weight[0].item() == 10.0 + assert attributed_temporal_graph.data.edge_temperature[0].item() == 4 + + +def test_from_temporal_graph_propagates_attrs(attributed_temporal_graph): + """Building an event graph propagates the attributes of the temporal graph.""" + eg = EventGraph.from_temporal_graph(attributed_temporal_graph, delta=DELTA) + + assert torch.equal(eg.data.node_weight, torch.tensor([10.0, 20.0, 30.0, 40.0])) + assert eg.data.dataset_name == "toy" + assert "node_color" not in eg.data + # the lifted attribute is recognized as a node attribute of the event graph + assert "node_weight" in eg.node_attrs() + assert "node_temperature" in eg.node_attrs() + assert "node_weight" not in eg.edge_attrs() + + +def test_from_temporal_graph_getitem(attributed_temporal_graph): + """Indexing an EventGraph works as expected.""" + eg = EventGraph.from_temporal_graph(attributed_temporal_graph, delta=DELTA) + + assert eg["dataset_name"] == "toy" + assert eg["node_weight"][0].item() == 10.0 + assert eg["node_temperature"][0].item() == 4 + assert eg[("node_temperature", "b->c@2")] == 3 + + +def test_reduce_delta_keeps_attrs(attributed_temporal_graph): + """Reducing delta keeps node and graph attributes and masks edge attributes.""" + eg = EventGraph.from_temporal_graph(attributed_temporal_graph, delta=DELTA) + eg.data.edge_label = torch.arange(eg.m) + kept = eg.data.edge_delta <= 1 + + reduced = eg.reduce_delta(1) + + assert torch.equal(reduced.data.node_weight, eg.data.node_weight) + assert reduced.data.dataset_name == "toy" + assert torch.equal(reduced.data.edge_label, eg.data.edge_label[kept]) + assert reduced.data.edge_label.size(0) == reduced.m + + +def test_reduce_delta_to_zero_removes_all_edges(event_graph): + """Reducing delta 2->0 leaves the events but drops every continuation edge.""" + reduced = event_graph.reduce_delta(2) + assert reduced.delta == 0 + assert reduced.num_events == event_graph.num_events + assert reduced.data.edge_index.as_tensor().numel() == 0 + with pytest.raises(ValueError): + event_graph.reduce_delta(3) # would make delta negative + + +""" +The following tests illustrate that an EventGraph can be constructed from a raw +`torch_geometric.data.Data` object. +""" +@pytest.fixture +def event_data() -> Data: + return Data( + edge_index=torch.tensor([[0, 1], [1, 2]]), + num_nodes=4, + node_sequence=torch.tensor([[0, 1], [1, 2], [2, 4], [1, 3]]), + node_time=torch.tensor([1, 2, 3, 5]), + ) + + +def test_construct_from_data(event_data): + """An EventGraph can be built from a raw Data object with correct edge deltas.""" + eg = EventGraph(event_data, delta=DELTA) + got = { + tuple(c): d + for c, d in zip( + eg.data.edge_index.as_tensor().t().tolist(), eg.data.edge_delta.tolist() + ) + } + assert got == {(0, 1): 1, (1, 2): 1} + + +def test_construct_from_data_to_temporal_graph(event_data): + """An EventGraph built from raw Data still converts to a TemporalGraph.""" + first_order_mapping = IndexMap(["a", "b", "c", "d", "e"]) + eg = EventGraph(event_data, delta=DELTA, first_order_mapping=first_order_mapping, n_first_order=5) + tg = eg.to_temporal_graph() + assert isinstance(tg, TemporalGraph) + assert tg.n == 5 + assert torch.equal( + tg.data.edge_index.as_tensor(), + torch.tensor([[0, 1, 2, 1], [1, 2, 4, 3]]), + ) + assert tg.data.time.tolist() == [1, 2, 3, 5] \ No newline at end of file diff --git a/tests/core/test_graph.py b/tests/core/test_graph.py index f27071bb..897b4e0b 100644 --- a/tests/core/test_graph.py +++ b/tests/core/test_graph.py @@ -35,6 +35,16 @@ def test_init_with_edge_index(): assert isinstance(g.edge_to_index, dict) +def test_init_with_presorted_edge_index_keeps_edge_attrs(): + # An EdgeIndex that already carries sort_order="row" is returned by sort_by without a + # permutation, in which case the edge attributes must be left as they are. + edge_index = EdgeIndex([[0, 0, 1], [1, 2, 2]], sparse_size=(3, 3), sort_order="row") + edge_weight = torch.tensor([1.0, 2.0, 3.0]) + g = Graph(Data(edge_index=edge_index, num_nodes=3, edge_weight=edge_weight)) + assert g.data.edge_weight.shape == edge_weight.shape + assert torch.equal(g.data.edge_weight, edge_weight) + + def test_init_with_mapping(): edge_index = get_random_edge_index(100, 100, 1000) data = Data(edge_index=edge_index, num_nodes=100) @@ -77,6 +87,7 @@ def test_from_edge_list(): ("b", "c"), ] g = Graph.from_edge_list(edge_list) + # Since node labels are strings, they are sorted lexicographically. assert g.mapping.to_idx("a") == 0 assert g.mapping.to_idx("b") == 1 assert g.mapping.to_idx("c") == 2 @@ -86,12 +97,15 @@ def test_from_edge_list(): (2, 1), ] g = Graph.from_edge_list(edge_list) + # Since node labels are ints, they are sorted numerically. assert g.mapping.to_idx(1) == 0 assert g.mapping.to_idx(2) == 1 assert g.mapping.to_idx(12) == 2 edge_list = [("1", "12"), ("2", "1"), ("21", "3")] g = Graph.from_edge_list(edge_list) + # Node labels are strings, but since ALL of them are numeric, they are sorted + # numerically, not lexicographically. assert g.mapping.to_idx("1") == 0 assert g.mapping.to_idx("2") == 1 assert g.mapping.to_idx("3") == 2 @@ -280,8 +294,8 @@ def test_add_operator_wo_indices(): g1 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), num_nodes=4) g2 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), num_nodes=4) g = g1 + g2 - assert g.n == g1.n - assert g.m == g1.m + g2.m + assert g.n == g1.n # No relabeling - number of nodes stays the same + assert g.m == g1.m + g2.m # No deduplication - number of edges goes up assert torch.equal(g.data.edge_index, torch.tensor([[0, 0, 1, 1, 1, 1], [1, 1, 2, 3, 2, 3]])) g3 = Graph.from_edge_index(torch.IntTensor([[0, 2, 3], [2, 3, 4]]), num_nodes=5) @@ -296,8 +310,8 @@ def test_add_operator_complete_overlap(): g1 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), mapping=IndexMap(["a", "b", "c", "d"])) g2 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), mapping=IndexMap(["a", "b", "c", "d"])) g = g1 + g2 - assert g.n == g1.n - assert g.m == g1.m + g2.m + assert g.n == g1.n # No relabeling - number of nodes stays the same + assert g.m == g1.m + g2.m # No deduplication - number of edges goes up assert torch.equal(g.data.edge_index, torch.tensor([[0, 0, 1, 1, 1, 1], [1, 1, 2, 3, 2, 3]])) diff --git a/tests/core/test_index_map.py b/tests/core/test_index_map.py index 6cb91e1c..c7130730 100644 --- a/tests/core/test_index_map.py +++ b/tests/core/test_index_map.py @@ -122,7 +122,7 @@ def test_bulk_ids(): node_ids = np.array(["a", "c", "b", "d", "a", "e"]) with pytest.raises(ValueError): mapping = IndexMap(node_ids) - mapping = IndexMap(np.unique(node_ids)) + mapping = IndexMap(np.unique(node_ids)) # sorts assert mapping.to_idx("a") == 0 assert mapping.to_idx("c") == 2 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 diff --git a/tests/visualisations/test_network_plot.py b/tests/visualisations/test_network_plot.py index d2971056..c584767a 100644 --- a/tests/visualisations/test_network_plot.py +++ b/tests/visualisations/test_network_plot.py @@ -4,10 +4,12 @@ import pandas as pd import pytest +from pathpyG.core.event_graph import EventGraph from pathpyG.core.graph import Graph from pathpyG.core.index_map import IndexMap from pathpyG.core.multi_order_model import MultiOrderModel from pathpyG.core.path_data import PathData +from pathpyG.core.temporal_graph import TemporalGraph from pathpyG.visualisations.network_plot import NetworkPlot @@ -202,6 +204,14 @@ def test_higher_order_network(self): # Index should be stringified tuples assert list(nodes.index) == ["a->b", "a->d", "b->c", "c->a"] + def test_event_graph(self): + # An event graph has order 2, but its node IDs are plain strings + tg = TemporalGraph.from_edge_list([("a", "b", 1), ("b", "c", 2), ("b", "d", 5), ("c", "e", 3)]) + eg = EventGraph.from_temporal_graph(tg, delta=2) + plot = NetworkPlot(eg) + assert list(plot.data["nodes"].index) == ["a->b@1", "b->c@2", "c->e@3", "b->d@5"] + assert list(plot.data["edges"].index) == [("a->b@1", "b->c@2"), ("b->c@2", "c->e@3")] + def test_invalid_image_path_raises(self): with pytest.raises(AttributeError): NetworkPlot(self.g, node_image="/nonexistent/path/to/image.png")