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 +36 -0
- gasm/api.py +152 -0
- gasm/attributes.py +176 -0
- gasm/convergence.py +100 -0
- gasm/cpu/__init__.py +5 -0
- gasm/cpu/core.py +180 -0
- gasm/gpu/__init__.py +5 -0
- gasm/gpu/core.py +278 -0
- gasm/gpu/kernels/gasm.cl +79 -0
- gasm/graph.py +259 -0
- gasm/lap/__init__.py +53 -0
- gasm/lap/auction.py +90 -0
- gasm/lap/jv.py +29 -0
- gasm/matching.py +143 -0
- gasm/metrics.py +87 -0
- gasm/utils.py +26 -0
- gasm_or-0.1.0.dist-info/METADATA +128 -0
- gasm_or-0.1.0.dist-info/RECORD +21 -0
- gasm_or-0.1.0.dist-info/WHEEL +5 -0
- gasm_or-0.1.0.dist-info/licenses/LICENSE +674 -0
- gasm_or-0.1.0.dist-info/top_level.txt +1 -0
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
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
|