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 +35 -0
- mfaoa/_lib.py +184 -0
- mfaoa/_problems.py +475 -0
- mfaoa/_solve.py +91 -0
- mfaoa/_symmetry.py +129 -0
- mfaoa/dimod.py +102 -0
- mfaoa/mfaoa.dll +0 -0
- mfaoa/pyomo.py +393 -0
- mfaoa-0.1.0.dist-info/METADATA +417 -0
- mfaoa-0.1.0.dist-info/RECORD +12 -0
- mfaoa-0.1.0.dist-info/WHEEL +5 -0
- mfaoa-0.1.0.dist-info/licenses/LICENSE +201 -0
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)
|