-
Notifications
You must be signed in to change notification settings - Fork 364
Add subgraph isomorphism (monomorphism) #5598
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 9 commits
db7bc02
82b120c
deaed24
38c9cdf
88f9c2f
91f9725
3fc34cc
0cd1944
f6346a0
d1c346a
eaffd70
3e47755
992fb9e
70586b2
345393a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field | ||
|
|
||
| import cudf | ||
|
|
||
| from cugraph.utilities.utils import import_optional | ||
|
|
||
| # networkx is an optional (test) dependency of cugraph; only the code paths | ||
| # that use it require it to be installed. | ||
| nx = import_optional("networkx") | ||
|
|
||
|
|
||
| def count_unique(edge_list): | ||
| """Count the number of unique vertices in an edge list.""" | ||
| return len(set().union(*edge_list)) | ||
|
|
||
|
|
||
| @dataclass | ||
| class MotifData: | ||
| """A small building-block graph ("motif") used to decompose the pattern | ||
| graph during motif-based subgraph isomorphism. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| name : str | ||
| Identifier for the motif, used in the solver's decomposition report. | ||
| motif : list of (int, int) | ||
| Edge list over vertices ``0..k-1`` defining the motif graph. | ||
| """ | ||
|
|
||
| name: str | ||
| motif: list | ||
| graph: object = field(init=False, default=None, repr=False) | ||
| size: int = field(init=False, default=0) | ||
| isomorphisms: object = field(init=False, default=None, repr=False) | ||
|
|
||
| def __post_init__(self): | ||
| self.size = count_unique(self.motif) if self.motif else 1 | ||
| self.graph = nx.Graph() | ||
| self.graph.add_nodes_from(range(self.size)) | ||
| self.graph.add_edges_from(self.motif) | ||
|
|
||
| def copy(self): | ||
| """Copy this MotifData; the isomorphisms DataFrame is deep-copied.""" | ||
| new_instance = self.__class__(motif=self.motif, name=self.name) | ||
| new_instance.graph = self.graph | ||
| new_instance.size = self.size | ||
| if self.isomorphisms is not None: | ||
| new_instance.isomorphisms = self.isomorphisms.copy() | ||
| return new_instance | ||
|
|
||
|
|
||
| def data_to_dataframe(data, num_vertices): | ||
| # Down-cast by vertex-id range; every motif table uses the same rule so | ||
| # cudf merge keys stay dtype-consistent across tables. | ||
| if num_vertices <= 256: | ||
| dtype = "uint8" | ||
| elif num_vertices <= 65536: | ||
| dtype = "uint16" | ||
| else: | ||
| dtype = "uint32" | ||
| return cudf.DataFrame(data, dtype=dtype) | ||
|
|
||
|
|
||
| def make_m2_motif(edge_df, num_vertices): | ||
| """Build the base single-edge ("M2") motif whose isomorphisms table is | ||
| the bidirectional, de-duplicated edge list of the target graph. | ||
|
|
||
| The concat + drop_duplicates normalizes the input regardless of whether | ||
| ``edge_df`` is already symmetrized or holds each edge in one direction. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| edge_df : cudf.DataFrame | ||
| Two columns (source, destination) of target edges in the compact | ||
| ``0..num_vertices-1`` vertex space, with self-loops already removed. | ||
| num_vertices : int | ||
| Number of vertices in the target graph. | ||
| """ | ||
| m2_motif = MotifData(name="M2", motif=[(0, 1)]) | ||
| df = data_to_dataframe(edge_df.to_cupy(), num_vertices) | ||
| df_rev = df[[1, 0]] | ||
| df_rev.columns = [0, 1] | ||
| m2_motif.isomorphisms = cudf.concat( | ||
| [df, df_rev], ignore_index=True | ||
| ).drop_duplicates(ignore_index=True, keep="first") | ||
| return m2_motif | ||
|
|
||
|
|
||
| def default_motif_library(): | ||
| """Return a small library of 3-vertex motifs usable as building blocks. | ||
|
|
||
| Passing these to ``subgraph_isomorphism`` makes the solver precompute | ||
| their embeddings in the target graph (a full solve per motif), which can | ||
| speed up large patterns at the cost of upfront work and memory. | ||
| """ | ||
| return [ | ||
| MotifData(name="M3-path", motif=[(0, 1), (1, 2)]), | ||
| MotifData(name="M3-triangle", motif=[(0, 1), (1, 2), (0, 2)]), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field | ||
| from typing import List, Tuple | ||
|
|
||
| from cugraph.experimental.isomorphism.motif import MotifData | ||
| from cugraph.utilities.utils import import_optional | ||
|
|
||
| nx = import_optional("networkx") | ||
|
|
||
|
|
||
| @dataclass | ||
| class SlicingResults: | ||
| """ | ||
| Stores the results from pattern graph slicing into motifs. | ||
|
|
||
| Attributes: | ||
| chosen_motifs: A list of MotifData objects selected during slicing. | ||
| slices: Each sublist contains pattern vertex ids for a slice. | ||
| boundaries: A list where each element represents the boundary | ||
| conditions for a slice as tuples of | ||
| ([slice_index, vertex_index], vertex_index). | ||
| intersections: Each set contains pattern vertices at the intersection | ||
| of a slice and its boundary. | ||
| """ | ||
|
|
||
| chosen_motifs: List[MotifData] = field(default_factory=list) | ||
| slices: List[List[int]] = field(default_factory=list) | ||
| boundaries: List[List[Tuple[List[int], int]]] = field(default_factory=list) | ||
| intersections: List[set] = field(default_factory=list) | ||
|
|
||
| def extend(self, other: "SlicingResults") -> None: | ||
| """Appends the content of another SlicingResults instance to the | ||
| current one.""" | ||
| self.chosen_motifs.extend(other.chosen_motifs) | ||
| self.slices.extend(other.slices) | ||
| self.boundaries.extend(other.boundaries) | ||
| self.intersections.extend(other.intersections) | ||
|
|
||
|
|
||
| def slice_pattern_graph_using_motifs( | ||
| pattern_graph: nx.Graph, | ||
| motif_metadata: List[MotifData], | ||
| ) -> SlicingResults: | ||
| """Slice the pattern graph into subgraphs based on predefined motifs.""" | ||
| graph = nx.Graph() | ||
| graph.add_nodes_from(pattern_graph.nodes) | ||
| graph.add_edges_from(pattern_graph.edges) | ||
| results = SlicingResults() | ||
| boundary_nodes: set = set() | ||
|
|
||
| # Precompute adjacency list for fast lookups | ||
| adjacency_map = build_adjacency_map(graph) | ||
|
|
||
| def _extract_next_slice( | ||
| graph: nx.Graph, | ||
| motif_metadata: List[MotifData], | ||
| adjacency_map: dict, | ||
| boundary_nodes: set, | ||
| existing_slices: List[List[int]], | ||
| ) -> SlicingResults | None: | ||
| """Attempt to extract the next valid motif slice.""" | ||
| remaining_nodes = set(graph.nodes) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again; I think it would be better to just use raw arrays - I don't think the conversion should be too hard. |
||
| existing_slices_node = set( | ||
| node for slice_ in existing_slices for node in slice_ | ||
| ) | ||
|
|
||
| # Start from the largest and most complex motifs | ||
| for motif_data in motif_metadata[::-1]: | ||
| if len(remaining_nodes) < motif_data.size: | ||
| continue | ||
|
|
||
| # Node-induced subgraph matches of the motif in the residual | ||
| # pattern graph (same semantics as rustworkx vf2_mapping with | ||
| # subgraph=True). | ||
| matcher = nx.algorithms.isomorphism.GraphMatcher(graph, motif_data.graph) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not performant code - I think we'd want to implement graph matching in cugraph/cudf, or I suppose we could allow the user to specify a graph matching function - networkx could be one option. |
||
|
|
||
| for mapping in matcher.subgraph_isomorphisms_iter(): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. again, this is a lot of CPU code in a GPU library... |
||
| # mapping is {pattern_node: motif_node}; invert it so that | ||
| # slice_nodes[i] is the pattern vertex matched to motif | ||
| # vertex i. Dict iteration order carries no such guarantee. | ||
| inv = {motif_node: pat_node for pat_node, motif_node in mapping.items()} | ||
| slice_nodes = [inv[i] for i in range(motif_data.size)] | ||
| slice_set = set(slice_nodes) | ||
|
|
||
| # The found slice should cover at least one existing | ||
| # boundary node | ||
| if boundary_nodes and not (slice_set & boundary_nodes): | ||
| continue | ||
|
|
||
| combined_nodes = slice_set | existing_slices_node | ||
| slice_edges = [ | ||
| (slice_nodes[u], slice_nodes[v]) for (u, v) in motif_data.motif | ||
| ] | ||
| updated_boundary = find_boundary_nodes( | ||
| combined_nodes, slice_edges, adjacency_map | ||
| ) | ||
|
|
||
| nodes_to_remove = slice_set - updated_boundary | ||
| # The found slice should contribute to the removal of pattern | ||
| # graph nodes. If no nodes are removed, it indicates a | ||
| # potential infinite loop. | ||
| if not nodes_to_remove: | ||
| # If the slice fully covers existing slices, skip it | ||
| if any(not (slice_set - set(slice_)) for slice_ in existing_slices): | ||
| continue | ||
| graph.remove_nodes_from(list(nodes_to_remove)) | ||
|
|
||
| intersection = slice_set & boundary_nodes | ||
| boundary_nodes.clear() | ||
| boundary_nodes.update(updated_boundary) | ||
| boundary_condition = [ | ||
| ( | ||
| find_element_index(existing_slices, node), | ||
| slice_nodes.index(node), | ||
| ) | ||
| for node in intersection | ||
| ] | ||
| return SlicingResults( | ||
| [motif_data.copy()], | ||
| [slice_nodes], | ||
| [boundary_condition], | ||
| [intersection], | ||
| ) | ||
| return None | ||
|
|
||
| while graph.number_of_nodes(): | ||
| _step_res = _extract_next_slice( | ||
| graph, | ||
| motif_metadata, | ||
| adjacency_map, | ||
| boundary_nodes, | ||
| results.slices, | ||
| ) | ||
|
|
||
| if not _step_res: | ||
| raise ValueError("Cannot slice the Pattern Graph.") | ||
|
|
||
| results.extend(_step_res) | ||
|
|
||
| return results | ||
|
|
||
|
|
||
| def build_adjacency_map(graph: nx.Graph) -> dict: | ||
| """Build adjacency map from graph edges.""" | ||
| adjacency_map = {u: set() for u in graph.nodes} | ||
| for u, v in graph.edges: | ||
| adjacency_map[u].add(v) | ||
| adjacency_map[v].add(u) | ||
| return adjacency_map | ||
|
|
||
|
|
||
| def find_element_index(slices: List[List[int]], target: int) -> List[int]: | ||
| """Find the index of an element in a ragged 2D list. | ||
| Return [slice_index, vertex_index] if found, else [].""" | ||
| for i, group in enumerate(slices): | ||
| if target in group: | ||
| return [i, group.index(target)] | ||
| return [] | ||
|
|
||
|
|
||
| def find_boundary_nodes( | ||
| combined_nodes: set, | ||
| current_slice_edges: list, | ||
| adjacency_map: dict, | ||
| ) -> set: | ||
| """ | ||
| Find updated boundary nodes after extracting a motif slice. | ||
| A node is a boundary if it connects to any untouched node outside the | ||
| current slice. | ||
| """ | ||
| # Remove the adjacent node if the edge is covered by the current slice | ||
| for node1, node2 in current_slice_edges: | ||
| if node1 in adjacency_map and node2 in adjacency_map[node1]: | ||
| adjacency_map[node1].remove(node2) | ||
| if node2 in adjacency_map and node1 in adjacency_map[node2]: | ||
| adjacency_map[node2].remove(node1) | ||
|
|
||
| # Identify boundary nodes | ||
| return {node for node in combined_nodes if adjacency_map.get(node, set())} | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd prefer we use cudf/cupy instead of networkx both for scalability and dependency reasons.