Source code for uniqc.algorithms.workflows.qaoa_workflow

"""Lightweight QAOA workflow built on ``qaoa_ansatz`` + ``pauli_expectation``.

Mirrors :mod:`uniqc.algorithms.workflows.vqe_workflow`: scipy-based optimiser,
no torch / torchquantum dependency. The cost Hamiltonian is supplied as a list
of ``(pauli_string, coefficient)`` terms, identical to the VQE workflow.
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass, field

import numpy as np

from uniqc.algorithms.core.ansatz.qaoa_ansatz import qaoa_ansatz
from uniqc.algorithms.core.measurement.pauli_expectation import pauli_expectation
from uniqc.circuit_builder import Circuit

__all__ = ["QAOAResult", "run_qaoa_workflow"]


[docs] @dataclass class QAOAResult: """Outcome of :func:`run_qaoa_workflow`. Attributes: energy: Minimum cost-function value ⟨H_C⟩ found. gammas: Optimal γ angles, length ``p``. betas: Optimal β angles, length ``p``. history: Per-iteration energies (in evaluation order). n_iter: Number of objective evaluations. success: Whether the underlying scipy optimiser reported success. message: Optimiser termination message. """ energy: float gammas: np.ndarray betas: np.ndarray history: list[float] = field(default_factory=list) n_iter: int = 0 success: bool = True message: str = ""
def _energy(circuit: Circuit, hamiltonian: Sequence[tuple[str, float]], shots: int | None) -> float: total = 0.0 for pauli, coeff in hamiltonian: total += float(coeff) * pauli_expectation(circuit, pauli, shots=shots) return total
[docs] def run_qaoa_workflow( cost_hamiltonian: Sequence[tuple[str, float]], *, n_qubits: int | None = None, p: int = 1, init_gammas: np.ndarray | None = None, init_betas: np.ndarray | None = None, shots: int | None = None, method: str = "COBYLA", options: dict | None = None, ) -> QAOAResult: """Run a scipy-driven QAOA on ``cost_hamiltonian``. Args: cost_hamiltonian: ``(pauli_string, coefficient)`` terms of H_C. n_qubits: Number of qubits. Inferred from the first Pauli term if ``None``. p: QAOA depth (number of γ/β layers). init_gammas: Optional initial γ vector of length ``p``. Defaults to a uniform-random vector in ``[0, π]``. init_betas: Optional initial β vector of length ``p``. Defaults to a uniform-random vector in ``[0, π/2]``. shots: Shots per Pauli-term expectation. ``None`` uses the analytic statevector estimator. method: ``scipy.optimize.minimize`` method. Defaults to ``COBYLA`` (gradient-free). options: Optional dict forwarded to ``scipy.optimize.minimize``. Returns: :class:`QAOAResult` with the optimised γ / β and minimum energy. Example: >>> # MaxCut on a 2-edge path graph 0-1-2: H_C = (Z0Z1 + Z1Z2 - 2)/2 >>> H = [("ZZI", 0.5), ("IZZ", 0.5), ("III", -1.0)] >>> result = run_qaoa_workflow(H, n_qubits=3, p=1) # doctest: +SKIP >>> result.energy <= -0.5 # doctest: +SKIP True """ from scipy.optimize import minimize hamiltonian = list(cost_hamiltonian) if not hamiltonian: raise ValueError("cost_hamiltonian must contain at least one term") if n_qubits is None: n_qubits = len(hamiltonian[0][0]) for pauli, _ in hamiltonian: if len(pauli) != n_qubits: raise ValueError(f"All Pauli terms must have length {n_qubits}, got {pauli!r}") if p < 1: raise ValueError("p must be a positive integer") rng = np.random.default_rng(0) if init_gammas is None: init_gammas = rng.uniform(0.0, np.pi, size=p) if init_betas is None: init_betas = rng.uniform(0.0, np.pi / 2, size=p) if len(init_gammas) != p or len(init_betas) != p: raise ValueError(f"init_gammas / init_betas must have length p={p}") history: list[float] = [] def _to_indexed(pauli: str) -> str: """Convert 'ZZI' (positional) to 'Z0Z1' (qaoa_ansatz format).""" parts = [] for i, ch in enumerate(pauli): if ch.upper() != "I": parts.append(f"{ch.upper()}{i}") return "".join(parts) # qaoa_ansatz needs the indexed form and skips constant (all-I) terms, # which only contribute a global phase. Pre-compute once. indexed_cost = [] constant_offset = 0.0 for pauli, coeff in hamiltonian: if all(ch.upper() == "I" for ch in pauli): constant_offset += float(coeff) continue indexed_cost.append((_to_indexed(pauli), float(coeff))) if not indexed_cost: raise ValueError("cost_hamiltonian must contain at least one non-identity term") def objective(packed: np.ndarray) -> float: gammas = packed[:p] betas = packed[p:] circuit = qaoa_ansatz( indexed_cost, p=p, gammas=gammas, betas=betas, ) for q in range(n_qubits): circuit.measure(q) # _energy expects fixed-length ZZI form; use original hamiltonian # so the constant offset is included automatically (pauli_expectation # of all-I returns 1.0 by definition). e = _energy(circuit, hamiltonian, shots) history.append(e) return e x0 = np.concatenate([np.asarray(init_gammas, dtype=float), np.asarray(init_betas, dtype=float)]) if options is None and method.upper() == "COBYLA": options = {"maxiter": 200, "rhobeg": 0.1} res = minimize(objective, x0, method=method, options=options) g_opt = np.asarray(res.x[:p]) b_opt = np.asarray(res.x[p:]) return QAOAResult( energy=float(res.fun), gammas=g_opt, betas=b_opt, history=history, n_iter=int(getattr(res, "nfev", len(history))), success=bool(res.success), message=str(res.message), )
[docs] def run_qaoa_workflow_example() -> QAOAResult: """Smallest non-trivial example: 3-node path-graph MaxCut, p=1.""" h_c = [("ZZI", 0.5), ("IZZ", 0.5), ("III", -1.0)] return run_qaoa_workflow(h_c, n_qubits=3, p=1, shots=None)