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.
@@ -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
+ }