diff --git a/docs/tutorial/archive/_scalability_analysis.ipynb b/docs/tutorial/archive/_scalability_analysis.ipynb index 799c0550..6c83ac7f 100644 --- a/docs/tutorial/archive/_scalability_analysis.ipynb +++ b/docs/tutorial/archive/_scalability_analysis.ipynb @@ -36,7 +36,7 @@ " res['temp_net_events'] = g.data.edge_index.size(1)\n", "\n", " start_time = time.time()\n", - " eg = pp.algorithms.lift_order_temporal(g, delta=exp['delta'])\n", + " eg = pp.core.event_graph.EventGraph.build_edge_index(g, delta=exp['delta'])\n", " eg.to(exp['device'])\n", " res['lift_event_graph_time'] = time.time() - start_time\n", " res['event_graph_edges'] = eg.size(1)\n", @@ -1392,13 +1392,13 @@ "evalue": "", "output_type": "error", "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mKeyboardInterrupt\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[4], line 13\u001b[0m\n\u001b[1;32m 11\u001b[0m exp[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmax_order\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m k\n\u001b[1;32m 12\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[0;32m---> 13\u001b[0m res \u001b[38;5;241m=\u001b[39m \u001b[43mtest_mo_scalability\u001b[49m\u001b[43m(\u001b[49m\u001b[43mg\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mexp\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 14\u001b[0m printer\u001b[38;5;241m.\u001b[39mpprint(res)\n\u001b[1;32m 15\u001b[0m results_rm[delta][k] \u001b[38;5;241m=\u001b[39m res\n", - "Cell \u001b[0;32mIn[2], line 16\u001b[0m, in \u001b[0;36mtest_mo_scalability\u001b[0;34m(g, exp)\u001b[0m\n\u001b[1;32m 13\u001b[0m res[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mevent_graph_edges\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m eg\u001b[38;5;241m.\u001b[39msize(\u001b[38;5;241m1\u001b[39m)\n\u001b[1;32m 15\u001b[0m start_time \u001b[38;5;241m=\u001b[39m time\u001b[38;5;241m.\u001b[39mtime()\n\u001b[0;32m---> 16\u001b[0m m \u001b[38;5;241m=\u001b[39m \u001b[43mpp\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mMultiOrderModel\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mfrom_temporal_graph\u001b[49m\u001b[43m(\u001b[49m\u001b[43mg\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mdelta\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mexp\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mdelta\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mmax_order\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mexp\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mmax_order\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 17\u001b[0m res[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmo_time\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m time\u001b[38;5;241m.\u001b[39mtime() \u001b[38;5;241m-\u001b[39m start_time\n\u001b[1;32m 18\u001b[0m res[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmax_order_nodes\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m m\u001b[38;5;241m.\u001b[39mlayers[exp[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mmax_order\u001b[39m\u001b[38;5;124m'\u001b[39m]]\u001b[38;5;241m.\u001b[39mN\n", - "File \u001b[0;32m/workspaces/pathpyG/src/pathpyG/core/multi_order_model.py:108\u001b[0m, in \u001b[0;36mMultiOrderModel.from_temporal_graph\u001b[0;34m(g, delta, max_order, weight, cached)\u001b[0m\n\u001b[1;32m 106\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m max_order \u001b[38;5;241m>\u001b[39m \u001b[38;5;241m1\u001b[39m:\n\u001b[1;32m 107\u001b[0m node_sequence \u001b[38;5;241m=\u001b[39m torch\u001b[38;5;241m.\u001b[39mcat([node_sequence[edge_index[\u001b[38;5;241m0\u001b[39m]], node_sequence[edge_index[\u001b[38;5;241m1\u001b[39m]][:, \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m1\u001b[39m:]], dim\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m1\u001b[39m)\n\u001b[0;32m--> 108\u001b[0m edge_index \u001b[38;5;241m=\u001b[39m \u001b[43mlift_order_temporal\u001b[49m\u001b[43m(\u001b[49m\u001b[43mg\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mdelta\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 109\u001b[0m edge_weight \u001b[38;5;241m=\u001b[39m aggregate_node_attributes(edge_index, edge_weight, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124msrc\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 111\u001b[0m \u001b[38;5;66;03m# Aggregate\u001b[39;00m\n", - "File \u001b[0;32m/workspaces/pathpyG/src/pathpyG/algorithms/temporal.py:39\u001b[0m, in \u001b[0;36mlift_order_temporal\u001b[0;34m(g, delta)\u001b[0m\n\u001b[1;32m 36\u001b[0m dst_node_mask \u001b[38;5;241m=\u001b[39m torch\u001b[38;5;241m.\u001b[39misin(edge_index[\u001b[38;5;241m0\u001b[39m], edge_index[\u001b[38;5;241m1\u001b[39m, src_edge_idx])\n\u001b[1;32m 37\u001b[0m dst_edge_idx \u001b[38;5;241m=\u001b[39m indices[dst_time_mask \u001b[38;5;241m&\u001b[39m dst_node_mask]\n\u001b[0;32m---> 39\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[43mdst_edge_idx\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43msize\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m0\u001b[39;49m\u001b[43m)\u001b[49m \u001b[38;5;241m>\u001b[39m \u001b[38;5;241m0\u001b[39m \u001b[38;5;129;01mand\u001b[39;00m src_edge_idx\u001b[38;5;241m.\u001b[39msize(\u001b[38;5;241m0\u001b[39m) \u001b[38;5;241m>\u001b[39m \u001b[38;5;241m0\u001b[39m:\n\u001b[1;32m 40\u001b[0m \n\u001b[1;32m 41\u001b[0m \u001b[38;5;66;03m# compute second-order edges between src and dst idx for all edges where dst in src_edges matches src in dst_edges\u001b[39;00m\n\u001b[1;32m 42\u001b[0m x \u001b[38;5;241m=\u001b[39m torch\u001b[38;5;241m.\u001b[39mcartesian_prod(src_edge_idx, dst_edge_idx)\u001b[38;5;241m.\u001b[39mt()\n\u001b[1;32m 43\u001b[0m \u001b[38;5;66;03m# print(x.size(1))\u001b[39;00m\n", - "\u001b[0;31mKeyboardInterrupt\u001b[0m: " + "\u001B[0;31m---------------------------------------------------------------------------\u001B[0m", + "\u001B[0;31mKeyboardInterrupt\u001B[0m Traceback (most recent call last)", + "Cell \u001B[0;32mIn[4], line 13\u001B[0m\n\u001B[1;32m 11\u001B[0m exp[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmax_order\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m k\n\u001B[1;32m 12\u001B[0m \u001B[38;5;28;01mtry\u001B[39;00m:\n\u001B[0;32m---> 13\u001B[0m res \u001B[38;5;241m=\u001B[39m \u001B[43mtest_mo_scalability\u001B[49m\u001B[43m(\u001B[49m\u001B[43mg\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mexp\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 14\u001B[0m printer\u001B[38;5;241m.\u001B[39mpprint(res)\n\u001B[1;32m 15\u001B[0m results_rm[delta][k] \u001B[38;5;241m=\u001B[39m res\n", + "Cell \u001B[0;32mIn[2], line 16\u001B[0m, in \u001B[0;36mtest_mo_scalability\u001B[0;34m(g, exp)\u001B[0m\n\u001B[1;32m 13\u001B[0m res[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mevent_graph_edges\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m eg\u001B[38;5;241m.\u001B[39msize(\u001B[38;5;241m1\u001B[39m)\n\u001B[1;32m 15\u001B[0m start_time \u001B[38;5;241m=\u001B[39m time\u001B[38;5;241m.\u001B[39mtime()\n\u001B[0;32m---> 16\u001B[0m m \u001B[38;5;241m=\u001B[39m \u001B[43mpp\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mMultiOrderModel\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mfrom_temporal_graph\u001B[49m\u001B[43m(\u001B[49m\u001B[43mg\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mdelta\u001B[49m\u001B[38;5;241;43m=\u001B[39;49m\u001B[43mexp\u001B[49m\u001B[43m[\u001B[49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[38;5;124;43mdelta\u001B[39;49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[43m]\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mmax_order\u001B[49m\u001B[38;5;241;43m=\u001B[39;49m\u001B[43mexp\u001B[49m\u001B[43m[\u001B[49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[38;5;124;43mmax_order\u001B[39;49m\u001B[38;5;124;43m'\u001B[39;49m\u001B[43m]\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 17\u001B[0m res[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmo_time\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m time\u001B[38;5;241m.\u001B[39mtime() \u001B[38;5;241m-\u001B[39m start_time\n\u001B[1;32m 18\u001B[0m res[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmax_order_nodes\u001B[39m\u001B[38;5;124m'\u001B[39m] \u001B[38;5;241m=\u001B[39m m\u001B[38;5;241m.\u001B[39mlayers[exp[\u001B[38;5;124m'\u001B[39m\u001B[38;5;124mmax_order\u001B[39m\u001B[38;5;124m'\u001B[39m]]\u001B[38;5;241m.\u001B[39mN\n", + "File \u001B[0;32m/workspaces/pathpyG/src/pathpyG/core/multi_order_model.py:108\u001B[0m, in \u001B[0;36mMultiOrderModel.from_temporal_graph\u001B[0;34m(g, delta, max_order, weight, cached)\u001B[0m\n\u001B[1;32m 106\u001B[0m \u001B[38;5;28;01mif\u001B[39;00m max_order \u001B[38;5;241m>\u001B[39m \u001B[38;5;241m1\u001B[39m:\n\u001B[1;32m 107\u001B[0m node_sequence \u001B[38;5;241m=\u001B[39m torch\u001B[38;5;241m.\u001B[39mcat([node_sequence[edge_index[\u001B[38;5;241m0\u001B[39m]], node_sequence[edge_index[\u001B[38;5;241m1\u001B[39m]][:, \u001B[38;5;241m-\u001B[39m\u001B[38;5;241m1\u001B[39m:]], dim\u001B[38;5;241m=\u001B[39m\u001B[38;5;241m1\u001B[39m)\n\u001B[0;32m--> 108\u001B[0m edge_index \u001B[38;5;241m=\u001B[39m \u001B[43mlift_order_temporal\u001B[49m\u001B[43m(\u001B[49m\u001B[43mg\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mdelta\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 109\u001B[0m edge_weight \u001B[38;5;241m=\u001B[39m aggregate_node_attributes(edge_index, edge_weight, \u001B[38;5;124m\"\u001B[39m\u001B[38;5;124msrc\u001B[39m\u001B[38;5;124m\"\u001B[39m)\n\u001B[1;32m 111\u001B[0m \u001B[38;5;66;03m# Aggregate\u001B[39;00m\n", + "File \u001B[0;32m/workspaces/pathpyG/src/pathpyG/algorithms/temporal.py:39\u001B[0m, in \u001B[0;36mlift_order_temporal\u001B[0;34m(g, delta)\u001B[0m\n\u001B[1;32m 36\u001B[0m dst_node_mask \u001B[38;5;241m=\u001B[39m torch\u001B[38;5;241m.\u001B[39misin(edge_index[\u001B[38;5;241m0\u001B[39m], edge_index[\u001B[38;5;241m1\u001B[39m, src_edge_idx])\n\u001B[1;32m 37\u001B[0m dst_edge_idx \u001B[38;5;241m=\u001B[39m indices[dst_time_mask \u001B[38;5;241m&\u001B[39m dst_node_mask]\n\u001B[0;32m---> 39\u001B[0m \u001B[38;5;28;01mif\u001B[39;00m \u001B[43mdst_edge_idx\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43msize\u001B[49m\u001B[43m(\u001B[49m\u001B[38;5;241;43m0\u001B[39;49m\u001B[43m)\u001B[49m \u001B[38;5;241m>\u001B[39m \u001B[38;5;241m0\u001B[39m \u001B[38;5;129;01mand\u001B[39;00m src_edge_idx\u001B[38;5;241m.\u001B[39msize(\u001B[38;5;241m0\u001B[39m) \u001B[38;5;241m>\u001B[39m \u001B[38;5;241m0\u001B[39m:\n\u001B[1;32m 40\u001B[0m \n\u001B[1;32m 41\u001B[0m \u001B[38;5;66;03m# compute second-order edges between src and dst idx for all edges where dst in src_edges matches src in dst_edges\u001B[39;00m\n\u001B[1;32m 42\u001B[0m x \u001B[38;5;241m=\u001B[39m torch\u001B[38;5;241m.\u001B[39mcartesian_prod(src_edge_idx, dst_edge_idx)\u001B[38;5;241m.\u001B[39mt()\n\u001B[1;32m 43\u001B[0m \u001B[38;5;66;03m# print(x.size(1))\u001B[39;00m\n", + "\u001B[0;31mKeyboardInterrupt\u001B[0m: " ] } ], diff --git a/docs/tutorial/implementation_concepts.ipynb b/docs/tutorial/implementation_concepts.ipynb index 8bcdad2f..d6e1de83 100644 --- a/docs/tutorial/implementation_concepts.ipynb +++ b/docs/tutorial/implementation_concepts.ipynb @@ -2023,7 +2023,7 @@ "source": [ "### Temporal Order Lifting\n", "\n", - "One of the core functionalities of PathpyG is the ability to create temporal higher-order models. For this, an extension of the `lift_order_edge_index` function to temporal graphs is needed. We implement this in the `lift_order_temporal` function. This function works similarly to the `lift_order_edge_index` function, but with some additional steps to account for the temporal aspect of the graph. The main difference is that we need to ensure that the higher-order edges respect the temporal ordering of the original edges. Let us take a look at an example:" + "One of the core functionalities of PathpyG is the ability to create temporal higher-order models. For this, an extension of the `lift_order_edge_index` function to temporal graphs is needed. We implement this in the `EventGraph.build_edge_index` function. This function works similarly to the `lift_order_edge_index` function, but with some additional steps to account for the temporal aspect of the graph. The main difference is that we need to ensure that the higher-order edges respect the temporal ordering of the original edges. Let us take a look at an example:" ] }, { @@ -2690,9 +2690,7 @@ "cell_type": "markdown", "id": "951a8ffa", "metadata": {}, - "source": [ - "We can create a second-order graph from this temporal graph using the `lift_order_temporal` function. This second-order graph is typically referred to as an event graph. Each node in the graph is an event (edge) in the original temporal graph and two events are connected if they can follow each other in time respecting a maximum time difference `delta`. Here, we set `delta=2` which means that two events can be connected if the time difference between them is at most 2 time units." - ] + "source": "We can create a second-order graph from this temporal graph using the `EventGraph.build_edge_index` function. This second-order graph is typically referred to as an event graph. Each node in the graph is an event (edge) in the original temporal graph and two events are connected if they can follow each other in time respecting a maximum time difference `delta`. Here, we set `delta=2` which means that two events can be connected if the time difference between them is at most 2 time units." }, { "cell_type": "code", @@ -3223,7 +3221,7 @@ } ], "source": [ - "event_edge_index = pp.algorithms.temporal.lift_order_temporal(t, delta=2)\n", + "event_edge_index = pp.core.event_graph.EventGraph.build_edge_index(t, delta=2)\n", "event_mapping = pp.IndexMap(t.temporal_edges)\n", "event_data = Data(edge_index=event_edge_index, node_sequence=graph.data.edge_index.t())\n", "event_graph = pp.Graph(data=event_data, mapping=event_mapping)\n", @@ -3237,9 +3235,9 @@ "source": [ "Starting with the event graph, we have a static higher-order representation of the temporal graph that we can use to create higher-order models. For each following lift-order transformations, we can use the same principles as described in the previous section on order-lifting and line graph transformations. \n", "\n", - "#### Internals of the `lift_order_temporal` Function\n", + "#### Internals of the `EventGraph.build_edge_index` Function\n", "\n", - "The simplest way to implement the `lift_order_temporal` function would be to first create the full higher-order edge index using the `lift_order_edge_index` function and then filter out the edges that do not respect the temporal ordering. The filter function could look as follows:" + "The simplest way to implement the `EventGraph.build_edge_index` function would be to first create the full higher-order edge index using the `lift_order_edge_index` function and then filter out the edges that do not respect the temporal ordering. The filter function could look as follows:" ] }, { @@ -3825,7 +3823,7 @@ "
\n", "\n", "\n", - "However, the above implementation has a large memory consumption for graphs with many edges because the full higher-order edge index is created before filtering. Therefore, we implement a more memory-efficient version in PathpyG that constructs the higher-order edges from the temporal graph sequentially for each timestamp. This implementation looks as follows:" + "However, the above implementation has a large memory consumption for graphs with many edges because the full higher-order edge index is created before filtering. Therefore, we implement a more memory-efficient version in PathpyG that constructs the higher-order edges from the temporal graph sequentially for each timestamp. This implementation (available as `EventGraph.build_edge_index`) looks as follows:" ] }, { @@ -3835,30 +3833,31 @@ "metadata": {}, "outputs": [], "source": [ - "def lift_order_temporal(g: pp.TemporalGraph, delta: int = 1): # noqa: D103\n", - " indices = torch.arange(0, g.data.edge_index.size(1))\n", + "def build_edge_index(g: pp.TemporalGraph, delta: int = 1): # noqa: D103\n", + " # first-order edge index\n", + " edge_index, timestamps = g.data.edge_index, g.data.time\n", "\n", - " unique_t = torch.unique(g.data.time)\n", + " delta = torch.tensor(delta, device=edge_index.device) # type: ignore[assignment]\n", + " indices = torch.arange(0, edge_index.size(1), device=edge_index.device)\n", + "\n", + " unique_t = torch.unique(timestamps, sorted=True)\n", " second_order = []\n", "\n", " # lift order: find possible continuations for edges in each time stamp\n", " for t in unique_t:\n", - "\n", " # find indices of all source edges that occur at unique timestamp t\n", - " src_time_mask = g.data.time == t\n", + " src_time_mask = timestamps == t\n", " src_edge_idx = indices[src_time_mask]\n", "\n", " # find indices of all edges that can possibly continue edges occurring at time t for the given delta\n", - " dst_time_mask = (g.data.time > t) & (g.data.time <= t + delta)\n", + " dst_time_mask = (timestamps > t) & (timestamps <= t + delta)\n", " dst_edge_idx = indices[dst_time_mask]\n", "\n", " if dst_edge_idx.size(0) > 0 and src_edge_idx.size(0) > 0:\n", " # compute second-order edges between src and dst idx\n", - " # create all possible combinations of src and dst edges\n", + " # for all edges where dst in src_edges (edge_index[1, x[:, 0]]) matches src in dst_edges (edge_index[0, x[:, 1]])\n", " x = torch.cartesian_prod(src_edge_idx, dst_edge_idx)\n", - " # filter combinations for real higher-order edges\n", - " # for all edges where dst in src_edges (g.data.edge_index[1, x[:, 0]]) matches src in dst_edges (g.data.edge_index[0, x[:, 1]])\n", - " ho_edge_index = x[g.data.edge_index[1, x[:, 0]] == g.data.edge_index[0, x[:, 1]]]\n", + " ho_edge_index = x[edge_index[1, x[:, 0]] == edge_index[0, x[:, 1]]]\n", " second_order.append(ho_edge_index)\n", "\n", " ho_index = torch.cat(second_order, dim=0).t().contiguous()\n", @@ -5691,7 +5690,7 @@ "id": "94f6db38", "metadata": {}, "source": [ - "We can see that the second-order graph created by the `MultiOrderModel` is different from the one created by the `lift_order_temporal` function directly. This is because the `MultiOrderModel` higher-order DeBruijn graph representation. This representation merges higher-order nodes that correspond to the same path in the original graph. This means that temporal edges that appear in the event graph as different nodes will be merged into one node in the DeBruijn graph if they correspond to the same path in the original graph. This results in a more compact representation of the higher-order graph.\n", + "We can see that the second-order graph created by the `MultiOrderModel` is different from the one created by the `EventGraph.build_edge_index` function directly. This is because the `MultiOrderModel` higher-order DeBruijn graph representation. This representation merges higher-order nodes that correspond to the same path in the original graph. This means that temporal edges that appear in the event graph as different nodes will be merged into one node in the DeBruijn graph if they correspond to the same path in the original graph. This results in a more compact representation of the higher-order graph.\n", "\n", "\n", "The same is true for paths. We can create a multi-order model from a collection of paths as follows:" @@ -6241,7 +6240,7 @@ "Let us now take a closer look at how the `MultiOrderModel` class works under the hood. We already saw that the `MultiOrderModel` merges higher-order nodes from the line/event graph transformations. \n", "\n", "This is done in 3 distinct steps which we will go through using the paths example above:\n", - "1. **Order Lifting**: First, we create the higher-order edge index using the appropriate lift-order function (`lift_order_edge_index` or `lift_order_temporal`) depending on whether we are working with paths or temporal graphs in the first order and `lift_order_edge_index` for the second order and beyond regardless of the input type.\n", + "1. **Order Lifting**: First, we create the higher-order edge index using the appropriate lift-order function (`EventGraph.build_edge_index` or `lift_order_temporal`) depending on whether we are working with paths or temporal graphs in the first order and `lift_order_edge_index` for the second order and beyond regardless of the input type.\n", "\n", "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/algorithms/__init__.py b/src/pathpyG/algorithms/__init__.py index d2deda91..0cf041a0 100644 --- a/src/pathpyG/algorithms/__init__.py +++ b/src/pathpyG/algorithms/__init__.py @@ -21,7 +21,7 @@ ]) # Extract DAG capturing causal interaction sequences in temporal graph. - e_i = pp.algorithms.lift_order_temporal(g, delta=1) + e_i = EventGraph.build_edge_index(g, delta=1) dag = pp.Graph.from_edge_index(e_i) print(dag) @@ -33,11 +33,10 @@ from pathpyG.algorithms import centrality, generative_models, shortest_paths from pathpyG.algorithms.components import connected_components, largest_connected_component from pathpyG.algorithms.rolling_time_window import RollingTimeWindow -from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths +from pathpyG.algorithms.temporal import temporal_shortest_paths from pathpyG.algorithms.weisfeiler_leman import WeisfeilerLeman_test __all__ = [ - "lift_order_temporal", "temporal_shortest_paths", "centrality", "generative_models", diff --git a/src/pathpyG/algorithms/centrality.py b/src/pathpyG/algorithms/centrality.py index c65a4a7e..394afdcd 100644 --- a/src/pathpyG/algorithms/centrality.py +++ b/src/pathpyG/algorithms/centrality.py @@ -42,7 +42,8 @@ from torch_geometric.utils import to_networkx from tqdm import tqdm -from pathpyG.algorithms.temporal import lift_order_temporal, temporal_shortest_paths +from pathpyG.algorithms.temporal import temporal_shortest_paths +from pathpyG.core.event_graph import EventGraph from pathpyG.core.graph import Graph from pathpyG.core.path_data import PathData from pathpyG.core.temporal_graph import TemporalGraph @@ -193,7 +194,7 @@ def temporal_betweenness_centrality(graph: TemporalGraph, delta: int = 1) -> dic ``` """ # generate temporal event DAG - edge_index = lift_order_temporal(graph, delta) + edge_index = EventGraph.build_edge_index(graph, delta) # Add indices of first-order nodes as src of paths in augmented # temporal event DAG diff --git a/src/pathpyG/algorithms/temporal.py b/src/pathpyG/algorithms/temporal.py index 108268be..3218ff43 100644 --- a/src/pathpyG/algorithms/temporal.py +++ b/src/pathpyG/algorithms/temporal.py @@ -7,67 +7,34 @@ 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(g: TemporalGraph | None, delta: int, eg: EventGraph | None = None) -> Tuple[np.ndarray, np.ndarray]: """Compute shortest time-respecting paths in a temporal graph. Args: - g: Temporal graph to compute shortest paths on. + g: Temporal graph to compute shortest paths on. If None, `eg` must be provided. delta: Maximum time difference between events in a path. + eg: Event graph to compute shortest paths on. If None, `g` must be provided. Returns: Tuple of two numpy arrays: - dist: Shortest time-respecting path distances between all first-order nodes. - pred: Predecessor matrix for shortest time-respecting paths between all first-order nodes. """ - # generate temporal event DAG - edge_index = lift_order_temporal(g, delta) + assert g is None or eg is None, "Only one of g or eg can be provided" + if g is None: + assert eg is not None, "If g is None, eg must be provided" + edge_index = eg.data.edge_index + g = eg.to_temporal_graph() + else: + # generate temporal event DAG + edge_index = EventGraph.build_edge_index(g, delta) # Add indices of first-order nodes as src and dst of paths in augmented # temporal event DAG diff --git a/src/pathpyG/core/event_graph.py b/src/pathpyG/core/event_graph.py new file mode 100644 index 00000000..e979eb3b --- /dev/null +++ b/src/pathpyG/core/event_graph.py @@ -0,0 +1,210 @@ +"""Event graph representation of a temporal graph and related operations.""" +from __future__ import annotations + +from typing import Tuple + +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 + + +class EventGraph(Graph): + """A directed acyclic graph whose nodes are time-stamped events.""" + + 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(g: TemporalGraph, delta: float | int = 1): + """Build the event-graph edge index by lifting a temporal graph to second order. + + Each temporal edge of `g` becomes an event (node); two events are connected + when the second can continue the first within the time window `delta`. + + Args: + g: Temporal graph to lift. + delta: Maximum time difference between events to consider them connected. + + Returns: + ho_index: Edge index of the second-order temporal event graph. + """ + # first-order edge index + edge_index, timestamps = g.data.edge_index, g.data.time + + delta = torch.tensor(delta, device=edge_index.device) # type: ignore[assignment] + indices = torch.arange(0, edge_index.size(1), device=edge_index.device) + + unique_t = torch.unique(timestamps, sorted=True) + second_order = [] + + # lift order: find possible continuations for edges in each time stamp + for t in tqdm(unique_t): + # find indices of all source edges that occur at unique timestamp t + src_time_mask = timestamps == t + src_edge_idx = indices[src_time_mask] + + # find indices of all edges that can possibly continue edges occurring at time t for the given delta + dst_time_mask = (timestamps > t) & (timestamps <= t + delta) + dst_edge_idx = indices[dst_time_mask] + + if dst_edge_idx.size(0) > 0 and src_edge_idx.size(0) > 0: + # compute second-order edges between src and dst idx + # for all edges where dst in src_edges (edge_index[1, x[:, 0]]) matches src in dst_edges (edge_index[0, x[:, 1]]) + x = torch.cartesian_prod(src_edge_idx, dst_edge_idx) + ho_edge_index = x[edge_index[1, x[:, 0]] == edge_index[0, x[:, 1]]] + second_order.append(ho_edge_index) + + ho_index = torch.cat(second_order, dim=0).t().contiguous() + return ho_index + + @classmethod + def from_temporal_graph(cls, g: TemporalGraph, delta: int = 1) -> "EventGraph": + """Build an EventGraph from a temporal graph by lifting its edges into events.""" + ho_index = cls.build_edge_index(g, delta) + m = g.data.time.size(0) # number of events (== number of first-order edges) + node_sequence = g.data.edge_index.as_tensor().t().contiguous() # [m, 2] + node_time = g.data.time.clone() # [m] + + # Build an event mapping with IDs of the form "a->b@t" for each edge node + event_ids = [ + f"{g.mapping.to_id(u)}->{g.mapping.to_id(v)}@{t}" + for (u, v), t in zip(node_sequence.tolist(), node_time.tolist()) + ] + mapping = IndexMap(event_ids) + + data = Data( + edge_index=ho_index, + num_nodes=m, + node_sequence=node_sequence, + node_time=node_time, + ) + eg = cls(data, delta=delta, first_order_mapping=g.mapping, n_first_order=g.n, mapping=mapping) + + # Attach a clone of the temporal graph since we already have it + eg._temporal_graph = TemporalGraph(g.data.clone(), mapping=g.mapping) + + return eg + + def __str__(self) -> str: + """Return a human-readable summary listing the delta and all events.""" + return ( + f"EventGraph (delta={self.delta})\n" + + "\n".join(f"{self.mapping.to_id(i)}" for i in range(self.n)) + ) + + def __len__(self): + """Return the number of events in the graph.""" + return self.n + + def __getitem__(self, key): + """Return the (u, v, t) endpoints for an integer key, else delegate to `Graph`.""" + if isinstance(key, (int, np.integer)) and not isinstance(key, bool): + u, v = self.data.node_sequence[key].tolist() + return self.first_order_mapping.to_id(u), self.first_order_mapping.to_id(v), self.data.node_time[key].item() + return super().__getitem__(key) + + def to(self, device: torch.device) -> "EventGraph": + """Move the event graph and its underlying temporal graph to the given device.""" + super().to(device) + 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 + + def event_time(self, i: int) -> int: + """Return the timestamp of the i-th event.""" + return self.data.node_time[i].item() + + def edge_delta_map(self) -> dict[tuple[int, int], int]: + """Return a mapping from each transition edge (src, dst) to its time delta.""" + return { + tuple(c): d + for c, d in zip( + self.data.edge_index.as_tensor().t().tolist(), + self.data.edge_delta.tolist(), + ) + } + + def shortest_paths(self) -> Tuple[np.ndarray, np.ndarray]: + """Return first-order shortest-path distances and predecessors respecting delta.""" + from pathpyG.algorithms.temporal import temporal_shortest_paths + + return temporal_shortest_paths(g=None, delta=self.delta, eg=self) + + def reduce_delta(self, decrement: int = 1) -> "EventGraph": + """Return a new EventGraph with a reduced time window `delta - decrement`.""" + new_delta = self.delta - decrement + if new_delta < 0: + raise ValueError( + f"decrement={decrement} exceeds current delta={self.delta}" + ) + + ei = self.data.edge_index + edge_delta = self.data.node_time[ei[1]] - self.data.node_time[ei[0]] + mask = edge_delta <= new_delta + new_edge_index = ei[:, mask].contiguous() + + data = Data( + edge_index=new_edge_index, + num_nodes=self.n, + node_sequence=self.data.node_sequence.clone(), + node_time=self.data.node_time.clone(), + ) + return EventGraph( + data, + delta=new_delta, + first_order_mapping=self.first_order_mapping, + n_first_order=self.n_first_order, + mapping=self.mapping, + ) \ No newline at end of file diff --git a/src/pathpyG/core/multi_order_model.py b/src/pathpyG/core/multi_order_model.py index 38647c2a..f7739d27 100644 --- a/src/pathpyG/core/multi_order_model.py +++ b/src/pathpyG/core/multi_order_model.py @@ -16,7 +16,7 @@ lift_order_edge_index, lift_order_edge_index_weighted, ) -from pathpyG.algorithms.temporal import lift_order_temporal +from pathpyG.core.event_graph import EventGraph from pathpyG.core.graph import Graph from pathpyG.core.index_map import IndexMap from pathpyG.core.path_data import PathData @@ -164,7 +164,7 @@ def from_temporal_graph( if max_order > 1: node_sequence = torch.cat([node_sequence[edge_index[0]], node_sequence[edge_index[1]][:, -1:]], dim=1) if event_graph is None: - edge_index = lift_order_temporal(g, delta) + edge_index = EventGraph.build_edge_index(g, delta) else: edge_index = event_graph edge_weight = aggregate_node_attributes(edge_index, edge_weight, "src") @@ -191,6 +191,34 @@ def from_temporal_graph( m.layers[k] = gk # type: ignore[assignment] return m + @classmethod + def from_event_graph( + cls, + eg: EventGraph, + max_order: int = 2, + cached: bool = True, + ) -> "MultiOrderModel": + """Create a multi-order model from a pre-built event graph. + + Args: + eg: The second-order temporal `EventGraph` to build the model from. + max_order: The maximum order of the model to compute. + cached: Whether to also keep the aggregated layers below `max_order`. + + Returns: + MultiOrderModel2: A multi-order model equivalent to + `MultiOrderModel.from_temporal_graph(eg.to_temporal_graph(), delta=eg.delta, ...)`. + """ + m = cls() + m.layers = MultiOrderModel.from_temporal_graph( + eg.to_temporal_graph(), + delta=eg.delta, + max_order=max_order, + cached=cached, + event_graph=eg.data.edge_index.as_tensor(), + ).layers + return m + @staticmethod def from_path_data( path_data: PathData, max_order: int = 1, mode: str = "propagation", cached: bool = True diff --git a/tests/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..deb1a466 --- /dev/null +++ b/tests/core/test_event_graph.py @@ -0,0 +1,287 @@ +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.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 lists the delta and all events.""" + assert ( + str(event_graph) + == "EventGraph (delta=2)\na->b@1\nb->c@2\nc->e@3\nb->d@5" + ) + + +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_getitem(event_graph): + """Indexing an EventGraph yields the (u, v, t) tuple for each event.""" + assert event_graph[0] == ("a", "b", 1) + assert event_graph[1] == ("b", "c", 2) + assert event_graph[2] == ("c", "e", 3) + assert event_graph[3] == ("b", "d", 5) + + +def test_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_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() + + +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..2ae1c060 100644 --- a/tests/core/test_graph.py +++ b/tests/core/test_graph.py @@ -77,6 +77,7 @@ def test_from_edge_list(): ("b", "c"), ] g = Graph.from_edge_list(edge_list) + # Since node labels are strings, they are sorted lexicographically. assert g.mapping.to_idx("a") == 0 assert g.mapping.to_idx("b") == 1 assert g.mapping.to_idx("c") == 2 @@ -86,12 +87,15 @@ def test_from_edge_list(): (2, 1), ] g = Graph.from_edge_list(edge_list) + # Since node labels are ints, they are sorted numerically. assert g.mapping.to_idx(1) == 0 assert g.mapping.to_idx(2) == 1 assert g.mapping.to_idx(12) == 2 edge_list = [("1", "12"), ("2", "1"), ("21", "3")] g = Graph.from_edge_list(edge_list) + # Node labels are strings, but since ALL of them are numeric, they are sorted + # numerically, not lexicographically. assert g.mapping.to_idx("1") == 0 assert g.mapping.to_idx("2") == 1 assert g.mapping.to_idx("3") == 2 @@ -280,8 +284,8 @@ def test_add_operator_wo_indices(): g1 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), num_nodes=4) g2 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), num_nodes=4) g = g1 + g2 - assert g.n == g1.n - assert g.m == g1.m + g2.m + assert g.n == g1.n # No relabeling - number of nodes stays the same + assert g.m == g1.m + g2.m # No deduplication - number of edges goes up assert torch.equal(g.data.edge_index, torch.tensor([[0, 0, 1, 1, 1, 1], [1, 1, 2, 3, 2, 3]])) g3 = Graph.from_edge_index(torch.IntTensor([[0, 2, 3], [2, 3, 4]]), num_nodes=5) @@ -296,8 +300,8 @@ def test_add_operator_complete_overlap(): g1 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), mapping=IndexMap(["a", "b", "c", "d"])) g2 = Graph.from_edge_index(torch.IntTensor([[0, 1, 1], [1, 2, 3]]), mapping=IndexMap(["a", "b", "c", "d"])) g = g1 + g2 - assert g.n == g1.n - assert g.m == g1.m + g2.m + assert g.n == g1.n # No relabeling - number of nodes stays the same + assert g.m == g1.m + g2.m # No deduplication - number of edges goes up assert torch.equal(g.data.edge_index, torch.tensor([[0, 0, 1, 1, 1, 1], [1, 1, 2, 3, 2, 3]])) diff --git a/tests/core/test_index_map.py b/tests/core/test_index_map.py index 6cb91e1c..c7130730 100644 --- a/tests/core/test_index_map.py +++ b/tests/core/test_index_map.py @@ -122,7 +122,7 @@ def test_bulk_ids(): node_ids = np.array(["a", "c", "b", "d", "a", "e"]) with pytest.raises(ValueError): mapping = IndexMap(node_ids) - mapping = IndexMap(np.unique(node_ids)) + mapping = IndexMap(np.unique(node_ids)) # sorts assert mapping.to_idx("a") == 0 assert mapping.to_idx("c") == 2