GASM-or 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gasm/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """GASM -- Graph Attributes and Structure Matching.
2
+
3
+ A fast graph matching library implementing the GASM algorithm of Candelier,
4
+ *Graph Matching Based on Similarities in Structure and Attributes*,
5
+ Journal of Graph Algorithms and Applications 29(1), 289-320 (2025),
6
+ with GPU (OpenCL) and CPU back-ends.
7
+
8
+ Typical usage::
9
+
10
+ import gasm
11
+ M = gasm.match(G1, G2)
12
+ pairs = M.matchups
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from importlib.metadata import PackageNotFoundError, version
18
+
19
+ from .api import match
20
+ from .attributes import Attribute
21
+ from .matching import Matching
22
+ from .metrics import accuracy, structural_quality
23
+
24
+ try:
25
+ __version__ = version("GASM-or")
26
+ except PackageNotFoundError: # pragma: no cover - source checkout without install
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "match",
31
+ "Matching",
32
+ "Attribute",
33
+ "accuracy",
34
+ "structural_quality",
35
+ "__version__",
36
+ ]
gasm/api.py ADDED
@@ -0,0 +1,152 @@
1
+ """Public entry point: :func:`match`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import networkx as nx
6
+
7
+ from . import attributes as attr_mod
8
+ from . import lap as lap_registry
9
+ from .cpu import core as cpu_core
10
+ from .matching import Matching
11
+ from .graph import from_networkx
12
+ from .utils import AttributeWarning, PlatformWarning, warn
13
+
14
+
15
+ def match(
16
+ G1,
17
+ G2,
18
+ *,
19
+ platform: str = "GPU",
20
+ attributes=None,
21
+ structure: bool = True,
22
+ complement: bool = True,
23
+ lap: str = "auto",
24
+ noise: float = 1e-10,
25
+ convergence: str = "adaptive",
26
+ tol: float = 1e-6,
27
+ patience: int = 2,
28
+ max_iterations: int | None = None,
29
+ normalize: bool = True,
30
+ match_on: str = "vertices",
31
+ return_scores: bool = False,
32
+ seed: int | None = None,
33
+ ) -> Matching:
34
+ """Match two graphs with the GASM algorithm.
35
+
36
+ Parameters
37
+ ----------
38
+ G1, G2:
39
+ The two graphs to match, as :class:`networkx.Graph` or
40
+ :class:`networkx.DiGraph`. Both must share the same directedness.
41
+ platform:
42
+ ``'GPU'`` (default) runs on OpenCL, falling back to the CPU with a
43
+ warning when no device is available; ``'CPU'`` forces the reference
44
+ implementation.
45
+ attributes:
46
+ ``None`` for a purely structural matching, or a list of
47
+ :class:`gasm.Attribute` (or equivalent dicts) describing the vertex and
48
+ edge attributes to use, each with its uncertainty ``rho``.
49
+ structure:
50
+ When ``False``, ignore the graph structure and match on attributes only.
51
+ complement:
52
+ Allow the complement procedure (eq. 18 / 26) for dense graphs. ``False``
53
+ always uses the original incidence matrices.
54
+ lap:
55
+ Linear assignment solver: ``'auto'``, ``'jv'`` (Jonker-Volgenant) or
56
+ ``'auction'``.
57
+ noise:
58
+ Amplitude ``eta`` of the symmetry-lifting noise (eq. 11). ``0`` disables
59
+ it.
60
+ convergence:
61
+ ``'adaptive'`` (early stopping) or ``'diameter'`` (fixed number of
62
+ iterations, eq. 30).
63
+ tol, patience:
64
+ Parameters of the adaptive convergence criterion.
65
+ max_iterations:
66
+ Hard cap on the number of iterations; defaults to ``min(diam_A, diam_B)``
67
+ (eq. 30).
68
+ normalize:
69
+ Apply the approximate normalization ``fx = 4 dA dB + 1`` (eq. S2).
70
+ match_on:
71
+ ``'vertices'`` (match on the vertex score matrix ``X``) or ``'edges'``
72
+ (match on the edge score matrix ``Y``).
73
+ return_scores:
74
+ For the GPU platform, transfer the score matrix immediately instead of
75
+ lazily. Ignored on the CPU, where scores are always available.
76
+ seed:
77
+ Seed for the noise generator, for reproducible matchings.
78
+
79
+ Returns
80
+ -------
81
+ Matching
82
+ The matching result.
83
+ """
84
+ if match_on not in ("vertices", "edges"):
85
+ raise ValueError("match_on must be 'vertices' or 'edges'.")
86
+ if platform not in ("GPU", "CPU"):
87
+ raise ValueError("platform must be 'GPU' or 'CPU'.")
88
+
89
+ if G1.is_directed() != G2.is_directed():
90
+ raise ValueError(
91
+ "Both graphs must share the same directedness "
92
+ "(both directed or both undirected)."
93
+ )
94
+ if G1.number_of_nodes() == 0 or G2.number_of_nodes() == 0:
95
+ raise ValueError("Both graphs must have at least one vertex.")
96
+
97
+ if not structure and not attributes:
98
+ warn(
99
+ "structure=False with no attributes: the matching has no information "
100
+ "to rely on and will be arbitrary.",
101
+ AttributeWarning,
102
+ )
103
+
104
+ ga = from_networkx(G1)
105
+ gb = from_networkx(G2)
106
+ V, E = attr_mod.build_matrices(attributes, ga, gb)
107
+
108
+ options = dict(
109
+ structure=structure,
110
+ complement=complement,
111
+ noise=noise,
112
+ convergence=convergence,
113
+ tol=tol,
114
+ patience=patience,
115
+ max_iterations=max_iterations,
116
+ normalize=normalize,
117
+ match_on=match_on,
118
+ seed=seed,
119
+ )
120
+
121
+ score_matrix = None
122
+ labels_a = labels_b = None
123
+ used_platform = platform
124
+
125
+ if platform == "GPU":
126
+ try:
127
+ from .gpu import core as gpu_core
128
+
129
+ score_matrix, labels_a, labels_b, _iters, _loader = gpu_core.run(
130
+ ga, gb, V, E, lap=lap, return_scores=return_scores, **options
131
+ )
132
+ except Exception as exc: # pragma: no cover - depends on host OpenCL
133
+ warn(
134
+ "GPU platform unavailable "
135
+ f"({type(exc).__name__}: {exc}); falling back to the CPU.",
136
+ PlatformWarning,
137
+ )
138
+ used_platform = "CPU"
139
+
140
+ if used_platform == "CPU":
141
+ score_matrix, labels_a, labels_b, _iters = cpu_core.run(ga, gb, V, E, **options)
142
+
143
+ solver = lap_registry.get_cpu_solver(lap)
144
+ rows, cols = solver(score_matrix)
145
+ return Matching(
146
+ labels_a,
147
+ labels_b,
148
+ rows,
149
+ cols,
150
+ match_on=match_on,
151
+ score_matrix=score_matrix,
152
+ )
gasm/attributes.py ADDED
@@ -0,0 +1,176 @@
1
+ """Attribute handling for GASM (Section 3.2 of the reference article).
2
+
3
+ Builds the vertex distance matrix ``V`` (eq. 9) and edge distance matrix ``E``
4
+ (eq. 10) from user-specified attributes, each with an uncertainty parameter
5
+ ``rho``. Categorical attributes use eq. (6)-(7); measurable attributes use
6
+ eq. (8).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Literal
13
+
14
+ import numpy as np
15
+
16
+ from .graph import Graph
17
+ from .utils import AttributeWarning, warn
18
+
19
+
20
+ @dataclass
21
+ class Attribute:
22
+ """Specification of a graph attribute used for matching.
23
+
24
+ Parameters
25
+ ----------
26
+ name:
27
+ Key under which the attribute is stored in the networkx node or edge
28
+ data dictionaries.
29
+ on:
30
+ ``'vertex'`` or ``'edge'``.
31
+ kind:
32
+ ``'measurable'`` (a distance can be defined, eq. 8) or ``'categorical'``
33
+ (only equality is meaningful, eq. 6-7).
34
+ rho:
35
+ Uncertainty over the attribute values. A non-negative float, or
36
+ ``'auto'`` to use the standard deviation of all pairwise comparisons as
37
+ a safe upper bound.
38
+ """
39
+
40
+ name: str
41
+ on: Literal["vertex", "edge"]
42
+ kind: Literal["measurable", "categorical"] = "measurable"
43
+ rho: float | str = "auto"
44
+
45
+ def __post_init__(self):
46
+ if self.on not in ("vertex", "edge"):
47
+ raise ValueError("Attribute.on must be 'vertex' or 'edge'.")
48
+ if self.kind not in ("measurable", "categorical"):
49
+ raise ValueError(
50
+ "Attribute.kind must be 'measurable' or 'categorical'."
51
+ )
52
+ if not (self.rho == "auto" or (isinstance(self.rho, (int, float)) and self.rho >= 0)):
53
+ raise ValueError("Attribute.rho must be a non-negative float or 'auto'.")
54
+
55
+
56
+ def _coerce(spec) -> Attribute:
57
+ if isinstance(spec, Attribute):
58
+ return spec
59
+ if isinstance(spec, dict):
60
+ return Attribute(**spec)
61
+ raise TypeError(
62
+ "Each attribute must be a gasm.Attribute or a dict, got "
63
+ f"{type(spec).__name__}."
64
+ )
65
+
66
+
67
+ def _vertex_values(g: Graph, name: str):
68
+ missing = [v for v in g.nodes if name not in g._raw_node_data[v]]
69
+ if missing:
70
+ warn(
71
+ f"Vertex attribute '{name}' is missing on {len(missing)} vertices; "
72
+ "those comparisons are treated as dissimilar.",
73
+ AttributeWarning,
74
+ )
75
+ return [g._raw_node_data[v].get(name, _MISSING) for v in g.nodes]
76
+
77
+
78
+ def _edge_values(g: Graph, name: str):
79
+ missing = [e for e in g.edges if name not in g._raw_edge_data[e]]
80
+ if missing:
81
+ warn(
82
+ f"Edge attribute '{name}' is missing on {len(missing)} edges; "
83
+ "those comparisons are treated as dissimilar.",
84
+ AttributeWarning,
85
+ )
86
+ return [g._raw_edge_data[e].get(name, _MISSING) for e in g.edges]
87
+
88
+
89
+ class _Missing:
90
+ def __repr__(self):
91
+ return "<missing>"
92
+
93
+
94
+ _MISSING = _Missing()
95
+
96
+
97
+ def _measurable_matrix(va, vb, rho):
98
+ a = np.asarray(va, dtype=np.float64)[:, None]
99
+ b = np.asarray(vb, dtype=np.float64)[None, :]
100
+ diff = a - b
101
+ if rho == 0:
102
+ return (diff == 0).astype(np.float64)
103
+ return np.exp(-(diff ** 2) / (2.0 * rho ** 2))
104
+
105
+
106
+ def _categorical_matrix(va, vb, rho):
107
+ a = np.asarray(va, dtype=object)[:, None]
108
+ b = np.asarray(vb, dtype=object)[None, :]
109
+ equal = a == b
110
+ if rho == 0:
111
+ return equal.astype(np.float64)
112
+ out = np.full(equal.shape, np.exp(-1.0 / (2.0 * rho ** 2)), dtype=np.float64)
113
+ out[equal] = 1.0
114
+ return out
115
+
116
+
117
+ def _auto_rho(va, vb, kind):
118
+ a = np.asarray(va, dtype=np.float64) if kind == "measurable" else None
119
+ if kind == "measurable":
120
+ diff = a[:, None] - np.asarray(vb, dtype=np.float64)[None, :]
121
+ sigma = float(np.std(diff))
122
+ else:
123
+ eq = (np.asarray(va, dtype=object)[:, None] == np.asarray(vb, dtype=object)[None, :])
124
+ sigma = float(np.std(eq.astype(np.float64)))
125
+ # Guard against a degenerate zero std (all values identical).
126
+ return sigma if sigma > 0 else 0.0
127
+
128
+
129
+ def _attribute_matrix(spec: Attribute, ga: Graph, gb: Graph):
130
+ if spec.on == "vertex":
131
+ va = _vertex_values(ga, spec.name)
132
+ vb = _vertex_values(gb, spec.name)
133
+ else:
134
+ va = _edge_values(ga, spec.name)
135
+ vb = _edge_values(gb, spec.name)
136
+
137
+ rho = _auto_rho(va, vb, spec.kind) if spec.rho == "auto" else float(spec.rho)
138
+
139
+ if spec.kind == "measurable":
140
+ return _measurable_matrix(va, vb, rho)
141
+ return _categorical_matrix(va, vb, rho)
142
+
143
+
144
+ def build_matrices(specs, ga: Graph, gb: Graph):
145
+ """Build the vertex (``V``) and edge (``E``) distance matrices.
146
+
147
+ Parameters
148
+ ----------
149
+ specs:
150
+ Iterable of :class:`Attribute` or dict specifications, or ``None``.
151
+ ga, gb:
152
+ The two graphs being matched.
153
+
154
+ Returns
155
+ -------
156
+ V:
157
+ Vertex distance matrix of shape ``(nA, nB)`` (eq. 9). All-ones when no
158
+ vertex attribute is specified.
159
+ E:
160
+ Edge distance matrix of shape ``(mA, mB)`` (eq. 10). All-ones when no
161
+ edge attribute is specified.
162
+ """
163
+ V = np.ones((ga.n, gb.n), dtype=np.float64)
164
+ E = np.ones((ga.m, gb.m), dtype=np.float64)
165
+
166
+ if not specs:
167
+ return V, E
168
+
169
+ for spec in specs:
170
+ spec = _coerce(spec)
171
+ A = _attribute_matrix(spec, ga, gb)
172
+ if spec.on == "vertex":
173
+ V *= A
174
+ else:
175
+ E *= A
176
+ return V, E
gasm/convergence.py ADDED
@@ -0,0 +1,100 @@
1
+ """Convergence criteria for the GASM iterative procedure.
2
+
3
+ The reference article (eq. 30) fixes the number of iterations to
4
+ ``k_tilde = min(diam_A, diam_B)``. Supp. Fig. S3 shows the accuracy usually
5
+ plateaus well before that bound, so this module also provides an adaptive
6
+ early-stop criterion that monitors the stabilisation of the row-wise argmax
7
+ assignment -- a cheap surrogate for the final LAP that avoids running the LAP at
8
+ every iteration -- capped by the diameter for safety.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+
16
+ class ConvergenceMonitor:
17
+ """Track convergence of the vertex score matrix across iterations.
18
+
19
+ Parameters
20
+ ----------
21
+ mode:
22
+ ``'adaptive'`` (default) for early stopping, or ``'diameter'`` to
23
+ reproduce the fixed-iteration behaviour of the article.
24
+ diameter_cap:
25
+ Hard upper bound on the number of iterations ``k_tilde`` (eq. 30).
26
+ max_iterations:
27
+ Optional manual override of the hard cap.
28
+ tol:
29
+ Relative Frobenius-norm tolerance for the early-stop criterion.
30
+ patience:
31
+ Number of consecutive iterations with an unchanged argmax assignment
32
+ required to declare convergence.
33
+ floor:
34
+ Minimum number of iterations before early stopping is allowed.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ mode: str = "adaptive",
40
+ diameter_cap: int = 1,
41
+ max_iterations: int | None = None,
42
+ tol: float = 1e-6,
43
+ patience: int = 2,
44
+ floor: int = 2,
45
+ ):
46
+ if mode not in ("adaptive", "diameter"):
47
+ raise ValueError("convergence must be 'adaptive' or 'diameter'.")
48
+ self.mode = mode
49
+ cap = diameter_cap if max_iterations is None else max_iterations
50
+ self.cap = max(int(cap), 1)
51
+ self.tol = tol
52
+ self.patience = patience
53
+ self.floor = max(floor, 1)
54
+ self._prev_argmax: np.ndarray | None = None
55
+ self._prev_X: np.ndarray | None = None
56
+ self._stable = 0
57
+ self.iterations = 0
58
+
59
+ @property
60
+ def max_steps(self) -> int:
61
+ """Maximum number of update iterations that will be performed."""
62
+ return self.cap
63
+
64
+ def update(self, k: int, X: np.ndarray) -> bool:
65
+ """Register iteration ``k`` and return ``True`` if iteration should stop.
66
+
67
+ Parameters
68
+ ----------
69
+ k:
70
+ Current iteration index (``>= 1``).
71
+ X:
72
+ Current vertex score matrix.
73
+ """
74
+ self.iterations = k
75
+ if k >= self.cap:
76
+ return True
77
+ if self.mode == "diameter":
78
+ return False
79
+ if k < self.floor:
80
+ self._prev_argmax = X.argmax(axis=1)
81
+ self._prev_X = X
82
+ return False
83
+
84
+ argmax = X.argmax(axis=1)
85
+ stable = False
86
+ if self._prev_argmax is not None and np.array_equal(argmax, self._prev_argmax):
87
+ self._stable += 1
88
+ else:
89
+ self._stable = 0
90
+
91
+ if self._prev_X is not None:
92
+ denom = np.linalg.norm(X)
93
+ if denom > 0:
94
+ rel = np.linalg.norm(X - self._prev_X) / denom
95
+ if rel < self.tol:
96
+ stable = True
97
+
98
+ self._prev_argmax = argmax
99
+ self._prev_X = X
100
+ return stable or self._stable >= self.patience
gasm/cpu/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """CPU platform for GASM (reference implementation)."""
2
+
3
+ from . import core
4
+
5
+ __all__ = ["core"]
gasm/cpu/core.py ADDED
@@ -0,0 +1,180 @@
1
+ """CPU implementation of the GASM iterative procedure (eq. 15-31).
2
+
3
+ This is the reference implementation, faithful to Candelier (JGAA 29(1), 2025).
4
+ It uses dense NumPy score matrices with sparse incidence multiplications and is
5
+ the platform used when ``platform='CPU'`` or when no OpenCL device is available.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import scipy.sparse as sp
12
+ from scipy.sparse.csgraph import shortest_path
13
+
14
+ from .. import graph as graphmod
15
+ from ..convergence import ConvergenceMonitor
16
+
17
+
18
+ def _effective_diameter(g, use_comp: bool) -> int:
19
+ """Diameter of the graph actually propagated during the iterations.
20
+
21
+ When the complement is used (eq. 18 / 26) the structural information flows
22
+ along the complement graph, whose diameter governs how many message-passing
23
+ steps are needed. Self-loops are irrelevant to distances and are ignored.
24
+ """
25
+ if not use_comp:
26
+ return graphmod.diameter(g)
27
+ n = g.n
28
+ A = (g.adjacency > 0).toarray()
29
+ comp = np.ones((n, n), dtype=bool)
30
+ np.fill_diagonal(comp, False)
31
+ comp &= ~A
32
+ if not g.directed:
33
+ comp |= comp.T
34
+ if not comp.any():
35
+ return 0
36
+ dist = shortest_path(
37
+ sp.csr_matrix(comp.astype(np.float64)),
38
+ directed=g.directed,
39
+ unweighted=True,
40
+ )
41
+ finite = dist[np.isfinite(dist)]
42
+ return int(finite.max()) if finite.size else 0
43
+
44
+
45
+
46
+ def _l(Ssp, D):
47
+ """Sparse-left product ``Ssp @ D`` returning a dense array."""
48
+ return np.asarray(Ssp @ D)
49
+
50
+
51
+ def _r(D, Ssp):
52
+ """Dense-right product ``D @ Ssp`` returning a dense array.
53
+
54
+ Computed as ``(Ssp.T @ D.T).T`` to keep the multiplication as
55
+ sparse-times-dense, which SciPy supports efficiently.
56
+ """
57
+ return np.asarray((Ssp.transpose() @ D.T).T)
58
+
59
+
60
+ def _init_structure(ga, gb, E):
61
+ """Structure term of the initialization (eq. 15 / eq. 27)."""
62
+ if ga.directed:
63
+ return _r(_l(ga.S, E), gb.S.transpose()) + _r(_l(ga.T, E), gb.T.transpose())
64
+ return _r(_l(ga.R, E), gb.R.transpose())
65
+
66
+
67
+ def run(
68
+ ga,
69
+ gb,
70
+ V,
71
+ E,
72
+ *,
73
+ structure: bool = True,
74
+ complement: bool = True,
75
+ noise: float = 1e-10,
76
+ convergence: str = "adaptive",
77
+ tol: float = 1e-6,
78
+ patience: int = 2,
79
+ max_iterations: int | None = None,
80
+ normalize: bool = True,
81
+ match_on: str = "vertices",
82
+ seed: int | None = None,
83
+ ):
84
+ """Run the GASM iterations on the CPU.
85
+
86
+ Returns
87
+ -------
88
+ score_matrix:
89
+ The converged score matrix the LAP should be run on: the vertex score
90
+ matrix ``X`` (``match_on='vertices'``) or the edge score matrix ``Y``
91
+ (``match_on='edges'``).
92
+ labels_a, labels_b:
93
+ Labels of the rows and columns of ``score_matrix`` (node labels for
94
+ vertices, ``(u, v)`` edge tuples for edges).
95
+ iterations:
96
+ Number of iterations actually performed.
97
+ """
98
+ rng = np.random.default_rng(seed)
99
+ nA, nB = ga.n, gb.n
100
+
101
+ # Noise term H, h_uv ~ U[0, eta] (eq. 11).
102
+ if noise and noise > 0:
103
+ H = rng.uniform(0.0, noise, size=(nA, nB))
104
+ else:
105
+ H = np.zeros((nA, nB))
106
+ Vplus = V + H
107
+
108
+ do_iterate = structure and ga.m > 0 and gb.m > 0
109
+
110
+ # Initialization (eq. 15 / 27). Always uses the original incidence matrices.
111
+ if do_iterate:
112
+ X = Vplus * _init_structure(ga, gb, E)
113
+ else:
114
+ X = Vplus.copy()
115
+ Y = None
116
+
117
+ # Normalization factor fx = 4 dA dB + 1 (eq. S2); fy = 1.
118
+ fx = (4.0 * ga.mean_degree * gb.mean_degree + 1.0) if normalize else 1.0
119
+
120
+ # Incidence matrices used in the iterations: complements when dense enough
121
+ # (eq. 18 / 26), unless matching on edges (Y must index real edges).
122
+ use_comp = complement and match_on != "edges" and graphmod.use_complement(ga, gb)
123
+ if ga.directed:
124
+ if use_comp:
125
+ _, SA, TA = ga.complement_incidence()
126
+ _, SB, TB = gb.complement_incidence()
127
+ else:
128
+ SA, TA, SB, TB = ga.S, ga.T, gb.S, gb.T
129
+ else:
130
+ if use_comp:
131
+ RA, _, _ = ga.complement_incidence()
132
+ RB, _, _ = gb.complement_incidence()
133
+ else:
134
+ RA, RB = ga.R, gb.R
135
+
136
+ # Convergence cap k_tilde = min(diam_A, diam_B) (eq. 30). In 'diameter' mode
137
+ # this faithfully uses the original graph diameters; in 'adaptive' mode the
138
+ # cap follows the iterated (complement) graph so early stopping is not
139
+ # artificially capped below the number of steps needed to propagate.
140
+ if convergence == "diameter":
141
+ cap = max(min(graphmod.diameter(ga), graphmod.diameter(gb)), 1)
142
+ else:
143
+ cap = max(min(_effective_diameter(ga, use_comp), _effective_diameter(gb, use_comp)), 1)
144
+ monitor = ConvergenceMonitor(
145
+ mode=convergence,
146
+ diameter_cap=cap,
147
+ max_iterations=max_iterations,
148
+ tol=tol,
149
+ patience=patience,
150
+ floor=2,
151
+ )
152
+
153
+ k = 1
154
+ if do_iterate:
155
+ while not monitor.update(k, X):
156
+ k += 1
157
+ if ga.directed:
158
+ Y = _r(_l(SA.transpose(), X), SB) + _r(_l(TA.transpose(), X), TB)
159
+ X = _r(_l(SA, Y), SB.transpose()) + _r(_l(TA, Y), TB.transpose())
160
+ else:
161
+ Y = _r(_l(RA.transpose(), X), RB)
162
+ X = _r(_l(RA, Y), RB.transpose())
163
+ if normalize:
164
+ X = X / fx
165
+
166
+ # Restore isolated vertices (eq. 31): x_{u,v} = nu_{u,v} / fx^{k-1},
167
+ # with nu = V (the initial vertex distances, without noise).
168
+ iso = ga.isolated[:, None] | gb.isolated[None, :]
169
+ if iso.any():
170
+ X = X.copy()
171
+ X[iso] = V[iso] / (fx ** (k - 1))
172
+
173
+ if match_on == "edges":
174
+ if Y is None:
175
+ raise ValueError(
176
+ "match_on='edges' requires a structural iteration; the graphs "
177
+ "have no edges or structure is disabled."
178
+ )
179
+ return Y, ga.edges, gb.edges, k
180
+ return X, ga.nodes, gb.nodes, k
gasm/gpu/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """GPU (OpenCL) platform for GASM.
2
+
3
+ The :mod:`gasm.gpu.core` module is imported lazily by :func:`gasm.match` so that
4
+ the package works without :mod:`pyopencl` installed.
5
+ """