-
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 all commits
db7bc02
82b120c
deaed24
38c9cdf
88f9c2f
91f9725
3fc34cc
0cd1944
f6346a0
d1c346a
eaffd70
3e47755
992fb9e
70586b2
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,143 @@ | ||
| # 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 dependency of cugraph (declared for tests only); | ||
| # import_optional defers the failure to first use, and only the private | ||
| # pattern-decomposition code paths use it — never MotifData instances. | ||
| nx = import_optional("networkx") | ||
|
|
||
|
|
||
| @dataclass | ||
| class EXPERIMENTAL__MotifData: | ||
| """A small building-block graph ("motif") used to decompose the pattern | ||
| graph during motif-based subgraph monomorphism. Holds only plain Python | ||
| data (an edge list) plus, after precomputation, a cuDF table of the | ||
| motif's embeddings in the target graph. | ||
|
|
||
| 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 | ||
| size: int = field(init=False, default=0) | ||
| embeddings: object = field(init=False, default=None, repr=False) | ||
|
|
||
| def __post_init__(self): | ||
| # Materialize first so a generator input can't be silently | ||
| # exhausted between validation and use. | ||
| self.motif = list(self.motif) | ||
| vertices = set().union(*self.motif) if self.motif else set() | ||
| if not self.motif or vertices != set(range(len(vertices))): | ||
| raise ValueError( | ||
| "motif must be a non-empty edge list over contiguous vertices 0..k-1" | ||
| ) | ||
| self.size = len(vertices) | ||
|
|
||
| def _to_nx(self): | ||
| """Build a networkx.Graph of this motif (private; used by the CPU | ||
| pattern-decomposition step only).""" | ||
| graph = nx.Graph() | ||
| graph.add_nodes_from(range(self.size)) | ||
| graph.add_edges_from(self.motif) | ||
| return graph | ||
|
|
||
| def copy(self): | ||
| """Copy this MotifData. The embeddings table is copied shallowly | ||
| (shared data buffers, independent column metadata): the solver only | ||
| ever renames the copy's columns, so slices of the same motif can | ||
| share one embeddings table instead of duplicating it on the GPU.""" | ||
| # Explicit class (not self.__class__) so copies of instances created | ||
| # through the experimental warning wrapper don't re-warn. | ||
| new_instance = EXPERIMENTAL__MotifData(motif=self.motif, name=self.name) | ||
| new_instance.size = self.size | ||
| if self.embeddings is not None: | ||
| new_instance.embeddings = self.embeddings.copy(deep=False) | ||
| return new_instance | ||
|
|
||
|
|
||
| # Internal alias: intra-package code (and type hints) use the plain name; | ||
| # the public export in cugraph.experimental applies the warning wrapper to | ||
| # the EXPERIMENTAL__-prefixed name above. Do not import this alias from | ||
| # outside the package — it bypasses the experimental warning. Note that | ||
| # instances made internally (default_motif_library, copy()) are of the raw | ||
| # class, so isinstance/== checks against the wrapped public class will not | ||
| # match them; experimental users should not rely on either. | ||
| MotifData = EXPERIMENTAL__MotifData | ||
|
|
||
|
|
||
| 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" | ||
| elif num_vertices <= 2**32: | ||
| dtype = "uint32" | ||
| else: | ||
| dtype = "uint64" | ||
| return cudf.DataFrame(data, dtype=dtype) | ||
|
|
||
|
|
||
| def _make_m2_motif(edge_df, num_vertices): | ||
| """Build the base single-edge ("M2") motif whose embeddings 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. | ||
| """ | ||
| if len(edge_df) >= 2**30: | ||
| # The concat below materializes 2 * len(edge_df) rows BEFORE the | ||
| # dedup, so at 2**30 input rows it exceeds cuDF's 2**31 - 1 row | ||
| # limit regardless of how many duplicates the dedup would remove; | ||
| # fail clearly rather than inside the concat. Support for larger | ||
| # targets would need a partitioned M2 table. | ||
| raise ValueError( | ||
| f"Target graph has {len(edge_df)} edges; the bidirectional " | ||
| "M2 motif table would exceed cuDF's 2**31 - 1 row limit." | ||
| ) | ||
| 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.embeddings = cudf.concat([df, df_rev], ignore_index=True).drop_duplicates( | ||
| ignore_index=True, keep="first" | ||
| ) | ||
| return m2_motif | ||
|
|
||
|
|
||
| def EXPERIMENTAL__default_motif_library(): | ||
| """Return a small library of 3-vertex motifs usable as building blocks. | ||
|
|
||
| Passing these to ``subgraph_monomorphism`` 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)]), | ||
| ] | ||
|
|
||
|
|
||
| default_motif_library = EXPERIMENTAL__default_motif_library |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| # 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._to_nx()) | ||
|
|
||
| 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 | ||
| ] | ||
| # NOTE: find_boundary_nodes mutates adjacency_map (removes | ||
| # the slice's edges) before this candidate can still be | ||
| # rejected below. That is safe only because a rejected | ||
| # candidate here is an induced match over already-covered | ||
| # slices, whose edges were removed when first covered — | ||
| # i.e. the mutation is idempotent for rejected candidates. | ||
| 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.