mixingmatrix 0.2.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.
- mixingmatrix/__init__.py +93 -0
- mixingmatrix/_graph.py +164 -0
- mixingmatrix/api.py +269 -0
- mixingmatrix/baselines.py +152 -0
- mixingmatrix/certificates.py +572 -0
- mixingmatrix/cli.py +135 -0
- mixingmatrix/evaluate.py +271 -0
- mixingmatrix/incremental.py +325 -0
- mixingmatrix/operators.py +243 -0
- mixingmatrix/problem.py +220 -0
- mixingmatrix/projection.py +279 -0
- mixingmatrix/simulate.py +82 -0
- mixingmatrix/solution.py +271 -0
- mixingmatrix/solvers/__init__.py +49 -0
- mixingmatrix/solvers/admm.py +479 -0
- mixingmatrix/solvers/base.py +221 -0
- mixingmatrix/solvers/conic.py +100 -0
- mixingmatrix/solvers/firstorder.py +271 -0
- mixingmatrix/spectral.py +377 -0
- mixingmatrix-0.2.0.dist-info/METADATA +244 -0
- mixingmatrix-0.2.0.dist-info/RECORD +25 -0
- mixingmatrix-0.2.0.dist-info/WHEEL +5 -0
- mixingmatrix-0.2.0.dist-info/entry_points.txt +2 -0
- mixingmatrix-0.2.0.dist-info/licenses/LICENSE +21 -0
- mixingmatrix-0.2.0.dist-info/top_level.txt +1 -0
mixingmatrix/__init__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""mixingmatrix -- optimal mixing matrices for graphs.
|
|
2
|
+
|
|
3
|
+
Compute the edge weights that make averaging, gossip or a random walk converge
|
|
4
|
+
as fast as possible on a given graph. This is the fastest-mixing Markov chain
|
|
5
|
+
problem (Boyd, Diaconis & Xiao, SIAM Review 2004) and its free-weight variant
|
|
6
|
+
FDLA (Xiao & Boyd, 2004).
|
|
7
|
+
|
|
8
|
+
import mixingmatrix
|
|
9
|
+
|
|
10
|
+
sol = mixingmatrix.solve(G) # G is a connected networkx.Graph
|
|
11
|
+
sol.weights # scipy.sparse.csr_matrix
|
|
12
|
+
sol.to_dict() # {(u, v): weight} in your own node labels
|
|
13
|
+
sol.slem # the mixing rate; lower is faster
|
|
14
|
+
sol.certified_gap # how far from optimal this might be
|
|
15
|
+
print(sol.summary())
|
|
16
|
+
|
|
17
|
+
Check ``sol.status`` and ``sol.certified_gap`` before relying on a result: a
|
|
18
|
+
status other than ``"optimal"`` means the solver stopped on a limit, and the
|
|
19
|
+
gap says how much that cost.
|
|
20
|
+
|
|
21
|
+
Other entry points:
|
|
22
|
+
|
|
23
|
+
mixingmatrix.compare(G) # is optimising worth it on this graph?
|
|
24
|
+
mixingmatrix.update(sol, changes=) # a few edges changed; reuse what you can
|
|
25
|
+
mixingmatrix.metropolis_hastings(G) # and max_degree, best_constant, ...
|
|
26
|
+
mixingmatrix.slem(B) # measure any mixing matrix
|
|
27
|
+
mixingmatrix.gossip(B, x0, rounds=) # run the chain
|
|
28
|
+
|
|
29
|
+
Set ``time_limit`` on graphs above about a thousand nodes: every iterate is a
|
|
30
|
+
valid mixing matrix, so a budget returns the best one found rather than
|
|
31
|
+
nothing.
|
|
32
|
+
|
|
33
|
+
See the docs/ directory: quickstart, cookbook, api, incremental, limits.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from ._graph import GraphIndex
|
|
39
|
+
from .api import available_methods, compare, solve
|
|
40
|
+
from .baselines import best_constant, lazy_random_walk, max_degree, metropolis_hastings
|
|
41
|
+
from .certificates import DualCertificate, EdgeCertificate, build_certificate
|
|
42
|
+
from .evaluate import (
|
|
43
|
+
FeasibilityReport,
|
|
44
|
+
consensus_rounds,
|
|
45
|
+
is_valid,
|
|
46
|
+
mixing_time,
|
|
47
|
+
slem,
|
|
48
|
+
spectral_gap,
|
|
49
|
+
stationary_distribution,
|
|
50
|
+
)
|
|
51
|
+
from .incremental import apply_changes, can_improve, can_remove, update
|
|
52
|
+
from .problem import MixingProblem
|
|
53
|
+
from .simulate import disagreement, gossip, rounds_to_consensus
|
|
54
|
+
from .solution import Solution
|
|
55
|
+
|
|
56
|
+
__version__ = "0.2.0"
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
# core
|
|
60
|
+
"solve",
|
|
61
|
+
"update",
|
|
62
|
+
"compare",
|
|
63
|
+
"Solution",
|
|
64
|
+
# certificates
|
|
65
|
+
"can_remove",
|
|
66
|
+
"can_improve",
|
|
67
|
+
"build_certificate",
|
|
68
|
+
"DualCertificate",
|
|
69
|
+
"EdgeCertificate",
|
|
70
|
+
# baselines
|
|
71
|
+
"metropolis_hastings",
|
|
72
|
+
"max_degree",
|
|
73
|
+
"best_constant",
|
|
74
|
+
"lazy_random_walk",
|
|
75
|
+
# evaluation
|
|
76
|
+
"slem",
|
|
77
|
+
"spectral_gap",
|
|
78
|
+
"consensus_rounds",
|
|
79
|
+
"mixing_time",
|
|
80
|
+
"stationary_distribution",
|
|
81
|
+
"is_valid",
|
|
82
|
+
"FeasibilityReport",
|
|
83
|
+
# simulation
|
|
84
|
+
"gossip",
|
|
85
|
+
"rounds_to_consensus",
|
|
86
|
+
"disagreement",
|
|
87
|
+
# plumbing, exposed for extension
|
|
88
|
+
"GraphIndex",
|
|
89
|
+
"MixingProblem",
|
|
90
|
+
"apply_changes",
|
|
91
|
+
"available_methods",
|
|
92
|
+
"__version__",
|
|
93
|
+
]
|
mixingmatrix/_graph.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Node-label bookkeeping.
|
|
2
|
+
|
|
3
|
+
The solvers work in integer indices; the user works in whatever labels their
|
|
4
|
+
graph has. :class:`GraphIndex` is the only place that knows both, so a label
|
|
5
|
+
never leaks into a numerical routine and an index never leaks back out to the
|
|
6
|
+
user. Everything the rest of the package needs about the *shape* of the
|
|
7
|
+
problem -- how many nodes, which pairs are edges, in what order -- comes from
|
|
8
|
+
here, which also makes "is this the same graph as last time?" a cheap identity
|
|
9
|
+
question during an incremental update.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Hashable, Iterable, Sequence
|
|
15
|
+
|
|
16
|
+
import networkx as nx
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
__all__ = ["GraphIndex", "edge_key"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def edge_key(u: Hashable, v: Hashable) -> tuple:
|
|
23
|
+
"""Canonical, order-independent key for an undirected edge.
|
|
24
|
+
|
|
25
|
+
Sorting labels directly fails on mixed types (``3 < "a"`` raises), so the
|
|
26
|
+
fallback key is the string form -- deterministic, and only ever used to
|
|
27
|
+
pick which of ``(u, v)`` and ``(v, u)`` is written down.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
return (u, v) if u <= v else (v, u) # type: ignore[operator]
|
|
31
|
+
except TypeError:
|
|
32
|
+
return (u, v) if str(u) <= str(v) else (v, u)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GraphIndex:
|
|
36
|
+
"""Immutable view of a graph as ``n`` indexed nodes and ``m`` indexed edges.
|
|
37
|
+
|
|
38
|
+
Self-loops are dropped: the diagonal of a mixing matrix is determined by
|
|
39
|
+
the off-diagonal weights (it is whatever makes the row sum to one), so a
|
|
40
|
+
self-loop in the input carries no information and is not an error.
|
|
41
|
+
Edge attributes are ignored as well -- an input graph supplies the
|
|
42
|
+
*support*, and the weights are what this package computes.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("nodes", "index", "edges", "edge_labels", "n", "m", "_edge_pos")
|
|
46
|
+
|
|
47
|
+
def __init__(self, graph: nx.Graph):
|
|
48
|
+
if graph.is_directed():
|
|
49
|
+
raise TypeError(
|
|
50
|
+
"mixingmatrix works on undirected graphs; pass G.to_undirected() if "
|
|
51
|
+
"the direction is not meaningful"
|
|
52
|
+
)
|
|
53
|
+
if graph.number_of_nodes() == 0:
|
|
54
|
+
raise ValueError("graph has no nodes")
|
|
55
|
+
|
|
56
|
+
self.nodes: tuple[Hashable, ...] = tuple(graph.nodes())
|
|
57
|
+
self.index: dict[Hashable, int] = {u: i for i, u in enumerate(self.nodes)}
|
|
58
|
+
self.n = len(self.nodes)
|
|
59
|
+
|
|
60
|
+
pairs = []
|
|
61
|
+
for u, v in graph.edges():
|
|
62
|
+
if u == v:
|
|
63
|
+
continue # self-loop: no information
|
|
64
|
+
i, j = self.index[u], self.index[v]
|
|
65
|
+
pairs.append((i, j) if i < j else (j, i))
|
|
66
|
+
pairs = sorted(set(pairs))
|
|
67
|
+
self.edges: tuple[tuple[int, int], ...] = tuple(pairs)
|
|
68
|
+
self.edge_labels: tuple[tuple, ...] = tuple(
|
|
69
|
+
(self.nodes[i], self.nodes[j]) for i, j in self.edges
|
|
70
|
+
)
|
|
71
|
+
self.m = len(self.edges)
|
|
72
|
+
self._edge_pos = {e: k for k, e in enumerate(self.edges)}
|
|
73
|
+
|
|
74
|
+
if self.n > 1 and not nx.is_connected(graph):
|
|
75
|
+
comps = nx.number_connected_components(graph)
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"graph is not connected ({comps} components): a chain that "
|
|
78
|
+
"cannot reach every node never mixes, so SLEM = 1 for any "
|
|
79
|
+
"weights. Solve each component separately, e.g. "
|
|
80
|
+
"[mixingmatrix.solve(graph.subgraph(c).copy()) for c in "
|
|
81
|
+
"nx.connected_components(graph)]"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# -- lookups ------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def position(self, u: Hashable, v: Hashable) -> int | None:
|
|
87
|
+
"""Column of edge ``(u, v)`` in weight space, or ``None`` if absent."""
|
|
88
|
+
try:
|
|
89
|
+
i, j = self.index[u], self.index[v]
|
|
90
|
+
except KeyError:
|
|
91
|
+
return None
|
|
92
|
+
return self._edge_pos.get((i, j) if i < j else (j, i))
|
|
93
|
+
|
|
94
|
+
def has_edge(self, u: Hashable, v: Hashable) -> bool:
|
|
95
|
+
return self.position(u, v) is not None
|
|
96
|
+
|
|
97
|
+
def require_node(self, u: Hashable) -> int:
|
|
98
|
+
if u not in self.index:
|
|
99
|
+
raise KeyError(f"node {u!r} is not in the graph")
|
|
100
|
+
return self.index[u]
|
|
101
|
+
|
|
102
|
+
def same_nodes(self, other: GraphIndex) -> bool:
|
|
103
|
+
"""Whether two indices agree on node identity *and* ordering.
|
|
104
|
+
|
|
105
|
+
Ordering matters because warm-start state (``Z``, ``U``) is stored as
|
|
106
|
+
``n x n`` matrices in index space; reusing it under a permuted labelling
|
|
107
|
+
would silently transport the dual variable to the wrong nodes.
|
|
108
|
+
"""
|
|
109
|
+
return self.nodes == other.nodes
|
|
110
|
+
|
|
111
|
+
def edge_set(self) -> frozenset[tuple]:
|
|
112
|
+
"""Edges as canonical label pairs."""
|
|
113
|
+
return frozenset(edge_key(u, v) for u, v in self.edge_labels)
|
|
114
|
+
|
|
115
|
+
# -- construction -------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def relabel_array(self, values: Sequence[float]) -> dict[tuple, float]:
|
|
118
|
+
"""Edge-indexed values as a label-keyed dict."""
|
|
119
|
+
return {edge_key(u, v): float(x) for (u, v), x in zip(self.edge_labels, values, strict=True)}
|
|
120
|
+
|
|
121
|
+
def to_graph(self) -> nx.Graph:
|
|
122
|
+
"""A fresh ``nx.Graph`` with the same nodes, ordering and edges."""
|
|
123
|
+
g = nx.Graph()
|
|
124
|
+
g.add_nodes_from(self.nodes)
|
|
125
|
+
g.add_edges_from(self.edge_labels)
|
|
126
|
+
return g
|
|
127
|
+
|
|
128
|
+
def degrees(self) -> np.ndarray:
|
|
129
|
+
d = np.zeros(self.n)
|
|
130
|
+
for i, j in self.edges:
|
|
131
|
+
d[i] += 1
|
|
132
|
+
d[j] += 1
|
|
133
|
+
return d
|
|
134
|
+
|
|
135
|
+
def stationary_vector(self, pi: dict | Iterable[float] | None) -> np.ndarray | None:
|
|
136
|
+
"""Normalise a user-supplied stationary distribution into index order.
|
|
137
|
+
|
|
138
|
+
Accepts a label-keyed mapping or an array already in ``self.nodes``
|
|
139
|
+
order. Returns ``None`` for the uniform case so callers can take the
|
|
140
|
+
cheaper path.
|
|
141
|
+
"""
|
|
142
|
+
if pi is None:
|
|
143
|
+
return None
|
|
144
|
+
if isinstance(pi, dict):
|
|
145
|
+
missing = [u for u in self.nodes if u not in pi]
|
|
146
|
+
if missing:
|
|
147
|
+
raise KeyError(f"stationary distribution is missing nodes: {missing[:5]}")
|
|
148
|
+
vec = np.array([float(pi[u]) for u in self.nodes])
|
|
149
|
+
else:
|
|
150
|
+
vec = np.asarray(list(pi), dtype=float)
|
|
151
|
+
if vec.shape != (self.n,):
|
|
152
|
+
raise ValueError(f"stationary distribution has length {vec.size}, expected {self.n}")
|
|
153
|
+
if np.any(vec <= 0):
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"stationary distribution must be strictly positive; a zero "
|
|
156
|
+
"entry makes the node unreachable and the chain reducible"
|
|
157
|
+
)
|
|
158
|
+
return vec / vec.sum()
|
|
159
|
+
|
|
160
|
+
def __len__(self) -> int:
|
|
161
|
+
return self.n
|
|
162
|
+
|
|
163
|
+
def __repr__(self) -> str: # pragma: no cover - display only
|
|
164
|
+
return f"<GraphIndex n={self.n} m={self.m}>"
|
mixingmatrix/api.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""The entry points: :func:`solve` and :func:`compare`.
|
|
2
|
+
|
|
3
|
+
``solve(G)`` is meant to be the whole API for most users. Every argument has a
|
|
4
|
+
default that works, ``method="auto"`` picks the solver, and the returned
|
|
5
|
+
:class:`~mixingmatrix.solution.Solution` recomputes its own quality rather than
|
|
6
|
+
reporting the solver's.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import warnings
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
|
|
15
|
+
import networkx as nx
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from ._graph import GraphIndex
|
|
19
|
+
from .baselines import BASELINES
|
|
20
|
+
from .evaluate import consensus_rounds, slem, spectral_gap
|
|
21
|
+
from .problem import MixingProblem
|
|
22
|
+
from .solution import Solution
|
|
23
|
+
from .solvers import SolveOptions, available_methods, choose_method, get_solver
|
|
24
|
+
|
|
25
|
+
__all__ = ["solve", "compare", "available_methods"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def solve(
|
|
29
|
+
graph: nx.Graph,
|
|
30
|
+
method: str = "auto",
|
|
31
|
+
tol: float = 1e-6,
|
|
32
|
+
max_iter: int | None = None,
|
|
33
|
+
time_limit: float | None = None,
|
|
34
|
+
warm_start: Solution | None = None,
|
|
35
|
+
verbose: bool = False,
|
|
36
|
+
callback: Callable[[int, float, float], bool | None] | None = None,
|
|
37
|
+
*,
|
|
38
|
+
allow_negative: bool = False,
|
|
39
|
+
stationary=None,
|
|
40
|
+
symmetric: bool = True,
|
|
41
|
+
**options,
|
|
42
|
+
) -> Solution:
|
|
43
|
+
"""Compute the fastest-mixing weights on ``graph``.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
graph : networkx.Graph
|
|
48
|
+
Undirected and connected. Edge attributes are ignored -- the graph
|
|
49
|
+
supplies the *support*, and the weights are what this computes. Self
|
|
50
|
+
loops are ignored: the diagonal is implied by the row sums.
|
|
51
|
+
method : {"auto", "admm", "smoothing", "subgradient", "cvxpy"}
|
|
52
|
+
``"auto"`` uses an exact conic solve for small graphs when CVXPY is
|
|
53
|
+
installed and ADMM otherwise. You should not need to change this.
|
|
54
|
+
``"admm"`` itself switches to a matrix-free implementation above
|
|
55
|
+
``dense_below`` nodes and runs in ``O(m + nk)`` memory from there on;
|
|
56
|
+
``"smoothing"`` needs a full eigendecomposition every iteration and
|
|
57
|
+
refuses above 2000 nodes rather than trying.
|
|
58
|
+
tol : float
|
|
59
|
+
Scaled residual tolerance. Not the same thing as accuracy in the
|
|
60
|
+
objective: see :attr:`Solution.certified_gap` for that.
|
|
61
|
+
max_iter : int, optional
|
|
62
|
+
Iteration cap. Derived from ``n`` when omitted.
|
|
63
|
+
time_limit : float, optional
|
|
64
|
+
Seconds. On expiry the best feasible iterate found so far is returned
|
|
65
|
+
with ``status="time_limit"`` and ``converged=False``. Worth setting on
|
|
66
|
+
anything unfamiliar: a badly conditioned graph converges slowly, and
|
|
67
|
+
without a budget that looks exactly like a hang.
|
|
68
|
+
warm_start : Solution, optional
|
|
69
|
+
A previous solution on the same node set. See :func:`mixingmatrix.update`,
|
|
70
|
+
which does this dispatch for you and can often skip the solve outright.
|
|
71
|
+
callback : callable, optional
|
|
72
|
+
``fn(iteration, residual, slem) -> bool``. Returning ``False`` stops the
|
|
73
|
+
solve and returns the best iterate so far with ``status="stopped"``.
|
|
74
|
+
allow_negative : bool
|
|
75
|
+
Solve FDLA instead of FMMC (Xiao & Boyd 2004): drop ``w >= 0``, so the
|
|
76
|
+
result is an averaging operator rather than a Markov chain. Always at
|
|
77
|
+
least as fast-mixing as the FMMC optimum, often noticeably so, and it
|
|
78
|
+
is what the distributed-averaging literature uses.
|
|
79
|
+
stationary : mapping or array, optional
|
|
80
|
+
Target stationary distribution. The result is the fastest reversible
|
|
81
|
+
chain with that distribution, and ``Solution.weights`` is then a
|
|
82
|
+
row-stochastic but nonsymmetric transition matrix. Uniform by default.
|
|
83
|
+
symmetric : bool
|
|
84
|
+
Reserved for the non-reversible variant, which is not implemented.
|
|
85
|
+
**options
|
|
86
|
+
Passed through to the solver. The ones worth knowing:
|
|
87
|
+
``spectrum`` (``"auto"``/``"dense"``/``"lanczos"``) forces the dense or
|
|
88
|
+
matrix-free implementation instead of letting size decide;
|
|
89
|
+
``dense_below`` (400) moves that crossover; ``rho``, ``adaptive_rho``,
|
|
90
|
+
``lanczos_k``, ``qp_backend``, ``adaptive_inner_tol``.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
Solution
|
|
95
|
+
|
|
96
|
+
Notes
|
|
97
|
+
-----
|
|
98
|
+
Nothing here forms a dense ``n x n`` array above ``dense_below`` nodes, so
|
|
99
|
+
memory is not what limits the size of a solve -- see ``docs/scaling.md``
|
|
100
|
+
for what is. Set ``time_limit`` on anything large: every iterate is a
|
|
101
|
+
legal mixing matrix, so a budget returns the best one reached rather than
|
|
102
|
+
nothing, and ``certified_gap`` says how good it is.
|
|
103
|
+
|
|
104
|
+
Examples
|
|
105
|
+
--------
|
|
106
|
+
>>> import networkx as nx, mixingmatrix
|
|
107
|
+
>>> sol = mixingmatrix.solve(nx.cycle_graph(6), tol=1e-9)
|
|
108
|
+
>>> round(sol.slem, 4) # uniform weight 0.4 on every edge
|
|
109
|
+
0.6
|
|
110
|
+
>>> sol.converged
|
|
111
|
+
True
|
|
112
|
+
"""
|
|
113
|
+
if not symmetric:
|
|
114
|
+
raise NotImplementedError(
|
|
115
|
+
"symmetric=False (a non-reversible row-stochastic chain) is not "
|
|
116
|
+
"implemented. That problem minimises the second-largest *singular* "
|
|
117
|
+
"value of a nonsymmetric matrix and needs a different spectral "
|
|
118
|
+
"step; it is not a flag away. Use stationary=pi for a reversible "
|
|
119
|
+
"chain with a nonuniform stationary distribution."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
ix = GraphIndex(graph)
|
|
123
|
+
pi = ix.stationary_vector(stationary)
|
|
124
|
+
if pi is not None and allow_negative:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"allow_negative=True and stationary=pi are incompatible: a chain "
|
|
127
|
+
"with negative weights has no stationary distribution to target"
|
|
128
|
+
)
|
|
129
|
+
problem = MixingProblem(ix, stationary=pi, nonneg=not allow_negative)
|
|
130
|
+
|
|
131
|
+
if method == "auto":
|
|
132
|
+
method = choose_method(problem.n, nonneg=problem.nonneg)
|
|
133
|
+
solver = get_solver(method)
|
|
134
|
+
|
|
135
|
+
state = None
|
|
136
|
+
if warm_start is not None:
|
|
137
|
+
state = _warm_start_state(warm_start, problem)
|
|
138
|
+
|
|
139
|
+
opts = SolveOptions(
|
|
140
|
+
tol=tol, max_iter=max_iter, time_limit=time_limit, verbose=verbose,
|
|
141
|
+
callback=callback, warm_start=state,
|
|
142
|
+
**{k: v for k, v in options.items() if k in SolveOptions.__dataclass_fields__},
|
|
143
|
+
)
|
|
144
|
+
unknown = set(options) - set(SolveOptions.__dataclass_fields__)
|
|
145
|
+
if unknown:
|
|
146
|
+
opts.extra.update({k: options[k] for k in unknown})
|
|
147
|
+
|
|
148
|
+
if verbose:
|
|
149
|
+
print(f"mixingmatrix: {problem!r} via {method}, tol={tol:g}")
|
|
150
|
+
t0 = time.perf_counter()
|
|
151
|
+
out = solver(problem, opts)
|
|
152
|
+
runtime = time.perf_counter() - t0
|
|
153
|
+
|
|
154
|
+
sol = Solution(
|
|
155
|
+
problem=problem, w=np.asarray(out.w, dtype=float), status=out.status,
|
|
156
|
+
iterations=out.iterations, runtime=runtime, method=method,
|
|
157
|
+
residuals={"primal": out.primal_residual, "dual": out.dual_residual,
|
|
158
|
+
"tolerance": out.tolerance},
|
|
159
|
+
options={"method": method, "tol": tol, "max_iter": max_iter,
|
|
160
|
+
"time_limit": time_limit, **options},
|
|
161
|
+
solver_state=out.state, info=dict(out.info),
|
|
162
|
+
)
|
|
163
|
+
if out.status in ("max_iter", "infeasible"):
|
|
164
|
+
warnings.warn(
|
|
165
|
+
f"mixingmatrix.solve did not converge (status={out.status!r}, "
|
|
166
|
+
f"{out.iterations} iterations, slem={sol.slem:.8f}). The matrix is "
|
|
167
|
+
"feasible and usable; it is not proven optimal. Raise max_iter or "
|
|
168
|
+
"loosen tol, and check Solution.certified_gap.",
|
|
169
|
+
RuntimeWarning, stacklevel=2,
|
|
170
|
+
)
|
|
171
|
+
return sol
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _warm_start_state(warm_start: Solution, problem: MixingProblem) -> dict:
|
|
175
|
+
"""Carry solver state across a solve, refusing to do so when it is not valid.
|
|
176
|
+
|
|
177
|
+
``Z`` and ``U`` live in node-index space, so they only transfer when the
|
|
178
|
+
node set *and its ordering* are unchanged. Weights transfer per edge and
|
|
179
|
+
are matched by label, so an edge that vanished is dropped and a new one
|
|
180
|
+
starts at zero -- which is exactly the right guess for an edge that was not
|
|
181
|
+
worth having a moment ago.
|
|
182
|
+
"""
|
|
183
|
+
old_ix, new_ix = warm_start.ix, problem.ix
|
|
184
|
+
state: dict = {}
|
|
185
|
+
if not old_ix.same_nodes(new_ix):
|
|
186
|
+
return state
|
|
187
|
+
if warm_start.problem.uniform != problem.uniform:
|
|
188
|
+
return state
|
|
189
|
+
w = np.zeros(problem.m)
|
|
190
|
+
old = dict(zip(old_ix.edges, warm_start.w, strict=True))
|
|
191
|
+
for e, key in enumerate(new_ix.edges):
|
|
192
|
+
if key in old:
|
|
193
|
+
w[e] = old[key]
|
|
194
|
+
state["w"] = w
|
|
195
|
+
for field in ("Z", "U", "rho"):
|
|
196
|
+
if field in warm_start.solver_state:
|
|
197
|
+
state[field] = warm_start.solver_state[field]
|
|
198
|
+
return state
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def compare(
|
|
202
|
+
graph: nx.Graph,
|
|
203
|
+
methods: Sequence[str] = ("optimal", "metropolis", "max_degree", "best_constant"),
|
|
204
|
+
eps: float = 1e-6,
|
|
205
|
+
x0=None,
|
|
206
|
+
**solve_kwargs,
|
|
207
|
+
):
|
|
208
|
+
"""One table showing what optimal weights buy on *this* graph.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
graph : networkx.Graph
|
|
213
|
+
methods : sequence of str
|
|
214
|
+
Any of ``"optimal"`` (:func:`solve`), ``"fdla"`` (``solve`` with
|
|
215
|
+
``allow_negative=True``), and the baselines ``"metropolis"``,
|
|
216
|
+
``"max_degree"``, ``"best_constant"``, ``"lazy_random_walk"``.
|
|
217
|
+
eps : float
|
|
218
|
+
Consensus accuracy the round counts are quoted at.
|
|
219
|
+
x0 : array, optional
|
|
220
|
+
If given, the table also reports the *measured* rounds to consensus
|
|
221
|
+
from this starting state, next to the predicted ones. The two differ:
|
|
222
|
+
the prediction is asymptotic and generally conservative for a random
|
|
223
|
+
start.
|
|
224
|
+
|
|
225
|
+
Returns
|
|
226
|
+
-------
|
|
227
|
+
pandas.DataFrame
|
|
228
|
+
Columns ``method, slem, spectral_gap, consensus_rounds, speedup,
|
|
229
|
+
runtime``, sorted by SLEM. ``speedup`` is the round-count ratio
|
|
230
|
+
against Metropolis-Hastings, the weighting a user most likely already
|
|
231
|
+
has -- the honest statement of the benefit, since a SLEM that is 5%
|
|
232
|
+
better is not a 5% faster anything.
|
|
233
|
+
"""
|
|
234
|
+
import pandas as pd
|
|
235
|
+
|
|
236
|
+
rows = []
|
|
237
|
+
for name in methods:
|
|
238
|
+
t0 = time.perf_counter()
|
|
239
|
+
if name in ("optimal", "fmmc"):
|
|
240
|
+
B = solve(graph, allow_negative=False, **solve_kwargs).weights
|
|
241
|
+
elif name == "fdla":
|
|
242
|
+
B = solve(graph, allow_negative=True, **solve_kwargs).weights
|
|
243
|
+
elif name in BASELINES:
|
|
244
|
+
B = BASELINES[name](graph)
|
|
245
|
+
else:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"unknown method {name!r}; choose from 'optimal', 'fdla', "
|
|
248
|
+
f"{sorted(BASELINES)}"
|
|
249
|
+
)
|
|
250
|
+
runtime = time.perf_counter() - t0
|
|
251
|
+
row = {
|
|
252
|
+
"method": name,
|
|
253
|
+
"slem": slem(B),
|
|
254
|
+
"spectral_gap": spectral_gap(B),
|
|
255
|
+
"consensus_rounds": consensus_rounds(B, eps=eps),
|
|
256
|
+
"runtime": runtime,
|
|
257
|
+
}
|
|
258
|
+
if x0 is not None:
|
|
259
|
+
from .simulate import rounds_to_consensus
|
|
260
|
+
|
|
261
|
+
row["measured_rounds"] = rounds_to_consensus(B, x0, eps=eps)
|
|
262
|
+
rows.append(row)
|
|
263
|
+
|
|
264
|
+
df = pd.DataFrame(rows).sort_values("slem").reset_index(drop=True)
|
|
265
|
+
ref = df.loc[df["method"] == "metropolis", "consensus_rounds"]
|
|
266
|
+
if len(ref) and np.isfinite(ref.iloc[0]):
|
|
267
|
+
df["speedup"] = ref.iloc[0] / df["consensus_rounds"]
|
|
268
|
+
return df[[c for c in ("method", "slem", "spectral_gap", "consensus_rounds",
|
|
269
|
+
"measured_rounds", "speedup", "runtime") if c in df]]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""The weights people actually use, so the optimum has something to beat.
|
|
2
|
+
|
|
3
|
+
These ship with the package on purpose. The question a user has is never
|
|
4
|
+
"what is the fastest mixing chain on my graph" in the abstract -- it is
|
|
5
|
+
"is it worth replacing the Metropolis-Hastings weights I already have".
|
|
6
|
+
Answering that should not require them to implement the comparison.
|
|
7
|
+
|
|
8
|
+
All four return the same type as :attr:`mixingmatrix.Solution.weights`, a
|
|
9
|
+
``scipy.sparse.csr_matrix``, so they drop straight into
|
|
10
|
+
:func:`mixingmatrix.slem`, :func:`mixingmatrix.gossip` and :func:`mixingmatrix.compare`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import networkx as nx
|
|
16
|
+
import numpy as np
|
|
17
|
+
import scipy.sparse as sp
|
|
18
|
+
|
|
19
|
+
import scipy.sparse.linalg as spla
|
|
20
|
+
|
|
21
|
+
from ._graph import GraphIndex
|
|
22
|
+
from .problem import MixingProblem
|
|
23
|
+
from .spectral import DENSE_LIMIT, symmetrize
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"metropolis_hastings",
|
|
27
|
+
"max_degree",
|
|
28
|
+
"best_constant",
|
|
29
|
+
"lazy_random_walk",
|
|
30
|
+
"BASELINES",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def metropolis_hastings(graph: nx.Graph, stationary=None) -> sp.csr_matrix:
|
|
35
|
+
"""``w_ij = 1 / (1 + max(d_i, d_j))`` -- the universal default.
|
|
36
|
+
|
|
37
|
+
Needs no global information (each node knows its own degree and its
|
|
38
|
+
neighbours'), which is why it is what nearly every distributed averaging
|
|
39
|
+
implementation ships with, and why it is the right thing to measure
|
|
40
|
+
against. With ``stationary`` given, this is the Metropolis-Hastings chain
|
|
41
|
+
targeting that distribution over the graph's uniform proposal.
|
|
42
|
+
"""
|
|
43
|
+
ix = GraphIndex(graph)
|
|
44
|
+
problem = MixingProblem(ix, stationary=ix.stationary_vector(stationary))
|
|
45
|
+
return problem.transition_sparse(problem.metropolis_weights())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def max_degree(graph: nx.Graph) -> sp.csr_matrix:
|
|
49
|
+
"""``w_ij = 1 / d_max`` on every edge: one global constant, no tuning.
|
|
50
|
+
|
|
51
|
+
The simplest weighting that is guaranteed stochastic, and the one whose
|
|
52
|
+
weakness is easiest to see: a single high-degree node slows down every edge
|
|
53
|
+
in the graph, including edges nowhere near it.
|
|
54
|
+
"""
|
|
55
|
+
ix = GraphIndex(graph)
|
|
56
|
+
problem = MixingProblem(ix)
|
|
57
|
+
d_max = float(ix.degrees().max()) if ix.n > 1 else 1.0
|
|
58
|
+
w = np.full(problem.m, 1.0 / d_max) if d_max > 0 else np.zeros(problem.m)
|
|
59
|
+
return problem.to_sparse(w)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def best_constant(graph: nx.Graph, allow_negative: bool = False) -> sp.csr_matrix:
|
|
63
|
+
"""The best possible *single* weight on every edge: ``B = I - alpha L``.
|
|
64
|
+
|
|
65
|
+
Solved exactly, not searched. The eigenvalues of ``B`` are ``1 - alpha
|
|
66
|
+
mu_i`` for the Laplacian eigenvalues ``mu_i``, so
|
|
67
|
+
|
|
68
|
+
SLEM(alpha) = max(|1 - alpha mu_2|, |1 - alpha mu_n|)
|
|
69
|
+
|
|
70
|
+
which is a convex piecewise-linear function of one variable, minimised
|
|
71
|
+
where the two terms meet: ``alpha* = 2 / (mu_2 + mu_n)`` (Xiao & Boyd 2004).
|
|
72
|
+
Nonnegativity of the chain caps ``alpha`` at ``1/d_max``, and because
|
|
73
|
+
``SLEM`` is convex the constrained optimum is just the clipped ``alpha*``.
|
|
74
|
+
|
|
75
|
+
This is the honest measure of what *edge-specific* weights buy: the gap
|
|
76
|
+
between this and :func:`mixingmatrix.solve` is the part of the improvement that
|
|
77
|
+
could not have come from a single well-chosen scalar.
|
|
78
|
+
"""
|
|
79
|
+
ix = GraphIndex(graph)
|
|
80
|
+
problem = MixingProblem(ix)
|
|
81
|
+
n = ix.n
|
|
82
|
+
if n <= 1 or problem.m == 0:
|
|
83
|
+
return sp.csr_matrix(np.ones((n, n)))
|
|
84
|
+
mu2, mun = _laplacian_extremes(ix)
|
|
85
|
+
alpha = 2.0 / (mu2 + mun) if (mu2 + mun) > 0 else 0.0
|
|
86
|
+
if not allow_negative:
|
|
87
|
+
d_max = float(ix.degrees().max())
|
|
88
|
+
alpha = min(alpha, 1.0 / d_max if d_max > 0 else alpha)
|
|
89
|
+
return problem.to_sparse(np.full(problem.m, alpha))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _laplacian_extremes(ix: GraphIndex) -> tuple[float, float]:
|
|
93
|
+
"""``(mu_2, mu_n)``: the algebraic connectivity and the largest Laplacian
|
|
94
|
+
eigenvalue, which is all the closed form needs.
|
|
95
|
+
|
|
96
|
+
Dense below :data:`~mixingmatrix.spectral.DENSE_LIMIT`; above it, two Lanczos
|
|
97
|
+
calls -- ``mu_n`` directly, and ``mu_2`` as the smallest eigenvalue of the
|
|
98
|
+
Laplacian deflated against its known null vector ``1``, which is exact
|
|
99
|
+
rather than a shift-invert approximation.
|
|
100
|
+
"""
|
|
101
|
+
n = ix.n
|
|
102
|
+
L = nx.laplacian_matrix(ix.to_graph(), nodelist=list(ix.nodes)).astype(float)
|
|
103
|
+
if n <= DENSE_LIMIT:
|
|
104
|
+
mu = np.sort(np.linalg.eigvalsh(symmetrize(L.toarray())))
|
|
105
|
+
return float(mu[1]), float(mu[-1])
|
|
106
|
+
Lc = L.tocsr()
|
|
107
|
+
mun = float(spla.eigsh(Lc, k=1, which="LA", tol=1e-8,
|
|
108
|
+
return_eigenvectors=False)[0])
|
|
109
|
+
ones = np.full(n, 1.0 / np.sqrt(n))
|
|
110
|
+
|
|
111
|
+
def shifted(x):
|
|
112
|
+
# L + mu_n * (1 1^T / n): pushes the null vector to the top of the
|
|
113
|
+
# spectrum so that the smallest eigenvalue of the shifted operator is
|
|
114
|
+
# mu_2 exactly, with no other eigenvalue moved.
|
|
115
|
+
return Lc @ x + (mun * float(ones @ x)) * ones
|
|
116
|
+
|
|
117
|
+
op = spla.LinearOperator((n, n), matvec=shifted, dtype=float)
|
|
118
|
+
mu2 = float(spla.eigsh(op, k=1, which="SA", tol=1e-8,
|
|
119
|
+
ncv=min(n - 1, 60), return_eigenvectors=False)[0])
|
|
120
|
+
return mu2, mun
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def lazy_random_walk(graph: nx.Graph, p: float = 0.5) -> sp.csr_matrix:
|
|
124
|
+
"""``P = (1 - p) I + p D^{-1} A`` -- the lazy simple random walk.
|
|
125
|
+
|
|
126
|
+
Included because it is the chain most of the Markov-chain literature means
|
|
127
|
+
by "the random walk on G", and because it is a useful reminder that a valid
|
|
128
|
+
chain need not be doubly stochastic: this one has stationary distribution
|
|
129
|
+
proportional to degree, not uniform, so it converges to a *degree-weighted*
|
|
130
|
+
average rather than the mean. :func:`mixingmatrix.slem` handles it correctly;
|
|
131
|
+
:func:`mixingmatrix.gossip` will happily show it converging to the wrong thing.
|
|
132
|
+
"""
|
|
133
|
+
if not 0.0 < p <= 1.0:
|
|
134
|
+
raise ValueError(f"laziness p must be in (0, 1], got {p}")
|
|
135
|
+
ix = GraphIndex(graph)
|
|
136
|
+
n = ix.n
|
|
137
|
+
i, j = np.array([e[0] for e in ix.edges] or [], dtype=int), \
|
|
138
|
+
np.array([e[1] for e in ix.edges] or [], dtype=int)
|
|
139
|
+
A = sp.csr_matrix((np.ones(2 * ix.m), (np.concatenate([i, j]),
|
|
140
|
+
np.concatenate([j, i]))), shape=(n, n))
|
|
141
|
+
deg = np.asarray(A.sum(axis=1)).ravel()
|
|
142
|
+
deg[deg == 0] = 1.0
|
|
143
|
+
return sp.csr_matrix((1.0 - p) * sp.eye(n) + p * (sp.diags(1.0 / deg) @ A))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
#: Name -> callable, for :func:`mixingmatrix.compare` and the CLI.
|
|
147
|
+
BASELINES = {
|
|
148
|
+
"metropolis": metropolis_hastings,
|
|
149
|
+
"max_degree": max_degree,
|
|
150
|
+
"best_constant": best_constant,
|
|
151
|
+
"lazy_random_walk": lazy_random_walk,
|
|
152
|
+
}
|