Source code for quantum_mind.computing.solvers

"""Solvers for robot QUBO problems: classical baselines and a thin Qiskit QAOA adapter.

The classical solvers (:func:`exhaustive`, :func:`simulated_annealing`, :func:`greedy_descent`) are the baselines a
quantum result must be reported against. :func:`qaoa` does not re-implement QAOA: it hands the problem's Ising
Hamiltonian to Qiskit's own ``QAOAAnsatz`` and simulates the circuit exactly, so the algorithm is Qiskit's and
the only thing added here is the connection to the robot problems and the bookkeeping (energy, probability of the
optimum, best sampled solution).
"""
from __future__ import annotations

import warnings
from dataclasses import dataclass, field

import numpy as np

from .._optional import require

__all__ = ['SolverResult', 'exhaustive', 'simulated_annealing', 'greedy_descent', 'random_sampling', 'qaoa']


[docs] @dataclass class SolverResult: """What a solver returns. Attributes ---------- x : numpy.ndarray Best bit string found. energy : float QUBO energy of ``x``. method : str info : dict Method-specific details (for QAOA: ``expected_energy``, ``p_optimal``, ``depth``). """ x: np.ndarray energy: float method: str info: dict = field(default_factory=dict)
def _coupling(qubo): """Upper-triangular linear terms and the symmetric off-diagonal coupling used for single-bit flips.""" U = qubo.normalised().Q lin = np.diag(U).copy() off = U - np.diag(lin) return lin, off + off.T
[docs] def exhaustive(qubo): """Exact optimum by enumeration (at most 22 variables). Returns ------- SolverResult """ x, e = qubo.brute_force() return SolverResult(x, e, 'exhaustive')
[docs] def simulated_annealing(qubo, sweeps=1000, restarts=10, t_start=None, t_end=None, seed=0): """Single-bit-flip simulated annealing with a geometric schedule (classical baseline). Single flips cannot move between two feasible assignments without crossing the penalty barrier of the encoding, so on penalty QUBOs this solver is often weaker than :func:`greedy_descent` with many restarts; report both. Parameters ---------- qubo : Qubo sweeps : int, optional Sweeps over all variables per restart. restarts : int, optional Independent restarts; the best result is returned. t_start, t_end : float, optional Start and end temperature (defaults from the largest energy change of one flip). seed : int, optional Returns ------- SolverResult """ rng = np.random.default_rng(seed) lin, sym = _coupling(qubo) n = qubo.n if t_start is None or t_end is None: # temperatures from the energy changes of random flips X = rng.integers(0, 2, (64, n)).astype(float) steps = np.abs((1 - 2 * X) * (lin + X @ sym.T)).ravel() steps = steps[steps > 1e-12] typical = float(np.mean(steps)) if len(steps) else 1.0 t_start = t_start or typical t_end = t_end or typical * 0.01 t0, t1 = t_start, t_end best_x, best_e = None, np.inf for _ in range(restarts): x = rng.integers(0, 2, n).astype(float) for sweep in range(sweeps): T = t0 * (t1 / t0) ** (sweep / max(sweeps - 1, 1)) for i in rng.permutation(n): d = 1 - 2 * x[i] delta = d * (lin[i] + sym[i] @ x) if delta <= 0 or rng.random() < np.exp(-delta / T): x[i] += d while True: # final quench: descend to the nearest local minimum delta = (1 - 2 * x) * (lin + sym @ x) i = int(np.argmin(delta)) if delta[i] >= -1e-12: break x[i] += 1 - 2 * x[i] e = qubo.energy(x) if e < best_e: best_x, best_e = x.copy(), e return SolverResult(best_x.astype(int), float(best_e), 'simulated annealing')
[docs] def greedy_descent(qubo, restarts=20, seed=0): """Best-improvement single-bit descent from random starts (the simplest classical baseline). Returns ------- SolverResult """ rng = np.random.default_rng(seed) lin, sym = _coupling(qubo) best_x, best_e = None, np.inf for _ in range(restarts): x = rng.integers(0, 2, qubo.n).astype(float) while True: delta = (1 - 2 * x) * (lin + sym @ x) i = int(np.argmin(delta)) if delta[i] >= -1e-12: break x[i] += 1 - 2 * x[i] e = qubo.energy(x) if e < best_e: best_x, best_e = x.copy(), e return SolverResult(best_x.astype(int), float(best_e), 'greedy descent')
[docs] def random_sampling(qubo, shots=1000, seed=0): """Best of ``shots`` uniformly random bit strings: the null baseline for any sampling-based solver. A quantum sampler that returns the best of ``shots`` samples must beat this with the same ``shots``, otherwise its success only reflects how many samples it drew from a small search space. Returns ------- SolverResult """ rng = np.random.default_rng(seed) X = rng.integers(0, 2, (shots, qubo.n)) e = qubo.energy(X.astype(float)) k = int(np.argmin(e)) return SolverResult(X[k], float(e[k]), 'random sampling', dict(shots=shots))
[docs] def qaoa(qubo, depth=2, restarts=3, shots=1000, maxiter=150, seed=0): """QAOA through Qiskit's ``QAOAAnsatz``, simulated exactly (at most 16 qubits). The circuit parameters are optimised classically (COBYLA) to minimise the expected QUBO energy; the returned solution is the lowest-energy bit string among ``shots`` samples of the final state, as on hardware. Parameters ---------- qubo : Qubo depth : int, optional Number of QAOA layers ``p``. restarts : int, optional Random starting angles; the best expected energy is kept. shots : int, optional Samples drawn from the optimised state. maxiter : int, optional COBYLA iterations per restart. seed : int, optional Returns ------- SolverResult ``info`` holds ``expected_energy``, ``p_optimal`` (probability mass on the exact optimum), ``depth`` and ``ratio`` (expected energy over the optimum, for energies measured from the worst case). Raises ------ ImportError If Qiskit is not installed (``pip install "quantum-mind[qiskit]"``). ValueError If the problem has more than 16 variables. """ require('qiskit') from qiskit.circuit.library import QAOAAnsatz from qiskit.quantum_info import SparsePauliOp, Statevector from scipy.optimize import minimize if qubo.n > 16: raise ValueError('the exact simulation supports at most 16 qubits') n = qubo.n h, J, _ = qubo.to_ising() terms = [('Z', [i], float(h[i])) for i in range(n) if abs(h[i]) > 1e-12] terms += [('ZZ', [i, j], float(J[i, j])) for i in range(n) for j in range(i + 1, n) if abs(J[i, j]) > 1e-12] if not terms: terms = [('Z', [0], 0.0)] cost_op = SparsePauliOp.from_sparse_list(terms, num_qubits=n) energies = qubo.all_energies() e_min, e_max = float(energies.min()), float(energies.max()) with warnings.catch_warnings(): warnings.simplefilter('ignore') ansatz = QAOAAnsatz(cost_op, reps=depth).decompose(reps=4) # basis gates: far faster to simulate rng = np.random.default_rng(seed) def probs(theta): return Statevector(ansatz.assign_parameters(theta)).probabilities() def objective(theta): return float(probs(theta) @ energies) best = None for _ in range(restarts): theta0 = rng.uniform(0, np.pi, ansatz.num_parameters) res = minimize(objective, theta0, method='COBYLA', options={'maxiter': maxiter}) if best is None or res.fun < best.fun: best = res p = probs(best.x) k = rng.choice(len(p), size=shots, p=p / p.sum()) k_best = int(k[np.argmin(energies[k])]) x = np.array([(k_best >> i) & 1 for i in range(n)]) p_opt = float(p[np.isclose(energies, e_min)].sum()) ratio = (e_max - best.fun) / (e_max - e_min) if e_max > e_min else 1.0 return SolverResult(x, float(energies[k_best]), 'QAOA (Qiskit)', dict(expected_energy=float(best.fun), p_optimal=p_opt, depth=depth, ratio=float(ratio)))