mfaoa 0.1.0__py3-none-win_amd64.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.
mfaoa/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Binary quadratic optimization with the mean-field approximate optimization
2
+ algorithm (MFAOA) of Misra-Spieldenner et al., PRX Quantum 4,
3
+ 030335 (2023).
4
+
5
+ State a problem as an `IsingProblem` (variables -1 and +1) or a
6
+ `QUBOProblem` (variables 0 and 1), then call `solve`:
7
+
8
+ >>> import numpy as np, mfaoa
9
+ >>> J = np.array([[0.0, 1.0], [1.0, 0.0]])
10
+ >>> mfaoa.solve(mfaoa.IsingProblem([0.5, 0.0], J)).sigma
11
+ array([-1, 1], dtype=int8)
12
+ """
13
+
14
+ from ._lib import version as _version
15
+ from ._problems import IsingProblem, QUBOProblem, Schedule, linear_schedule
16
+ from ._solve import Result, SpinState, solve
17
+ from ._symmetry import FlipSymmetricError
18
+
19
+ __version__ = _version()
20
+
21
+ # The public classes report the package as their module, so that messages
22
+ # and tracebacks name them as users import them: mfaoa.FlipSymmetricError.
23
+ for _cls in (IsingProblem, QUBOProblem, Schedule, Result, SpinState, FlipSymmetricError):
24
+ _cls.__module__ = "mfaoa"
25
+
26
+ __all__ = [
27
+ "FlipSymmetricError",
28
+ "IsingProblem",
29
+ "QUBOProblem",
30
+ "Result",
31
+ "Schedule",
32
+ "SpinState",
33
+ "linear_schedule",
34
+ "solve",
35
+ ]
mfaoa/_lib.py ADDED
@@ -0,0 +1,184 @@
1
+ """The C library: loading it, and calling mfaoa_solve through ctypes.
2
+
3
+ The structs below mirror include/mfaoa.h of the version HEADER_VERSION.
4
+ Nothing checks the two against each other when the package is built, so the
5
+ import compares the loaded library's version with HEADER_VERSION, and the
6
+ reference runs in the tests exercise every field.
7
+
8
+ The library in the wheels is built without a BLAS. For dense couplings it
9
+ calls SciPy's dgemv, whose address this module passes in every problem.
10
+ """
11
+
12
+ import ctypes
13
+ import functools
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+
20
+ HEADER_VERSION = "0.1.0"
21
+
22
+ # The return codes of mfaoa_solve.
23
+ OK = 0
24
+ FLIP_SYMMETRIC = 1
25
+ INVALID_ARGUMENT = 2
26
+
27
+ _int64 = ctypes.c_int64
28
+ _ptr = ctypes.c_void_p
29
+
30
+
31
+ class _Problem(ctypes.Structure):
32
+ """mfaoa_problem of mfaoa.h; pointers are passed as plain addresses."""
33
+ _fields_ = [
34
+ ("n", _int64),
35
+ ("h", _ptr),
36
+ ("J", _ptr),
37
+ ("colptr", _ptr),
38
+ ("rowval", _ptr),
39
+ ("nzval", _ptr),
40
+ ("delta", _ptr),
41
+ ("scale", ctypes.c_double),
42
+ ("sense", ctypes.c_int32),
43
+ ("dgemv", _ptr),
44
+ ]
45
+
46
+
47
+ class _Schedule(ctypes.Structure):
48
+ """mfaoa_schedule of mfaoa.h."""
49
+ _fields_ = [("p", _int64), ("beta", _ptr), ("gamma", _ptr)]
50
+
51
+
52
+ class _Workspace(ctypes.Structure):
53
+ """mfaoa_workspace of mfaoa.h."""
54
+ _fields_ = [(name, _ptr) for name in ("x", "y", "z", "m", "sigma", "group")]
55
+
56
+
57
+ def _library_path():
58
+ """The path of the C library inside the installed package, named as CMake names it on this platform."""
59
+ names = {"win32": "mfaoa.dll", "darwin": "libmfaoa.dylib"}
60
+ return Path(__file__).with_name(names.get(sys.platform, "libmfaoa.so"))
61
+
62
+
63
+ def _load():
64
+ """Load the C library and declare its functions.
65
+
66
+ Returns:
67
+ The ctypes library.
68
+
69
+ Raises:
70
+ ImportError: the library is missing, as it is when the package is
71
+ imported from its source folder, or its major and minor version
72
+ differ from HEADER_VERSION, so the structs above may not match it.
73
+ """
74
+ path = _library_path()
75
+ if not path.exists():
76
+ raise ImportError(
77
+ f"The C library of mfaoa is missing: {path} does not exist. The library is built "
78
+ "when the package is installed, so mfaoa cannot be imported from its source folder. "
79
+ "Install it with pip install mfaoa, or with pip install . in the source folder."
80
+ )
81
+ lib = ctypes.CDLL(str(path))
82
+ lib.mfaoa_solve.restype = ctypes.c_int32
83
+ lib.mfaoa_solve.argtypes = [
84
+ ctypes.POINTER(_Problem),
85
+ ctypes.POINTER(_Schedule),
86
+ ctypes.POINTER(_Workspace),
87
+ ]
88
+ lib.mfaoa_linear_schedule.restype = ctypes.c_int32
89
+ lib.mfaoa_linear_schedule.argtypes = [_int64, ctypes.c_double, _ptr, _ptr]
90
+ lib.mfaoa_version.restype = ctypes.c_char_p
91
+ lib.mfaoa_version.argtypes = []
92
+ loaded = lib.mfaoa_version().decode()
93
+ if loaded.split(".")[:2] != HEADER_VERSION.split(".")[:2]:
94
+ raise ImportError(
95
+ f"The Python package mfaoa {HEADER_VERSION} loaded its C library from {_library_path()}, "
96
+ f"but that library has version {loaded}. "
97
+ "Reinstall the package: pip install --force-reinstall mfaoa"
98
+ )
99
+ return lib
100
+
101
+
102
+ _lib = _load()
103
+
104
+
105
+ def version():
106
+ """The version of the loaded C library, such as "0.1.0"."""
107
+ return _lib.mfaoa_version().decode()
108
+
109
+
110
+ # SciPy's dgemv takes 32-bit integers. A SciPy built with 64-bit BLAS
111
+ # integers would show another type than "int" in this signature.
112
+ _DGEMV_SIGNATURE = (
113
+ "void (char *, int *, int *, double *, double *, int *, "
114
+ "double *, int *, double *, double *, int *)"
115
+ )
116
+
117
+
118
+ @functools.cache
119
+ def scipy_dgemv():
120
+ """The address of SciPy's dgemv, in the form mfaoa_problem.dgemv takes."""
121
+ from scipy.linalg import cython_blas
122
+
123
+ capsule = cython_blas.__pyx_capi__["dgemv"]
124
+ get_name = ctypes.pythonapi.PyCapsule_GetName
125
+ get_name.restype = ctypes.c_char_p
126
+ get_name.argtypes = [ctypes.py_object]
127
+ get_pointer = ctypes.pythonapi.PyCapsule_GetPointer
128
+ get_pointer.restype = ctypes.c_void_p
129
+ get_pointer.argtypes = [ctypes.py_object, ctypes.c_char_p]
130
+ name = get_name(capsule)
131
+ # Cython writes SciPy's own name for double, such as
132
+ # __pyx_t_5scipy_6linalg_11cython_blas_d.
133
+ signature = re.sub(r"__pyx_t_\w+_d\b", "double", name.decode())
134
+ if signature != _DGEMV_SIGNATURE:
135
+ raise RuntimeError(
136
+ "Dense couplings need SciPy's BLAS with 32-bit integers, "
137
+ f"but this SciPy's dgemv has the signature {signature}. "
138
+ "Pass J as a scipy.sparse matrix, or install SciPy from PyPI."
139
+ )
140
+ return get_pointer(capsule, name)
141
+
142
+
143
+ def _address(a):
144
+ """The address of an array's data, or None (NULL) for None."""
145
+ return None if a is None else a.ctypes.data
146
+
147
+
148
+ def solve(h, J, colptr, rowval, nzval, delta, scale, sense, beta, gamma):
149
+ """Run mfaoa_solve on arrays already in the form mfaoa.h describes.
150
+
151
+ J is a dense matrix (C- or Fortran-ordered; it must be symmetric) or
152
+ None, in which case colptr, rowval and nzval hold the couplings in
153
+ compressed sparse columns. scale <= 0 lets the library compute the
154
+ field scale. Returns the code and the workspace arrays.
155
+ """
156
+ n = h.shape[0]
157
+ problem = _Problem(
158
+ n=n,
159
+ h=_address(h),
160
+ J=_address(J),
161
+ colptr=_address(colptr),
162
+ rowval=_address(rowval),
163
+ nzval=_address(nzval),
164
+ delta=_address(delta),
165
+ scale=scale,
166
+ sense=sense,
167
+ dgemv=scipy_dgemv() if J is not None else None,
168
+ )
169
+ schedule = _Schedule(p=beta.shape[0], beta=_address(beta), gamma=_address(gamma))
170
+ ws = {name: np.empty(n) for name in ("x", "y", "z", "m")}
171
+ ws["sigma"] = np.empty(n, dtype=np.int8)
172
+ ws["group"] = np.empty(n, dtype=np.int64)
173
+ workspace = _Workspace(**{name: _address(a) for name, a in ws.items()})
174
+ code = _lib.mfaoa_solve(ctypes.byref(problem), ctypes.byref(schedule), ctypes.byref(workspace))
175
+ return code, ws
176
+
177
+
178
+ def linear_schedule(p, tau):
179
+ """beta and gamma of the linear schedule, from mfaoa_linear_schedule."""
180
+ beta = np.empty(p)
181
+ gamma = np.empty(p)
182
+ code = _lib.mfaoa_linear_schedule(p, tau, _address(beta), _address(gamma))
183
+ assert code == OK
184
+ return beta, gamma
mfaoa/_problems.py ADDED
@@ -0,0 +1,475 @@
1
+ """The two ways to state a problem, the conversion between them, and schedules.
2
+
3
+ Both problem types keep the arrays they are given, without copying them,
4
+ when those are already float64 NumPy arrays. A sparse J is stored in
5
+ compressed sparse columns (CSC), the format of the C library; its index
6
+ arrays are copied once, to the 64-bit integers the library takes.
7
+ """
8
+
9
+ import math
10
+ import operator
11
+
12
+ import numpy as np
13
+ import scipy.sparse as sp
14
+
15
+ from . import _lib
16
+
17
+ _SENSES = {"min": 1, "max": -1}
18
+
19
+
20
+ def _check_sense(sense):
21
+ """Raise a ValueError unless sense is "min" or "max"."""
22
+ if sense not in _SENSES:
23
+ raise ValueError(f"sense must be 'min' or 'max', got {sense!r}")
24
+
25
+
26
+ def _matrix(A):
27
+ """A as a float64 matrix: a SciPy sparse array in canonical CSC form
28
+ (sorted indices, no duplicates) with contiguous values, or a contiguous
29
+ NumPy array."""
30
+ if sp.issparse(A):
31
+ A = sp.csc_array(A, dtype=np.float64)
32
+ if not A.has_canonical_format:
33
+ A = A.copy()
34
+ A.sum_duplicates()
35
+ # The values may be a view with a stride, as the C library cannot read.
36
+ if not A.data.flags.c_contiguous:
37
+ A.data = np.ascontiguousarray(A.data)
38
+ return A
39
+ A = np.asarray(A, dtype=np.float64)
40
+ if not (A.flags.c_contiguous or A.flags.f_contiguous):
41
+ A = np.ascontiguousarray(A)
42
+ return A
43
+
44
+
45
+ def _vector(v, name, n=None):
46
+ """v as a contiguous float64 vector.
47
+
48
+ Args:
49
+ v: anything NumPy turns into a one-dimensional array.
50
+ name: the argument's name, for messages.
51
+ n: the length v must have, or None.
52
+
53
+ Raises:
54
+ ValueError: v is not one-dimensional, its length is not n, or an
55
+ entry is infinite or NaN.
56
+ """
57
+ v = np.ascontiguousarray(v, dtype=np.float64)
58
+ if v.ndim != 1:
59
+ raise ValueError(f"{name} must be a vector, got an array of shape {v.shape}")
60
+ if n is not None and v.shape[0] != n:
61
+ raise ValueError(f"{name} must have one entry per variable, got {v.shape[0]} for n = {n}")
62
+ bad = np.flatnonzero(~np.isfinite(v))
63
+ if bad.size:
64
+ i = int(bad[0])
65
+ raise ValueError(f"{name} must be finite, but {name}[{i}] = {float(v[i])}")
66
+ return v
67
+
68
+
69
+ def _number(value, name):
70
+ """value as a float; raises a ValueError if it is infinite or NaN."""
71
+ value = float(value)
72
+ if not math.isfinite(value):
73
+ raise ValueError(f"{name} must be finite, got {value}")
74
+ return value
75
+
76
+
77
+ def _check_finite(A, name):
78
+ """Raise a ValueError if the matrix A, dense or sparse, has an entry that is infinite or NaN."""
79
+ if sp.issparse(A):
80
+ A = A.tocoo()
81
+ bad = np.flatnonzero(~np.isfinite(A.data))
82
+ if not bad.size:
83
+ return
84
+ k = bad[0]
85
+ i, j, value = int(A.row[k]), int(A.col[k]), A.data[k]
86
+ else:
87
+ bad = np.argwhere(~np.isfinite(A))
88
+ if not len(bad):
89
+ return
90
+ i, j = (int(k) for k in bad[0])
91
+ value = A[i, j]
92
+ raise ValueError(f"{name} must be finite, but {name}[{i}, {j}] = {float(value)}")
93
+
94
+
95
+ def _check_couplings(J, n):
96
+ """Raise a ValueError unless J is n × n, finite, symmetric and zero on the diagonal."""
97
+ if J.shape != (n, n):
98
+ raise ValueError(f"J must be {n}×{n} to match h, got {J.shape}")
99
+ _check_finite(J, "J")
100
+ diagonal = J.diagonal()
101
+ nonzero = np.flatnonzero(diagonal)
102
+ if nonzero.size:
103
+ i = int(nonzero[0])
104
+ raise ValueError(
105
+ f"J must be zero on the diagonal, but J[{i}, {i}] = {float(diagonal[i])}. "
106
+ "Since σ[i]**2 = 1, a diagonal entry only adds a constant: add it to offset instead."
107
+ )
108
+ if sp.issparse(J):
109
+ differ = (J != J.T).tocoo()
110
+ pairs = list(zip(differ.row, differ.col))
111
+ else:
112
+ pairs = np.argwhere(J != J.T)
113
+ if len(pairs):
114
+ i, j = (int(k) for k in pairs[0])
115
+ raise ValueError(
116
+ f"J must be symmetric, but J[{i}, {j}] = {float(J[i, j])} "
117
+ f"and J[{j}, {i}] = {float(J[j, i])}"
118
+ )
119
+
120
+
121
+ def _sum_of_squares(A):
122
+ """The sum of the squared entries of a dense or sparse matrix."""
123
+ values = A.data if sp.issparse(A) else A.ravel(order="K")
124
+ return float(np.dot(values, values))
125
+
126
+
127
+ class IsingProblem:
128
+ """A binary quadratic problem in Ising form.
129
+
130
+ The variables σ[i] are -1 or +1, and the objective, to be minimized (or,
131
+ with ``sense="max"``, maximized), is
132
+
133
+ h @ σ + Σ_{i<j} J[i, j] σ[i] σ[j] + offset.
134
+
135
+ J must be symmetric with a zero diagonal. Since σ[i]**2 = 1, a diagonal
136
+ entry would only add a constant; put it into `offset` instead. J can be
137
+ a NumPy array or a SciPy sparse matrix. A sparse J is much faster when
138
+ most entries are zero.
139
+
140
+ Two keyword arguments tune the algorithm and rarely need to be set:
141
+
142
+ - `delta`: one positive factor per variable on the angles `beta` of the
143
+ schedule (see `Schedule`). The default, None, means 1 for every
144
+ variable and takes a faster code path.
145
+ - `scale`: the unit in which the schedule measures the slope of the
146
+ objective (see `fieldscale`). The default makes the solution
147
+ independent of the units of h and J; ``scale=1`` uses those units
148
+ themselves.
149
+ """
150
+
151
+ def __init__(self, h, J, *, delta=None, offset=0.0, sense="min", scale=None):
152
+ """Check and store the problem; see the class docstring.
153
+
154
+ Raises:
155
+ ValueError: an argument that does not describe a problem in Ising
156
+ form, with a message that says which and why.
157
+ """
158
+ _check_sense(sense)
159
+ h = _vector(h, "h")
160
+ n = h.shape[0]
161
+ if n < 1:
162
+ raise ValueError("the problem needs at least one variable")
163
+ J = _matrix(J)
164
+ _check_couplings(J, n)
165
+ if delta is not None:
166
+ delta = _vector(delta, "delta", n)
167
+ if not np.all(delta > 0):
168
+ raise ValueError("all driver strengths delta[i] must be positive")
169
+ if scale is None:
170
+ s = np.sqrt((float(np.dot(h, h)) + _sum_of_squares(J)) / n)
171
+ scale = 1.0 if s == 0 else float(s) # 1 for an all-zero objective
172
+ elif not (scale > 0 and math.isfinite(scale)):
173
+ raise ValueError(f"scale must be positive and finite, got {scale}")
174
+ self.n = n
175
+ self.h = h
176
+ self.J = J
177
+ self.delta = delta
178
+ self.offset = _number(offset, "offset")
179
+ self.sense = sense
180
+ self._scale = float(scale)
181
+ if sp.issparse(J):
182
+ self._colptr = J.indptr.astype(np.int64)
183
+ self._rowval = J.indices.astype(np.int64)
184
+
185
+ @property
186
+ def fieldscale(self):
187
+ """The unit in which the schedule measures the slope of the objective.
188
+
189
+ The algorithm moves each variable according to the slope of the
190
+ objective in that variable, m[i] = h[i] + Σ_j J[i, j] z[j], where
191
+ z[j], between -1 and +1, is the current value of variable j. In step
192
+ k, the angle that this slope sets is 2 gamma[k] m[i] / s (see
193
+ `Schedule`). Unless `scale` was given when the problem was built, s
194
+ is the root mean square of m[i] over all variables i and over random
195
+ values ±1 of z:
196
+
197
+ s = sqrt((Σ_i h[i]**2 + Σ_ij J[i, j]**2) / n).
198
+
199
+ Multiplying h and J by a constant multiplies s by the same constant,
200
+ so the algorithm, and the solution, do not depend on the units of the
201
+ objective.
202
+ """
203
+ return self._scale
204
+
205
+ def objective(self, sigma):
206
+ """The objective value at sigma, a vector of -1 and +1, including the offset."""
207
+ s = np.asarray(sigma, dtype=np.float64)
208
+ return float(self.h @ s + s @ (self.J @ s) / 2 + self.offset)
209
+
210
+ def is_flip_symmetric(self):
211
+ """Whether the problem has a group of variables that MFAOA cannot solve for.
212
+
213
+ Flipping all variables of such a group at once (σ → -σ) leaves the
214
+ objective unchanged. No term of the objective links a variable of the
215
+ group to one outside it, and h is zero on every variable of the
216
+ group, up to rounding errors relative to the largest coupling. When h
217
+ is zero everywhere, as in Max-Cut, the whole problem is such a
218
+ group. A single variable that appears in no term of the objective
219
+ does not count: any value is optimal for it, and the solution sets it
220
+ to +1.
221
+
222
+ The algorithm starts every variable at 0, halfway between -1 and +1.
223
+ There the objective has zero slope in the variables of such a group,
224
+ so they would never move. `solve` raises a `FlipSymmetricError`
225
+ instead, which names the groups. Fix one variable of each group to
226
+ either value and solve for the others. This loses nothing: flipping
227
+ the group turns every solution into one with the same objective
228
+ value, so of each such pair, one solution remains.
229
+
230
+ To fix σ[i] = s, remove variable i from the problem, add s * J[j, i]
231
+ to h[j] for every other variable j, and add s * h[i] to the offset.
232
+ """
233
+ code, _ = self._run(np.zeros(1), np.zeros(1))
234
+ return code == _lib.FLIP_SYMMETRIC
235
+
236
+ def _run(self, beta, gamma):
237
+ """mfaoa_solve on this problem; returns the code and the workspace."""
238
+ sparse = sp.issparse(self.J)
239
+ return _lib.solve(
240
+ self.h,
241
+ None if sparse else self.J,
242
+ self._colptr if sparse else None,
243
+ self._rowval if sparse else None,
244
+ self.J.data if sparse else None,
245
+ self.delta,
246
+ self._scale,
247
+ _SENSES[self.sense],
248
+ beta,
249
+ gamma,
250
+ )
251
+
252
+ def __repr__(self):
253
+ """The size, storage and sense of the problem."""
254
+ storage = "sparse" if sp.issparse(self.J) else "dense"
255
+ return f"IsingProblem: {self.n} variables, {storage} couplings, sense = {self.sense!r}"
256
+
257
+
258
+ class QUBOProblem:
259
+ """A binary quadratic problem in QUBO form.
260
+
261
+ The variables x[i] are 0 or 1, and the objective, to be minimized (or,
262
+ with ``sense="max"``, maximized), is
263
+
264
+ x @ Q @ x + c @ x + constant.
265
+
266
+ Q is used exactly as given: a symmetric matrix counts each off-diagonal
267
+ pair twice, an upper-triangular matrix once. Diagonal entries are
268
+ allowed; since x[i]**2 = x[i], they act as linear terms. Q can be a NumPy
269
+ array or a SciPy sparse matrix. `solve` converts the problem to Ising
270
+ form with x = (1 + σ) / 2 (see `to_ising`) and reports the solution both
271
+ as x and as σ.
272
+ """
273
+
274
+ def __init__(self, Q, *, c=None, constant=0.0, sense="min"):
275
+ """Check and store the problem; see the class docstring.
276
+
277
+ Raises:
278
+ ValueError: Q is not square or empty, c has the wrong length, a
279
+ number is infinite or NaN, or sense is not "min" or "max".
280
+ """
281
+ _check_sense(sense)
282
+ Q = _matrix(Q)
283
+ if Q.ndim != 2 or Q.shape[0] != Q.shape[1]:
284
+ raise ValueError(f"Q must be square, got shape {Q.shape}")
285
+ n = Q.shape[0]
286
+ if n < 1:
287
+ raise ValueError("the problem needs at least one variable")
288
+ _check_finite(Q, "Q")
289
+ self.n = n
290
+ self.Q = Q
291
+ self.c = np.zeros(n) if c is None else _vector(c, "c", n)
292
+ self.constant = _number(constant, "constant")
293
+ self.sense = sense
294
+
295
+ def objective(self, x):
296
+ """The objective value at x, a vector of 0 and 1, including the constant."""
297
+ v = np.asarray(x, dtype=np.float64)
298
+ return float(v @ (self.Q @ v) + self.c @ v + self.constant)
299
+
300
+ def to_ising(self, *, delta=None, scale=None):
301
+ """The same problem in Ising form, with x = (1 + σ) / 2.
302
+
303
+ A pair term Q[i, j] x[i] x[j] + Q[j, i] x[j] x[i] becomes the
304
+ coupling J[i, j] = (Q[i, j] + Q[j, i]) / 4 and contributes the same
305
+ amount to both fields and to the offset. The weight of x[i] alone,
306
+ c[i] + Q[i, i], goes half into h[i] and half into the offset. J is
307
+ sparse if Q is.
308
+ """
309
+ n, Q = self.n, self.Q
310
+ if sp.issparse(Q):
311
+ Q = Q.tocoo()
312
+ off = Q.row != Q.col
313
+ i, j, v = Q.row[off], Q.col[off], Q.data[off] / 4
314
+ # Both orientations of every entry; duplicates add up, which
315
+ # gives (Q[i, j] + Q[j, i]) / 4 at (i, j) and at (j, i).
316
+ J = sp.coo_array(
317
+ (np.concatenate([v, v]), (np.concatenate([i, j]), np.concatenate([j, i]))),
318
+ shape=(n, n),
319
+ ).tocsc()
320
+ J.sum_duplicates()
321
+ J.eliminate_zeros()
322
+ else:
323
+ J = (Q + Q.T) / 4
324
+ np.fill_diagonal(J, 0.0)
325
+ linear = self.c + Q.diagonal() # the weight of x[i] alone
326
+ coupling_sums = J @ np.ones(n) # Σ_j J[i, j]
327
+ h = linear / 2 + coupling_sums
328
+ offset = self.constant + linear.sum() / 2 + coupling_sums.sum() / 2
329
+ return IsingProblem(h, J, delta=delta, offset=offset, sense=self.sense, scale=scale)
330
+
331
+ def is_flip_symmetric(self):
332
+ """Whether the problem has a group of variables that MFAOA cannot solve for.
333
+
334
+ Flipping all variables of such a group at once (x → 1 - x) leaves the
335
+ objective unchanged. See `IsingProblem.is_flip_symmetric` for why
336
+ the algorithm cannot solve such a problem, and why fixing one variable
337
+ of each group loses nothing.
338
+
339
+ To fix x[i] = v, remove variable i from the problem, add
340
+ v * (Q[i, j] + Q[j, i]) to c[j] for every other variable j, and add
341
+ v * (Q[i, i] + c[i]) to the constant.
342
+ """
343
+ return self.to_ising().is_flip_symmetric()
344
+
345
+ def __repr__(self):
346
+ """The size, storage and sense of the problem."""
347
+ storage = "sparse" if sp.issparse(self.Q) else "dense"
348
+ return f"QUBOProblem: {self.n} variables, {storage} Q, sense = {self.sense!r}"
349
+
350
+
351
+ class Schedule:
352
+ """The angles that drive the algorithm, one pair beta[k], gamma[k] per step k.
353
+
354
+ The algorithm represents each variable σ[i] by a unit vector in three
355
+ dimensions. The vector's z component, between -1 and +1, is the
356
+ variable's current value. Every vector starts at (1, 0, 0), so every
357
+ variable starts at 0, halfway between -1 and +1. Each step k turns every
358
+ vector i twice:
359
+
360
+ 1. about the z axis by the angle 2 gamma[k] m[i] / s, where m[i] is the
361
+ slope of the objective in variable i at the current values, and s is
362
+ the problem's `fieldscale` (the opposite angle for a maximization);
363
+ 2. about the x axis by the angle 2 beta[k] delta[i], where delta[i] is 1
364
+ unless the problem sets it.
365
+
366
+ After the last step, σ[i] is the sign of the z component. beta and gamma
367
+ must have the same length, the number of steps p. `linear_schedule`
368
+ builds the usual choice; any other pair of vectors can be passed here.
369
+ """
370
+
371
+ def __init__(self, beta, gamma):
372
+ """Check and store the angles.
373
+
374
+ Raises:
375
+ ValueError: beta and gamma are not vectors of the same, nonzero
376
+ length, or an angle is infinite or NaN.
377
+ """
378
+ beta = _vector(beta, "beta")
379
+ gamma = _vector(gamma, "gamma")
380
+ if beta.shape != gamma.shape:
381
+ raise ValueError(
382
+ f"beta and gamma must have one entry per step; got {beta.shape[0]} and {gamma.shape[0]}"
383
+ )
384
+ if beta.shape[0] == 0:
385
+ raise ValueError("a schedule needs at least one step")
386
+ self.beta = beta
387
+ self.gamma = gamma
388
+
389
+ def __len__(self):
390
+ """The number of steps p."""
391
+ return self.beta.shape[0]
392
+
393
+ def __repr__(self):
394
+ """The number of steps."""
395
+ return f"Schedule: {len(self)} steps"
396
+
397
+
398
+ def linear_schedule(p, tau=0.5):
399
+ """The usual schedule: over p steps, the angles gamma grow and the angles
400
+ beta shrink linearly,
401
+
402
+ gamma[k] = tau (k + 1) / p, beta[k] = tau (1 - k / p), k = 0, …, p - 1.
403
+
404
+ This is Eq. 13 of Misra-Spieldenner et al., PRX Quantum 4, 030335 (2023).
405
+ tau sets the size of the angles. Because gamma is measured in units of
406
+ the problem's `fieldscale`, the same tau suits problems of any size and
407
+ units; values between 0.25 and 0.5 work well on typical instances. With
408
+ more steps, the angles change more slowly from one step to the next.
409
+ That usually gives a better solution, and the time of a solve grows in
410
+ proportion to p.
411
+
412
+ Raises:
413
+ TypeError: p is not an integer.
414
+ ValueError: p is less than 1, or tau is not positive and finite.
415
+ """
416
+ try:
417
+ p = operator.index(p)
418
+ except TypeError:
419
+ raise TypeError(f"p, the number of steps, must be an integer, got {p!r}") from None
420
+ if p < 1:
421
+ raise ValueError(f"need at least one step, got p = {p}")
422
+ tau = float(tau)
423
+ if not (tau > 0 and math.isfinite(tau)):
424
+ raise ValueError(f"tau must be positive and finite, got {tau}")
425
+ return Schedule(*_lib.linear_schedule(p, tau))
426
+
427
+
428
+ # The sparse product beats a dense one below about 25-30 % fill, measured for
429
+ # MFAOA.jl on an Apple M4 with OpenBLAS for n = 500 to 2000.
430
+ DENSE_FROM = 0.25
431
+
432
+
433
+ def coupling_storage(A):
434
+ """Choose dense or sparse storage for a matrix of pair weights.
435
+
436
+ The Pyomo interface and the dimod sampler collect the terms of an
437
+ objective into a sparse matrix. The solve is faster with a dense one once
438
+ the pairs that A couples, the pattern of A + A.T, fill a quarter of it.
439
+
440
+ Args:
441
+ A: a square SciPy sparse matrix.
442
+
443
+ Returns:
444
+ A as a dense NumPy array when that pattern fills at least DENSE_FROM
445
+ of the matrix, and otherwise A itself.
446
+ """
447
+ n = A.shape[0]
448
+ pattern = abs(A) + abs(A.T)
449
+ return A.toarray() if pattern.nnz >= DENSE_FROM * n * n else A
450
+
451
+
452
+ def schedule_from(p=None, tau=None, beta=None, gamma=None):
453
+ """The schedule that a front end's options ask for.
454
+
455
+ The Pyomo interface and the dimod sampler take the same four options as
456
+ `solve`.
457
+
458
+ Args:
459
+ p: the number of steps of the linear schedule (default 1000).
460
+ tau: its step size (default 0.5).
461
+ beta, gamma: an explicit schedule, used instead of the linear one.
462
+
463
+ Returns:
464
+ A `Schedule`: from beta and gamma when both are given, else the
465
+ linear schedule with p and tau.
466
+
467
+ Raises:
468
+ TypeError, ValueError: only one of beta and gamma, or values that
469
+ `Schedule` or `linear_schedule` reject.
470
+ """
471
+ if (beta is None) != (gamma is None):
472
+ raise ValueError("Set both beta and gamma for a schedule of your own, or neither.")
473
+ if beta is not None:
474
+ return Schedule(beta, gamma)
475
+ return linear_schedule(1000 if p is None else p, 0.5 if tau is None else tau)